TiGa-RCE commited on
Commit
6c60ea0
·
verified ·
1 Parent(s): 5e3e294

Add distinct winners, frozen evaluation and private-bucket recipes

Browse files
BINARY_PROVENANCE.json CHANGED
@@ -1,72 +1 @@
1
- {
2
- "artifact_origins": {
3
- "bin/altd-sim": {
4
- "actions_run_id": 38203558700,
5
- "archive_member": "altd-sim-nightly-x86_64-unknown-linux-gnu/altd-sim",
6
- "bytes": 15774792,
7
- "evidence_path": "build/receipts/upstream-altd-sim-nightly-x86_64-unknown-linux-gnu.tar.gz",
8
- "evidence_sha256": "15ddf3cc8748a13ab123226ed328fb65acfaade08351f97a93686223aa2cceed",
9
- "origin_type": "github_actions_archive_member",
10
- "run_conclusion": "success",
11
- "sha256": "0135ecc3d435a9d327be7a92e048cfb5c5291a02ef1e0dddd6ead1cc19b31166",
12
- "source_archive_sha256": "15ddf3cc8748a13ab123226ed328fb65acfaade08351f97a93686223aa2cceed",
13
- "source_repository": "https://github.com/Zorro909/driving_sim_rs",
14
- "source_revision": "b90b8af06ffc4ea4e4fd703becd1107a48b8e5e0"
15
- },
16
- "bin/extract_saved_track": {
17
- "build_receipt_path": "cpu/IMPORTER_BUILD_RECEIPT.json",
18
- "build_receipt_sha256": "4ba5c533cc617105478e4d85c83f456a7dfa68b1388049624619871ba9e42e9d",
19
- "bytes": 3247776,
20
- "compatibility_receipt": "build/receipts/build-receipt.json",
21
- "evidence_path": "cpu/IMPORTER_BUILD_RECEIPT.json",
22
- "evidence_sha256": "4ba5c533cc617105478e4d85c83f456a7dfa68b1388049624619871ba9e42e9d",
23
- "origin_type": "retained_verified_importer_binary",
24
- "sha256": "3a7733c45b4301a786fcbfe92660a1c484749272da5f608d62f34878bd4ff31a",
25
- "source_repository": "https://github.com/Zorro909/driving_sim_rs",
26
- "source_revision": "d8ab74c25df70b6a86ed101080e33d5fb785ea6f"
27
- },
28
- "bin/libaltd_gpu.so": {
29
- "build_command": "CUDA_ARCH=sm_86,sm_120 OUT=/work/artifacts/gpu bash /work/source/gpu/build-cuda.sh",
30
- "build_log_path": "build/receipts/cuda-build.log",
31
- "build_log_sha256": "c4ea4bde35aa972644dd3d6ad7511d097736547ab2afb216920e67b26d954be6",
32
- "builder_image": "nvidia/cuda:13.0.2-devel-ubuntu22.04",
33
- "builder_image_digest": "sha256:1c517d4fb96528c8999e14e6e6b16d7e2ff9ae8d194fd788d60fdc88e693e982",
34
- "bytes": 4711704,
35
- "compiled_architectures": [
36
- "sm_86",
37
- "sm_120",
38
- "compute_120 PTX"
39
- ],
40
- "evidence_path": "build/source-b90b8af.tar.gz",
41
- "evidence_sha256": "e9e561c6d9c56cedf344aaf9d91f584a4d5730ccd26ac5a2bfa23c3eed9204e6",
42
- "origin_type": "local_cuda_build",
43
- "sha256": "3b1b88bccc06a21acc33ac4400af016d55a7e3d8727136a0c43b541f3c7887ca",
44
- "source_archive_path": "build/source-b90b8af.tar.gz",
45
- "source_archive_sha256": "e9e561c6d9c56cedf344aaf9d91f584a4d5730ccd26ac5a2bfa23c3eed9204e6",
46
- "source_repository": "https://github.com/Zorro909/driving_sim_rs",
47
- "source_revision": "b90b8af06ffc4ea4e4fd703becd1107a48b8e5e0"
48
- }
49
- },
50
- "artifacts": {
51
- "bin/altd-sim": {
52
- "bytes": 15774792,
53
- "sha256": "0135ecc3d435a9d327be7a92e048cfb5c5291a02ef1e0dddd6ead1cc19b31166"
54
- },
55
- "bin/extract_saved_track": {
56
- "bytes": 3247776,
57
- "sha256": "3a7733c45b4301a786fcbfe92660a1c484749272da5f608d62f34878bd4ff31a"
58
- },
59
- "bin/libaltd_gpu.so": {
60
- "bytes": 4711704,
61
- "sha256": "3b1b88bccc06a21acc33ac4400af016d55a7e3d8727136a0c43b541f3c7887ca"
62
- }
63
- },
64
- "build_receipt": "../build/receipts/build-receipt.json",
65
- "build_receipt_sha256": "789806b8d70b73a718e889fb202171c18b50070e04a70ab57af9b93d0ad58a76",
66
- "github_actions_run": 38203558700,
67
- "importer_source_revision": "d8ab74c25df70b6a86ed101080e33d5fb785ea6f",
68
- "note": "The simulator is the exact b90 Linux Actions artifact; the CUDA library is built from the same b90 source for sm_86 and sm_120 with compute_120 PTX. Rust/C layout ABI validation passed. No CUDA device or kernel execution was available in the local builder; hosted CUDA qualification remains separate. The retained d8 track importer is separately identified and bounded compatibility is linked.",
69
- "run_conclusion": "success",
70
- "source_repository": "https://github.com/Zorro909/driving_sim_rs",
71
- "source_revision": "b90b8af06ffc4ea4e4fd703becd1107a48b8e5e0"
72
- }
 
1
+ {"artifact_origins":{"bin/altd-sim":{"build_receipt_path":"native/build-receipt.json","build_receipt_sha256":"b2336433fc14ddf8b7722faa8ca434eff6f73ec41ec6d48cc0e9bf230f5efb3d","bytes":3580296,"origin_type":"local_linux_build","patch_sha256":"56859bc55a946ea129d502868387f8441cbd37c30962fe3e7e484d313c4ccf63","sha256":"6488c7ec651701841999687708ff128a8ffb0b88feb53e23168a3ce678b5393d","source_receipt_sha256":"33d09c326e4d6c3ac1d1839e4528c70c06b4ff48138b90f174f434ab685b4b34","source_revision":"b90b8af06ffc4ea4e4fd703becd1107a48b8e5e0"},"bin/extract_saved_track":{"build_receipt_path":"IMPORTER_BUILD_RECEIPT.json","build_receipt_sha256":"4ba5c533cc617105478e4d85c83f456a7dfa68b1388049624619871ba9e42e9d","bytes":3247776,"compatibility_receipt_path":"native/build-receipt.json","compatibility_receipt_sha256":"b2336433fc14ddf8b7722faa8ca434eff6f73ec41ec6d48cc0e9bf230f5efb3d","evidence_path":"IMPORTER_BUILD_RECEIPT.json","evidence_sha256":"4ba5c533cc617105478e4d85c83f456a7dfa68b1388049624619871ba9e42e9d","origin_type":"retained_verified_importer_binary","sha256":"3a7733c45b4301a786fcbfe92660a1c484749272da5f608d62f34878bd4ff31a","source_repository":"https://github.com/Zorro909/driving_sim_rs","source_revision":"d8ab74c25df70b6a86ed101080e33d5fb785ea6f"},"bin/libaltd_gpu.so":{"build_receipt_path":"native/build-receipt.json","build_receipt_sha256":"b2336433fc14ddf8b7722faa8ca434eff6f73ec41ec6d48cc0e9bf230f5efb3d","bytes":4711704,"origin_type":"retained_cuda_b90","patch_sha256":"56859bc55a946ea129d502868387f8441cbd37c30962fe3e7e484d313c4ccf63","sha256":"3b1b88bccc06a21acc33ac4400af016d55a7e3d8727136a0c43b541f3c7887ca","source_receipt_sha256":"33d09c326e4d6c3ac1d1839e4528c70c06b4ff48138b90f174f434ab685b4b34","source_revision":"b90b8af06ffc4ea4e4fd703becd1107a48b8e5e0"}},"artifacts":{"bin/altd-sim":{"bytes":3580296,"sha256":"6488c7ec651701841999687708ff128a8ffb0b88feb53e23168a3ce678b5393d"},"bin/extract_saved_track":{"bytes":3247776,"sha256":"3a7733c45b4301a786fcbfe92660a1c484749272da5f608d62f34878bd4ff31a"},"bin/libaltd_gpu.so":{"bytes":4711704,"sha256":"3b1b88bccc06a21acc33ac4400af016d55a7e3d8727136a0c43b541f3c7887ca"}},"build_receipt":"native/build-receipt.json","build_receipt_path":"native/build-receipt.json","build_receipt_sha256":"b2336433fc14ddf8b7722faa8ca434eff6f73ec41ec6d48cc0e9bf230f5efb3d","importer_source_revision":"d8ab74c25df70b6a86ed101080e33d5fb785ea6f","native_patch_sha256":"56859bc55a946ea129d502868387f8441cbd37c30962fe3e7e484d313c4ccf63","note":"The simulator is a fresh Linux build from b90 plus the hashed local analysis patch. The CUDA library is retained b90 and has layout ABI evidence only; this package does not claim local GPU kernel execution. The track importer remains separately identified with bounded compatibility evidence.","source_repository":"https://github.com/Zorro909/driving_sim_rs","source_revision":"b90b8af06ffc4ea4e4fd703becd1107a48b8e5e0"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
BUCKET_BUNDLE.md CHANGED
@@ -1,4 +1,4 @@
1
- # Portable bucket bundle v1
2
 
3
  The browser supports explicit local-file export/import and optional direct save/restore to an existing private bucket. Local export downloads one JSON bundle. Restore validates the complete bundle before staging the network and any optional track or recipe in the current page. A restored receipt is kept in page memory and offered as a separate download. Restore never starts a run or writes browser storage.
4
 
@@ -6,11 +6,21 @@ Bucket storage starts disconnected. After an explicit HF OAuth authorization for
6
 
7
  ## Envelope
8
 
9
- The only top-level fields are `schema`, `version`, and `artifacts`. The schema is `ald.portable-bundle`, version `1`. The required artifact slot is `selected_network`; optional fixed slots are `selected_track`, `selected_recipe`, and `run_receipt`. Unknown slots or fields are rejected.
10
 
11
  - A network stores its original `.evolution` bytes as base64 with byte length, SHA-256, format `evolution`, and kind `input` or `result`. The page validates decoded network content with its existing game-network validator and keeps the original bytes when staging it.
12
  - A track stores original `.track` bytes with length and SHA-256. A built-in track also records its catalog ID and display label; an uploaded track has no extra identifying metadata.
13
  - A recipe stores only version, A–J slot, name, and the existing validated training settings. Restore stages it into the form; saving it to the browser recipe library remains a separate user action.
14
- - A run receipt is an allowlisted summary only: build ID, backend, output hash, whitelisted report fields for vehicle, counts, metrics, file hashes, and settings, plus up to 1,000 validated generation-history rows. It excludes paths, filenames, usernames, account data, job IDs/keys, and raw errors. Receipts that do not match the bundled network or included track are omitted on export or rejected on import. A connected auto-save bundles the exact verified run output, its captured input track, and matching run receipt.
15
 
16
- Caps are 4 MiB for network bytes, 1 MiB for track bytes, 32 KiB for recipe JSON, 512 KiB for receipt JSON, and 8 MiB for the complete bundle. Payload bytes and hashes must match exactly.
 
 
 
 
 
 
 
 
 
 
 
1
+ # Portable bucket bundles v1 and v2
2
 
3
  The browser supports explicit local-file export/import and optional direct save/restore to an existing private bucket. Local export downloads one JSON bundle. Restore validates the complete bundle before staging the network and any optional track or recipe in the current page. A restored receipt is kept in page memory and offered as a separate download. Restore never starts a run or writes browser storage.
4
 
 
6
 
7
  ## Envelope
8
 
9
+ The only top-level fields are `schema`, `version`, and `artifacts`. The schema is `ald.portable-bundle`. Legacy version `1` remains readable. Version `2` adds a validated analysis archive for new training and frozen-evaluation results. The required artifact slot is `selected_network`; optional fixed slots are `selected_track`, `selected_recipe`, and `run_receipt`. Version 2 also carries `analysis_archive`. Unknown slots or fields are rejected.
10
 
11
  - A network stores its original `.evolution` bytes as base64 with byte length, SHA-256, format `evolution`, and kind `input` or `result`. The page validates decoded network content with its existing game-network validator and keeps the original bytes when staging it.
12
  - A track stores original `.track` bytes with length and SHA-256. A built-in track also records its catalog ID and display label; an uploaded track has no extra identifying metadata.
13
  - A recipe stores only version, A–J slot, name, and the existing validated training settings. Restore stages it into the form; saving it to the browser recipe library remains a separate user action.
14
+ - A run receipt is an allowlisted summary only: build ID, backend, output hash, whitelisted report fields for vehicle, counts, metrics, file hashes, and settings, plus up to 1,000 validated generation-history rows. It excludes paths, filenames, usernames, account data, job IDs/keys, and raw errors. Legacy receipts must match the bundled network and included track. With v2, the selected network may be any manifested winner; the run receipt retains the original fitness-result identity, and the validator checks both bindings independently. Frozen evaluation binds the selected network to its unchanged input. A connected auto-save bundles the exact verified run output, its captured input track, and matching run receipt.
15
 
16
+ Caps are 4 MiB for network bytes, 1 MiB for track bytes, 32 KiB for recipe JSON, 512 KiB for receipt JSON, and 48 MiB for the complete bundle. Payload bytes and hashes must match exactly.
17
+
18
+ ## Analysis archive
19
+
20
+ The v2 `artifacts.analysis_archive` slot has `format: "ald.run-analysis-archive+gzip"`, `bytes`, `sha256`, and `data_base64`. Its bounded gzip payload expands to one JSON envelope with schema `ald.run-analysis-archive`, version 1, and a list of fixed-name files. Each entry carries its name, byte count, SHA-256 and base64 contents. The compressed cap is 32 MiB; expanded JSON is capped at 256 MiB, with an aggregate 192 MiB cap on decoded members to leave room for base64 overhead. Descriptors contain at most 128,000 complete car observations. Exceeding a limit is an error, never permission to truncate.
21
+
22
+ Required members are `analysis.json` and `descriptors.jsonl`. Train results include `winners/fitness.evolution`, plus fastest-lap and clean-lap files only when those categories have qualifying candidates. Frozen evaluation includes `evaluation.json` and no new winner files. Descriptors use the exact 18-channel order declared by the analysis manifest, storing compact value, population-relative rank, and unavailable-reason arrays.
23
+
24
+ Bucket saves preserve the canonical bundle, readable report/history, compressed archive, complete descriptor and metadata files, and individual winner networks. Every uploaded object is read back and checked before saving is reported as verified. Files live under a new run-specific prefix. The app never deletes existing bucket content or overwrites a previous run.
25
+
26
+ The local CLI can include a downloaded archive with `pack --analysis`; unpacking v2 restores its fixed analysis subdirectories into a new destination. Remote CLI operations remain explicit and use the local HF credential store.
DATA_POLICY.md CHANGED
@@ -34,16 +34,17 @@ The browser holds a random job access key in memory, independently of optional H
34
  Anyone who obtains that key can access the job; do not share it. Reloading the
35
  page loses access. Expiry is fixed at five minutes from result publication.
36
 
37
- Automatic deletion is the default. The browser validates the complete download
38
- and acknowledges receipt; that deletes the result store's copy. One server
39
- download attempt is permitted in the default mode. We cannot verify that the
40
- browser saved to disk. An interrupted default-mode download is not retryable;
41
- Delete now and fixed expiry still work. Visitors can opt in for each single run
42
- to keep its result in application memory for repeat downloads until the same
43
- five-minute deadline when bucket autosave is disconnected or off. The checkbox resets after every run. Batch runs always
44
- use automatic deletion. Delete now removes a stored result immediately, and
45
- downloads never reset expiry. The browser can also save its received result
46
- again from page memory.
 
47
 
48
  Access is refused at the deadline. While the server is running, the expiry loop
49
  releases expired stored data within approximately one second, subject to runtime
@@ -118,8 +119,8 @@ The displayed value is an insertion-position estimate against the fetched raw gl
118
 
119
  ## Optional private bucket storage
120
 
121
- Bucket storage starts disconnected. After an explicit HF OAuth authorization and connection to an existing private bucket, automatic saving is on by default and can be turned off for manual saves. With no connection or autosave turned off, completed runs make no bucket storage calls. With autosave enabled, each completed run is downloaded and hash-verified in the browser, then the application confirms deletion of the server result before it sends the resulting network and sanitized run report to the bucket. Each batch result is saved before the next run starts; a save failure stops the batch. A failed receive has no verified local copy; a deletion-acknowledgment failure skips bucket saving; after confirmed deletion, an unconfirmed bucket save leaves the verified local candidate available for download or manual retry. A remote save might have completed if its response could not be verified.
122
 
123
  HF describes the requested `write-repos` scope as read/write access to personal repositories. This grant is broader than one selected bucket and has not been confirmed as bucket-only or the minimum required scope. The token and bundle pass through this app server during storage operations; the app keeps the token in page memory only and removes temporary storage files after operations. It does not save tokens in URLs, browser storage, receipts or bundles. Disconnect, observed authorization denial/expiry, and page close clear the app's connection. The app has no bucket create, delete, overwrite or visibility controls; saved objects remain in the user's bucket until managed elsewhere. Provider-side logs and retention are outside app cleanup guarantees, which do not establish provider-wide zero data retention or secure physical erasure.
124
 
125
- Connecting checks the listed bundles and selects the newest compatible entry among those returned. It restores the entry in this page only when there is no existing network, track, result or editor draft; otherwise it only selects it. The list is bounded, so an incomplete scan may omit a newer bundle. No run starts automatically. Reports contain only fields returned by the engine, including generation history and configured settings; reward weights are not reported as per-reward contributions.
 
34
  Anyone who obtains that key can access the job; do not share it. Reloading the
35
  page loses access. Expiry is fixed at five minutes from result publication.
36
 
37
+ Automatic deletion is the default. New results contain a primary network and a
38
+ complete analysis archive. The browser verifies the size and hash of both before
39
+ acknowledging receipt; that acknowledgment deletes both stored payloads together.
40
+ Both remain retryable under the same job key until acknowledgment or the fixed
41
+ five-minute expiry. Retrying does not extend expiry. Older primary-only result
42
+ paths retain their one-attempt automatic delivery rule. We cannot verify that the
43
+ browser saved to disk. Delete now removes the entire result immediately.
44
+ Visitors can opt in for each single run to keep its result in application memory
45
+ until the same deadline when bucket autosave is disconnected or off. The checkbox
46
+ resets after every run. Batch and comparison runs use automatic deletion.
47
+ The browser can save received networks and analysis again from page memory.
48
 
49
  Access is refused at the deadline. While the server is running, the expiry loop
50
  releases expired stored data within approximately one second, subject to runtime
 
119
 
120
  ## Optional private bucket storage
121
 
122
+ Bucket storage starts disconnected. After an explicit HF OAuth authorization and connection to an existing private bucket, automatic saving is on by default and can be turned off for manual saves. With no connection or autosave turned off, completed runs make no bucket storage calls. With autosave enabled, each completed run is downloaded and hash-verified in the browser, then the application confirms deletion of both server payloads before it sends the resulting network, available winner networks, complete per-car measurements and sanitized run report to the bucket. Each batch result is saved before the next run starts; a save failure stops the batch. A failed receive has no verified local copy; a deletion-acknowledgment failure skips bucket saving; after confirmed deletion, an unconfirmed bucket save leaves the verified local candidate available for download or manual retry. A remote save might have completed if its response could not be verified.
123
 
124
  HF describes the requested `write-repos` scope as read/write access to personal repositories. This grant is broader than one selected bucket and has not been confirmed as bucket-only or the minimum required scope. The token and bundle pass through this app server during storage operations; the app keeps the token in page memory only and removes temporary storage files after operations. It does not save tokens in URLs, browser storage, receipts or bundles. Disconnect, observed authorization denial/expiry, and page close clear the app's connection. The app has no bucket create, delete, overwrite or visibility controls; saved objects remain in the user's bucket until managed elsewhere. Provider-side logs and retention are outside app cleanup guarantees, which do not establish provider-wide zero data retention or secure physical erasure.
125
 
126
+ Connecting checks the listed bundles and selects the newest compatible entry among those returned. It restores the entry in this page only when there is no existing network, track, result or editor draft; otherwise it only selects it. The list is bounded, so an incomplete scan may omit a newer bundle. No run starts automatically. Reports contain fields returned by the engine, including generation history and configured settings. New result archives also include all supported per-car reward measurements, update counts and within-population ranks. These ranks and configured reward weights are labeled separately; they are not absolute cross-run performance scores. Frozen evaluation preserves the input network and records a separate fresh driving result.
FRONTEND_SOURCE.json CHANGED
@@ -1,10 +1 @@
1
- {
2
- "source": "Runner r14 single-engine browser UI; selected .track tile renderer added from simulator atlas",
3
- "source_sha256": "65d1e5f2c4e19b4951b828e82510005ab99a5270cf3dcb9f4eaa0fa1af8c40f8",
4
- "output_sha256": "a145fbb303a85c728a530d98fbea998677c1c62a2589c46af9b8b287e4ce1f48",
5
- "scope": "Frontend only; no engine, private data, track assets, API calls or storage copied",
6
- "status": "Reviewed candidate with optional HF bucket OAuth, connected autosave, unique exports, player reports and latest-network picker; hosted qualification recorded separately",
7
- "track_atlas_sha256": "d97c8900e3e5fb17734b5bd61d1f4f4b196d9be30af9848e2e273ae7e4079ae4",
8
- "client_sha256": "7e4bea3b9d2ffa79533ff6abed54481a3a6530e8152093f388e229c621bed9ec",
9
- "track_map_sha256": "ab5374aa49c7ccec90a787ee8c56cd71beb56b903bf2bbc322783e6f43c009f0"
10
- }
 
1
+ {"base_provenance":{"output_sha256":"a145fbb303a85c728a530d98fbea998677c1c62a2589c46af9b8b287e4ce1f48","scope":"Frontend only; no engine, private data, track assets, API calls or storage copied","source":"Runner r14 single-engine browser UI; selected .track tile renderer added from simulator atlas","source_sha256":"65d1e5f2c4e19b4951b828e82510005ab99a5270cf3dcb9f4eaa0fa1af8c40f8"},"identity_note":"The historical source fields describe the base frontend; local packaged file hashes describe this candidate's derived frontend.","local_customization_id":"v1.2-winners-frozen-evaluation","packaged_frontend_files":{"assets/track-atlas.json":"d97c8900e3e5fb17734b5bd61d1f4f4b196d9be30af9848e2e273ae7e4079ae4","client.js":"946f06d308503a40c9048f07cda5cc580e58556a81e3e3430d9941fe9c2d7c3b","hf-auth.js":"3f3938bbc0935e4e9230c094029ef46e03beea74be199887c50e053e03dafcfe","index.html":"c46f7ba6dccf473956e038bf2028ff6d88b494a49d0c04c7ec0e278fd8eec0b3","track-library.js":"351ec4c026b49420ce963f4310cb000d9e987fd28e6dac5ecd0176509340f6e6","track-map.js":"ab5374aa49c7ccec90a787ee8c56cd71beb56b903bf2bbc322783e6f43c009f0"},"packaged_frontend_sha256":"df48f73011ef440936d4cb7cc2913db5c14e2a40652aa7090a08dc90ad2ad43c"}
 
 
 
 
 
 
 
 
 
README.md CHANGED
@@ -17,3 +17,7 @@ The selected track map previews tile layout and asphalt/dirt/ice directly from t
17
  Application-controlled inputs and intermediate files are temporary. Outputs are held in memory for at most five minutes and deleted sooner after verified download by default; a single-run visitor may explicitly keep the result until that five-minute deadline. This is not a provider-wide zero-data-retention guarantee. Read DATA_POLICY.md and TRACK_LIBRARY.md. Upstream code and license: https://github.com/Zorro909/driving_sim_rs. Open upstream PRs 19 and 21 are not included.
18
 
19
  Feedback issues: https://github.com/seeker-cyber-maker/driving_sim_rs/issues/new.
 
 
 
 
 
17
  Application-controlled inputs and intermediate files are temporary. Outputs are held in memory for at most five minutes and deleted sooner after verified download by default; a single-run visitor may explicitly keep the result until that five-minute deadline. This is not a provider-wide zero-data-retention guarantee. Read DATA_POLICY.md and TRACK_LIBRARY.md. Upstream code and license: https://github.com/Zorro909/driving_sim_rs. Open upstream PRs 19 and 21 are not included.
18
 
19
  Feedback issues: https://github.com/seeker-cyber-maker/driving_sim_rs/issues/new.
20
+
21
+ ## Candidate: outcome archives and frozen evaluation
22
+
23
+ This revision adds distinct fitness, fastest-lap and clean-lap network snapshots; per-car measurements for all supported reward channels; and evaluation without changing weights. Connected bucket saves include the analysis and winner files. See WINNERS_AND_EVALUATION.md for criteria, comparison limits and data handling. The original recipe remains user-controlled. Build-specific acceptance and hosted qualification are recorded separately.
RELEASE_MANIFEST.json CHANGED
@@ -1,84 +1 @@
1
- {
2
- "backend": "cpu+zerogpu",
3
- "files": {
4
- ".gitattributes": "c2ad16718fc51c888d32efc88c8f6e23ebe5fcf6f48e2954f896ba9770f24d72",
5
- "BETA_TESTING.md": "27e2492f9fe87cc04f52de2974b685b44041f7de9fa1155926b66c436f0bc613",
6
- "BINARY_PROVENANCE.json": "825d98f9ae91d88ee08143b11d1af9dd6f889c37f68dbea24163f718759dda3e",
7
- "BUCKET_BUNDLE.md": "8130b374ffa54dd8539910b58b9f1c8d49eb62098db7aa2c560762da9356e25c",
8
- "CUDA_REFERENCE.md": "f7453c3254d244f8b0de41fde5851cb025159f5cea4a4d1a5d8208a955550c6c",
9
- "DATA_POLICY.md": "f6f7057cda3eac7b40122ffe1ead0e0accd496bc0f5bd20fabf83dce83edfc72",
10
- "FEEDBACK.md": "f95b8f6afbf4a360976c0edca5d52f5b3a0b668c9a944d4947b840a7c53a122a",
11
- "FRONTEND_SOURCE.json": "67d3348aa3c119c766f259c97d665d93e6a503e7b0ac7eab2f49a66776efd1e3",
12
- "GODOT_LICENSE": "fb2b3bb82c60f1e76841ba2122eb902f8e39c151476fe21ce10c9a05d1045304",
13
- "IMPORTER_BUILD_RECEIPT.json": "4ba5c533cc617105478e4d85c83f456a7dfa68b1388049624619871ba9e42e9d",
14
- "LICENSE": "53d5851b6440feeae36f72a5f57fcab37858c48b7084765ece4b0c54b9a28ed8",
15
- "README.md": "b7d9e16dc252925f9c0db14b4811afae00de6c6af29439f9a9788791c7cb9a75",
16
- "STORAGE_BOUNDARY.md": "a35197d79e13f868323bff9ad736fa9ef3233649c2cac44ae40bbc4881939310",
17
- "TRACK_LIBRARY.md": "66b3656ed84f137d5b6116a0075731cd467818f555cca0969084aff67875319f",
18
- "UPSTREAM.json": "003235bbdf5e966561797fbe3d8bfc65c7c43d4a887fa54c3e6a96fb7c0c6938",
19
- "app.py": "162486abd6ac7c9281c8e75c15e33373e0fddba2baf6a799dfd54d333fd575d2",
20
- "app_release.py": "7482559c667a665fd3cb6aa8af6c78422a822b515e14589db8306f7fd4860a8b",
21
- "assets/formula_template.json": "80f745df82bb7be45af52785fd582460bcddc63c5a4e1d8375d85b3122db4c6b",
22
- "assets/rally_template.json": "7350a3fe28d9b4da7a0965ae6ea7ca3472fb7885303e342310d63a0dce80851c",
23
- "assets/snowmobile_template.json": "12d30545876011fd6fccdd3e721f9c465938c9c58ac99338205481d5e7ac6742",
24
- "assets/track-atlas.json": "d97c8900e3e5fb17734b5bd61d1f4f4b196d9be30af9848e2e273ae7e4079ae4",
25
- "assets/truck_template.json": "a83bffea4d5e818c135ab3aba68a47aa288d6ad0e7b8bd9c95ed0dea9e9df2f7",
26
- "bin/altd-sim": "0135ecc3d435a9d327be7a92e048cfb5c5291a02ef1e0dddd6ead1cc19b31166",
27
- "bin/extract_saved_track": "3a7733c45b4301a786fcbfe92660a1c484749272da5f608d62f34878bd4ff31a",
28
- "bin/libaltd_gpu.so": "3b1b88bccc06a21acc33ac4400af016d55a7e3d8727136a0c43b541f3c7887ca",
29
- "bucket_storage.py": "a0c17fa2ddde99f25b4cfc017557b4d8826f72ebbf3898a2394f072227ececa6",
30
- "build_info.py": "93d331c0405cd10076a544f87761bb4a00f6556bbbdab8abc12e070c4661bd78",
31
- "builtin_tracks.py": "2ecc2b1d00fe15701b0271d71e1e68d2e7e59c596330879494055f9b2a99c242",
32
- "builtin_tracks/catalog.json": "65006e70a87a5a53e3738166b0895bc4db9f99767dd891d917b68029abb4e80a",
33
- "builtin_tracks/tracks/Replica A01 Beginner.track": "ca5749537450430794e2eade2e0ac72fa141a4f225e13e2f8ef842abb948ff15",
34
- "builtin_tracks/tracks/Replica A02 Sharp Turns.track": "097f1ea8edf041112b690d0638075630716ddc03c7d0b1980ddcbeaf5071c8d4",
35
- "builtin_tracks/tracks/Replica A03 High Speed.track": "88917c63847be1854b989bb28a23af7eeacb964c3e7df9579953aebc9496e6af",
36
- "builtin_tracks/tracks/Replica A04 Dirt.track": "7f4fe58516227b15898d14bb0abb9a0856126d24fcd793884f051f7a4fae521d",
37
- "builtin_tracks/tracks/Replica A05 Two Terrains.track": "c4ee2842920d99cdfe3eebdcad59a77e24b626380acef0d02a39ea1e23823e2a",
38
- "builtin_tracks/tracks/Replica A06 Ice.track": "fccb9a159a06d1b986c6fe7d5bb89c0cabad3a005531f4e0d71f6e37254ada34",
39
- "builtin_tracks/tracks/Replica A07 Three Terrains.track": "a8fa652ad451e2702a23d5f3429eb0b6a6408bf6a2a76a73d549023e481875c6",
40
- "builtin_tracks/tracks/Replica A08 Long.track": "3e259bd227776ca847d35baaea88b5e8af57cc920643bf71563176bd6554ad65",
41
- "builtin_tracks/tracks/Replica A09 Sharpest.track": "5362cea3c564dce8f4f22fcd4b798347931dfaa4e65b84624944952b3e0b0d2a",
42
- "builtin_tracks/tracks/Replica B01 Oval.track": "0df5755150c145a35edc6c8693f429e5b9ff4b12210ba67cbfcb932c58faf158",
43
- "builtin_tracks/tracks/Replica B02 Snake.track": "210e699b1fa5f2624a2ff0969b73a20af7bc56bf774f4fd282f6109f80bfdb3c",
44
- "builtin_tracks/tracks/Replica B03 Slalom.track": "a8bab86f93e3072bf27a81a354220c137207b9fc0b548372c8dab93639a18a24",
45
- "builtin_tracks/tracks/Replica B04 Rally.track": "9d2fc668a7c86aabb9ca9dbfdbd21dff0d099c5d77029aa2ae96b6c7b9efd3f0",
46
- "builtin_tracks/tracks/Replica B05 Racetrack.track": "d0804ebc17646d2f7c3be65cfe0f6e94033417e75b101a8c598a69131a170079",
47
- "builtin_tracks/tracks/Replica B06 Hard.track": "8b1a10fd8485405d7f73abca8890f4845145227177c9ac07ac9a1d79f28908f9",
48
- "builtin_tracks/tracks/Replica B07 Drift.track": "d6d46216882cb63d55b1e8ce81ffa67f1c95ffd0a86a72806b8fd23973c6ce0a",
49
- "builtin_tracks/tracks/Replica B08 Arctic Circle.track": "229128e86a87089f781b072a4c677ff729cf8488b1d74ad75cfb43655b9f4a16",
50
- "builtin_tracks/tracks/Replica B09 Checkered.track": "bec4dbd0edc35e0e4f4d1035c608a0c56bbc99e7301f640f8e2eddba0100b2fe",
51
- "builtin_tracks/tracks/Replica R01 Blitzring.track": "14b52a7cbbf7de5606c772c74730c0e8776f39ed72955fef5ae1f7c2d8ccef77",
52
- "builtin_tracks/tracks/Replica R02 Desert Rally.track": "653286b00f2f04450e698d8b8e9d4ecb268064b323b182d2de9db7d017b22238",
53
- "builtin_tracks/tracks/Replica R03 Glacier.track": "0e1b42ce51ae91a8702e173013a090d00145e518bb47d7a659cb1d1fdff011b5",
54
- "builtin_tracks/tracks/Replica R04 Truck Maze.track": "d7b89d64e118506e6cec259057886d9267ca4c6fab570e7ba0d6641e8f822f11",
55
- "builtin_tracks/tracks/Replica R05 Endurance 500.track": "dde36ad5f627bce65d45a96d46a68497f8d446f6011c9df19ee51ce71375cba8",
56
- "builtin_tracks/tracks/Replica R06 Mix.track": "9de8d912a59001ea13baa7f1b4f92884b8cddf91978f31e19f3237975713f739",
57
- "builtin_tracks/tracks/Replica R07 Mirage Marina.track": "8c6764f3accb1a8372d910d57f4f681572dd40c590c4a3d03517379bb360fcd7",
58
- "builtin_tracks/tracks/Replica R08 Demolition Derby.track": "6f11a9ffbcb477c9d19964566b1b612d2a94675605246e93e21f27bbbdb357ef",
59
- "builtin_tracks/tracks/Replica R09 Eau Noir.track": "3ec0a78478cca1a4f0d2d98ac81f40783928adf271b70f0c32d295a6da2cdaa9",
60
- "builtin_tracks/tracks/Replica The Champions Trial.track": "a3bfb76422affc774b44c739b0636d08acb901fa7615b9adcb52c069f79834e1",
61
- "client.js": "7e4bea3b9d2ffa79533ff6abed54481a3a6530e8152093f388e229c621bed9ec",
62
- "delivery.py": "07453999c3831a16e8b1fcc2b4ae5fb28beb7d3952a3fae8de65d16eb6f11e5b",
63
- "gpu_tickets.py": "b81aadb5ed42cec9212d44da7e7bbb7b413c5bd517383cac0b49ec9b4d659c92",
64
- "hf-auth.js": "3f3938bbc0935e4e9230c094029ef46e03beea74be199887c50e053e03dafcfe",
65
- "hf_identity.py": "913fb1e089af21f376e8359a20cbc838d5bcd7971def6989a4382d53c1127b37",
66
- "index.html": "a145fbb303a85c728a530d98fbea998677c1c62a2589c46af9b8b287e4ce1f48",
67
- "portable_bundle.py": "dec236bf875a431b650e04715e2677344203ec2f1eedc63493caee97f338d50f",
68
- "requirements.txt": "fc7e0703ad2289fd3926a36efbdce8c61f0edc00f7575a87e2b565e46588c5a4",
69
- "server.py": "06e2a27e110f655cfc9bf4aabdc74a88b735ad366aab36e0b5907aa780feb024",
70
- "steam_leaderboard.py": "d76397c39cae58f636ddf9a9be1a46ae3d77fefd6b7269feac73d34d97165481",
71
- "track-library.js": "351ec4c026b49420ce963f4310cb000d9e987fd28e6dac5ecd0176509340f6e6",
72
- "track-map.js": "ab5374aa49c7ccec90a787ee8c56cd71beb56b903bf2bbc322783e6f43c009f0",
73
- "training_config.py": "a0dd095e0de8c5a964ace66954f346e5d7b861db3d34cd2feaceedc5ecb80bad",
74
- "vendor/gradio-client/LICENSE": "c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4",
75
- "vendor/gradio-client/dist/index.min.js": "fdd776db8633354f118fe2908c980592283968737e6b91dc3aa86d648da5c3fd",
76
- "vendor/gradio-client/package.json": "96c5b9e6d3587fb7bd8e5bb28775ece222de1043045afc4e6389c88db137ad0e",
77
- "vendor/hf-oauth/LICENSE": "d6e7b19451c1b3d1d66353bb4b1274138953b3bfd9ae60c56e77b1f548796982",
78
- "vendor/hf-oauth/index.js": "53282844000a171011e547f6472a3846bb4cecb5071adafea079fcac3a2aee7e",
79
- "vendor/hf-oauth/package.json": "fafd4d8891023a1961546a7d3bc25cd24f64923a2ebfc449fe6d516a2949bda8",
80
- "worker.py": "481ba85e6edceb0ed6aa993b028029a2f10ab2d7c983ef16c2a4cdcd7c084894"
81
- },
82
- "interface": "single-engine-runner-with-map",
83
- "upstream_revision": "b90b8af06ffc4ea4e4fd703becd1107a48b8e5e0"
84
- }
 
1
+ {"backend":"gpu","files":{".gitattributes":"c2ad16718fc51c888d32efc88c8f6e23ebe5fcf6f48e2954f896ba9770f24d72","BETA_TESTING.md":"27e2492f9fe87cc04f52de2974b685b44041f7de9fa1155926b66c436f0bc613","BINARY_PROVENANCE.json":"a0da1328fde0fb141d232c563ca5b217a4e9e50da62ba818ccb148a1529c9680","BUCKET_BUNDLE.md":"a802f658220f5eabc1a98ea4a5217989b54084d4993943c1faafbad36d421131","CUDA_REFERENCE.md":"f7453c3254d244f8b0de41fde5851cb025159f5cea4a4d1a5d8208a955550c6c","DATA_POLICY.md":"7e8b31b5ad30f35e5d7242c13bdb356191ae05c81dcea2709172974e82546e4d","FEEDBACK.md":"f95b8f6afbf4a360976c0edca5d52f5b3a0b668c9a944d4947b840a7c53a122a","FRONTEND_SOURCE.json":"35cc434f51c0f086b822541ef5c26825e6640f7a444209704ea36a254eedaa80","GODOT_LICENSE":"fb2b3bb82c60f1e76841ba2122eb902f8e39c151476fe21ce10c9a05d1045304","IMPORTER_BUILD_RECEIPT.json":"4ba5c533cc617105478e4d85c83f456a7dfa68b1388049624619871ba9e42e9d","LICENSE":"53d5851b6440feeae36f72a5f57fcab37858c48b7084765ece4b0c54b9a28ed8","README.md":"cb234a47d1aef9ec17ac72cddca0d7b82abe868cbd3acd992bb8abb0759a18c2","STORAGE_BOUNDARY.md":"ac6946e1fd54be0786d71c24ec499bb898e63c20fd064c3c0bb3bba7046a0c57","TRACK_LIBRARY.md":"66b3656ed84f137d5b6116a0075731cd467818f555cca0969084aff67875319f","UPSTREAM.json":"148fd5b06f9b7f2d6830f5b536bf05dfc95c772f124223e8e298acc77f330836","WINNERS_AND_EVALUATION.md":"c3df1b0e7c6ce76c2660159c9b74bd7c35af02be2fcad1b8022e3bb47677bebe","analysis_result.py":"0a5d73fe8dbca934b21823b8ffbd50b0baaef8e15a39e32604eefe16f01d1cf2","app.py":"78ddccd56e5c217b89681c5dcbee451b6a5868398c4f75bd2b4db33c235d800f","app_release.py":"7482559c667a665fd3cb6aa8af6c78422a822b515e14589db8306f7fd4860a8b","assets/formula_template.json":"80f745df82bb7be45af52785fd582460bcddc63c5a4e1d8375d85b3122db4c6b","assets/rally_template.json":"7350a3fe28d9b4da7a0965ae6ea7ca3472fb7885303e342310d63a0dce80851c","assets/snowmobile_template.json":"12d30545876011fd6fccdd3e721f9c465938c9c58ac99338205481d5e7ac6742","assets/track-atlas.json":"d97c8900e3e5fb17734b5bd61d1f4f4b196d9be30af9848e2e273ae7e4079ae4","assets/truck_template.json":"a83bffea4d5e818c135ab3aba68a47aa288d6ad0e7b8bd9c95ed0dea9e9df2f7","bin/altd-sim":"6488c7ec651701841999687708ff128a8ffb0b88feb53e23168a3ce678b5393d","bin/extract_saved_track":"3a7733c45b4301a786fcbfe92660a1c484749272da5f608d62f34878bd4ff31a","bin/libaltd_gpu.so":"3b1b88bccc06a21acc33ac4400af016d55a7e3d8727136a0c43b541f3c7887ca","bucket_storage.py":"4cdc4dc37388484fdd696e6932062ca20651c5ad64cd91a8ba059e92f90ca752","build_info.py":"03efdd1bca81022669a9184667b89d4f1096faba6a83c0514322dd419a938e1f","builtin_tracks.py":"2ecc2b1d00fe15701b0271d71e1e68d2e7e59c596330879494055f9b2a99c242","builtin_tracks/catalog.json":"65006e70a87a5a53e3738166b0895bc4db9f99767dd891d917b68029abb4e80a","builtin_tracks/tracks/Replica A01 Beginner.track":"ca5749537450430794e2eade2e0ac72fa141a4f225e13e2f8ef842abb948ff15","builtin_tracks/tracks/Replica A02 Sharp Turns.track":"097f1ea8edf041112b690d0638075630716ddc03c7d0b1980ddcbeaf5071c8d4","builtin_tracks/tracks/Replica A03 High Speed.track":"88917c63847be1854b989bb28a23af7eeacb964c3e7df9579953aebc9496e6af","builtin_tracks/tracks/Replica A04 Dirt.track":"7f4fe58516227b15898d14bb0abb9a0856126d24fcd793884f051f7a4fae521d","builtin_tracks/tracks/Replica A05 Two Terrains.track":"c4ee2842920d99cdfe3eebdcad59a77e24b626380acef0d02a39ea1e23823e2a","builtin_tracks/tracks/Replica A06 Ice.track":"fccb9a159a06d1b986c6fe7d5bb89c0cabad3a005531f4e0d71f6e37254ada34","builtin_tracks/tracks/Replica A07 Three Terrains.track":"a8fa652ad451e2702a23d5f3429eb0b6a6408bf6a2a76a73d549023e481875c6","builtin_tracks/tracks/Replica A08 Long.track":"3e259bd227776ca847d35baaea88b5e8af57cc920643bf71563176bd6554ad65","builtin_tracks/tracks/Replica A09 Sharpest.track":"5362cea3c564dce8f4f22fcd4b798347931dfaa4e65b84624944952b3e0b0d2a","builtin_tracks/tracks/Replica B01 Oval.track":"0df5755150c145a35edc6c8693f429e5b9ff4b12210ba67cbfcb932c58faf158","builtin_tracks/tracks/Replica B02 Snake.track":"210e699b1fa5f2624a2ff0969b73a20af7bc56bf774f4fd282f6109f80bfdb3c","builtin_tracks/tracks/Replica B03 Slalom.track":"a8bab86f93e3072bf27a81a354220c137207b9fc0b548372c8dab93639a18a24","builtin_tracks/tracks/Replica B04 Rally.track":"9d2fc668a7c86aabb9ca9dbfdbd21dff0d099c5d77029aa2ae96b6c7b9efd3f0","builtin_tracks/tracks/Replica B05 Racetrack.track":"d0804ebc17646d2f7c3be65cfe0f6e94033417e75b101a8c598a69131a170079","builtin_tracks/tracks/Replica B06 Hard.track":"8b1a10fd8485405d7f73abca8890f4845145227177c9ac07ac9a1d79f28908f9","builtin_tracks/tracks/Replica B07 Drift.track":"d6d46216882cb63d55b1e8ce81ffa67f1c95ffd0a86a72806b8fd23973c6ce0a","builtin_tracks/tracks/Replica B08 Arctic Circle.track":"229128e86a87089f781b072a4c677ff729cf8488b1d74ad75cfb43655b9f4a16","builtin_tracks/tracks/Replica B09 Checkered.track":"bec4dbd0edc35e0e4f4d1035c608a0c56bbc99e7301f640f8e2eddba0100b2fe","builtin_tracks/tracks/Replica R01 Blitzring.track":"14b52a7cbbf7de5606c772c74730c0e8776f39ed72955fef5ae1f7c2d8ccef77","builtin_tracks/tracks/Replica R02 Desert Rally.track":"653286b00f2f04450e698d8b8e9d4ecb268064b323b182d2de9db7d017b22238","builtin_tracks/tracks/Replica R03 Glacier.track":"0e1b42ce51ae91a8702e173013a090d00145e518bb47d7a659cb1d1fdff011b5","builtin_tracks/tracks/Replica R04 Truck Maze.track":"d7b89d64e118506e6cec259057886d9267ca4c6fab570e7ba0d6641e8f822f11","builtin_tracks/tracks/Replica R05 Endurance 500.track":"dde36ad5f627bce65d45a96d46a68497f8d446f6011c9df19ee51ce71375cba8","builtin_tracks/tracks/Replica R06 Mix.track":"9de8d912a59001ea13baa7f1b4f92884b8cddf91978f31e19f3237975713f739","builtin_tracks/tracks/Replica R07 Mirage Marina.track":"8c6764f3accb1a8372d910d57f4f681572dd40c590c4a3d03517379bb360fcd7","builtin_tracks/tracks/Replica R08 Demolition Derby.track":"6f11a9ffbcb477c9d19964566b1b612d2a94675605246e93e21f27bbbdb357ef","builtin_tracks/tracks/Replica R09 Eau Noir.track":"3ec0a78478cca1a4f0d2d98ac81f40783928adf271b70f0c32d295a6da2cdaa9","builtin_tracks/tracks/Replica The Champions Trial.track":"a3bfb76422affc774b44c739b0636d08acb901fa7615b9adcb52c069f79834e1","client.js":"946f06d308503a40c9048f07cda5cc580e58556a81e3e3430d9941fe9c2d7c3b","delivery.py":"6bdba2e87c3e19a41440cb16103695af27105f8bafbae38948bbb1b936ed0c1b","gpu_tickets.py":"b81aadb5ed42cec9212d44da7e7bbb7b413c5bd517383cac0b49ec9b4d659c92","hf-auth.js":"3f3938bbc0935e4e9230c094029ef46e03beea74be199887c50e053e03dafcfe","hf_identity.py":"913fb1e089af21f376e8359a20cbc838d5bcd7971def6989a4382d53c1127b37","index.html":"c46f7ba6dccf473956e038bf2028ff6d88b494a49d0c04c7ec0e278fd8eec0b3","native/analysis.patch":"56859bc55a946ea129d502868387f8441cbd37c30962fe3e7e484d313c4ccf63","native/build-receipt.json":"b2336433fc14ddf8b7722faa8ca434eff6f73ec41ec6d48cc0e9bf230f5efb3d","native/source-receipt.json":"33d09c326e4d6c3ac1d1839e4528c70c06b4ff48138b90f174f434ab685b4b34","portable_bundle.py":"29ab4e728eb054a51d02d41114a3548c6ed7cee65f809e0f33da40b87e0a12a6","requirements.txt":"fc7e0703ad2289fd3926a36efbdce8c61f0edc00f7575a87e2b565e46588c5a4","server.py":"a8f838ee9f9a7a30d977fb706337ae070bba16a7f90aad9ad66f96e1a6a0eb21","steam_leaderboard.py":"d76397c39cae58f636ddf9a9be1a46ae3d77fefd6b7269feac73d34d97165481","track-library.js":"351ec4c026b49420ce963f4310cb000d9e987fd28e6dac5ecd0176509340f6e6","track-map.js":"ab5374aa49c7ccec90a787ee8c56cd71beb56b903bf2bbc322783e6f43c009f0","training_config.py":"3d7fcaad585b474380e825649e4add3412494bbd68b48b3b14bd1b954e817ad0","vendor/gradio-client/LICENSE":"c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4","vendor/gradio-client/dist/index.min.js":"fdd776db8633354f118fe2908c980592283968737e6b91dc3aa86d648da5c3fd","vendor/gradio-client/package.json":"96c5b9e6d3587fb7bd8e5bb28775ece222de1043045afc4e6389c88db137ad0e","vendor/hf-oauth/LICENSE":"d6e7b19451c1b3d1d66353bb4b1274138953b3bfd9ae60c56e77b1f548796982","vendor/hf-oauth/index.js":"53282844000a171011e547f6472a3846bb4cecb5071adafea079fcac3a2aee7e","vendor/hf-oauth/package.json":"fafd4d8891023a1961546a7d3bc25cd24f64923a2ebfc449fe6d516a2949bda8","worker.py":"0c32f658c4c7af8c6f59f039b8bafdd997f324fcb12340f238e46a2d009d4e9f"},"interface":"single-engine-runner-with-map-winners-analysis-v1","upstream_revision":"b90b8af06ffc4ea4e4fd703becd1107a48b8e5e0"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
STORAGE_BOUNDARY.md CHANGED
@@ -11,9 +11,9 @@ same settings contract is used independently of the selected storage policy.
11
  | Selected built-in track | Synchronized selectors and one verified Blob | Page memory across runs; no cookie, recipe or localStorage persistence |
12
  | Worker inputs / intermediate files | Temporary directory | Removed before successful result publication |
13
  | GPU pending inputs | Opaque single-use ticket plus memory store | Dispatch, confirmed quota-rejection discard, fixed 120-second expiry, or shutdown |
14
- | Single-run output | Memory result store and random job key | Default receipt ACK; opt-in repeat download until fixed five-minute expiry; manual delete or shutdown |
15
  | Batch intermediates | Same job contract, always automatic | Verified size/hash and deletion ACK before advancement |
16
- | Received candidate / receipt | Browser page memory | Explicit discard, replacement, page reload/close; download to disk or optional connected-bucket autosave |
17
  | Optional HF identity / token | Browser page memory, separate from job/recipe storage | Sign out, expiry, or page reload/close; no app auth cookie or server session database |
18
  | Optional HF bucket OAuth grant | Separate explicit `openid profile write-repos` authorization; shared in memory with identity when granted together | Disconnect, observed expiry/denial, or page reload/close; no token in URL, localStorage, recipes or bundles |
19
  | One-use HF PKCE verifier/nonce | Tab-local sessionStorage for the sign-in redirect only | Five-minute acceptance limit; removed on return/error/fresh app load or tab close; never stores access tokens or files |
@@ -32,3 +32,5 @@ automatically share their library; explicit JSON export/import is the bridge.
32
 
33
  These are application lifecycle rules, not provider-wide erasure or a network
34
  sandbox. Hosted qualification must be repeated for each deployed build.
 
 
 
11
  | Selected built-in track | Synchronized selectors and one verified Blob | Page memory across runs; no cookie, recipe or localStorage persistence |
12
  | Worker inputs / intermediate files | Temporary directory | Removed before successful result publication |
13
  | GPU pending inputs | Opaque single-use ticket plus memory store | Dispatch, confirmed quota-rejection discard, fixed 120-second expiry, or shutdown |
14
+ | Single-run network and analysis | One memory result record and random job key; both payloads share expiry and deletion | Default receipt ACK; opt-in repeat download until fixed five-minute expiry; manual delete or shutdown |
15
  | Batch intermediates | Same job contract, always automatic | Verified size/hash and deletion ACK before advancement |
16
+ | Received candidate, winner networks, descriptors and receipt | Browser page memory | Explicit discard, replacement, page reload/close; download to disk or optional connected-bucket autosave |
17
  | Optional HF identity / token | Browser page memory, separate from job/recipe storage | Sign out, expiry, or page reload/close; no app auth cookie or server session database |
18
  | Optional HF bucket OAuth grant | Separate explicit `openid profile write-repos` authorization; shared in memory with identity when granted together | Disconnect, observed expiry/denial, or page reload/close; no token in URL, localStorage, recipes or bundles |
19
  | One-use HF PKCE verifier/nonce | Tab-local sessionStorage for the sign-in redirect only | Five-minute acceptance limit; removed on return/error/fresh app load or tab close; never stores access tokens or files |
 
32
 
33
  These are application lifecycle rules, not provider-wide erasure or a network
34
  sandbox. Hosted qualification must be repeated for each deployed build.
35
+
36
+ Analysis is saved only with the user-selected local download or connected private bucket. The complete per-car measurements and immutable winner networks use the same temporary job lifecycle as the primary network. Frozen evaluation changes no network weights and does not start training when a bundle is restored.
UPSTREAM.json CHANGED
@@ -1,12 +1 @@
1
- {
2
- "repository": "https://github.com/Zorro909/driving_sim_rs",
3
- "commit": "b90b8af06ffc4ea4e4fd703becd1107a48b8e5e0",
4
- "math_profile": "proton",
5
- "role": "gpu",
6
- "ui_refresh_date": "2026-10-12",
7
- "open_prs_not_included": [
8
- 19,
9
- 21
10
- ],
11
- "importer_source_revision": "d8ab74c25df70b6a86ed101080e33d5fb785ea6f"
12
- }
 
1
+ {"commit":"b90b8af06ffc4ea4e4fd703becd1107a48b8e5e0","importer_source_revision":"d8ab74c25df70b6a86ed101080e33d5fb785ea6f","math_profile":"proton","native_build":{"build_receipt_path":"native/build-receipt.json","build_receipt_sha256":"b2336433fc14ddf8b7722faa8ca434eff6f73ec41ec6d48cc0e9bf230f5efb3d","cuda_library_origin":"retained b90 CUDA library; layout ABI checked, no local device execution","patch_path":"native/analysis.patch","patch_sha256":"56859bc55a946ea129d502868387f8441cbd37c30962fe3e7e484d313c4ccf63","simulator_origin":"locally built Linux executable from b90 plus reviewed local patch","source_receipt_path":"native/source-receipt.json","source_receipt_sha256":"33d09c326e4d6c3ac1d1839e4528c70c06b4ff48138b90f174f434ab685b4b34","source_revision":"b90b8af06ffc4ea4e4fd703becd1107a48b8e5e0"},"open_prs_not_included":[19,21],"repository":"https://github.com/Zorro909/driving_sim_rs","role":"gpu","ui_refresh_date":"2026-10-12"}
 
 
 
 
 
 
 
 
 
 
 
WINNERS_AND_EVALUATION.md ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Winners, measurements and frozen comparisons
2
+
3
+ Training still uses the recipe you select. This app adds records you can inspect afterwards; it does not choose a curriculum or change your rewards automatically.
4
+
5
+ ## What a training result preserves
6
+
7
+ - **Fitness winner:** the best car under your selected reward recipe in the final evaluated population. This is the usual primary network download.
8
+ - **Fastest lap:** the car with the fastest completed forward lap observed during the run, even if it was not the final fitness winner.
9
+ - **Clean laps:** the fastest qualifying car that completed the required number of forward laps without physical wall contact. The default is two clean laps.
10
+
11
+ A single car can qualify for several categories. A category without a qualifying car is marked unavailable. Saving a winner preserves the exact policy evaluated for that result; later mutations do not replace its weights.
12
+
13
+ Every evaluated car has a measurement row, including the reward metrics you did not select. The archive contains raw measurements, update counts, reward-channel values, within-population ranks, the selected fitness score, completed laps, distance, and contact counts. A reward-channel rank depends on the population it was measured against. It is not an absolute score you can compare across unrelated runs. Missing measurements remain missing.
14
+
15
+ Saving a result to your connected private bucket also preserves the available winner networks and analysis. Names distinguish the run and winner category. Existing saves are not overwritten. You can also download the analysis for local use. Earlier bundle files remain readable; they cannot acquire measurements that were never recorded.
16
+
17
+ ## Recipe libraries in your bucket
18
+
19
+ Recipe import and export offer both local JSON files and your connected private bucket. Bucket exports save the current browser library of named recipes and batches under a new dated entry. They include settings only; networks, tracks, run results and credentials are not part of a recipe library.
20
+
21
+ Refresh saved recipes to list bucket entries and select the latest. Importing a selected library asks before replacing this browser's saved recipes and batches. It does not start training or automatically apply a different training strategy. Existing bucket exports are not overwritten. The same 32-KiB library limit applies to files and buckets.
22
+
23
+ ## Frozen evaluation
24
+
25
+ Choose evaluation to drive a network without training it. The evaluator starts with a fresh car and keeps the uploaded network unchanged: no mutation, weight decay, crossover or reproduction. Evaluation does not create a new winner population.
26
+
27
+ Select the winners you want to compare, choose the evaluation conditions, and start the comparison. Each distinct policy runs in sequence with its weights frozen. The evaluation may be longer than the original training run. Compare saved winners under one set of conditions: track, vehicle, run duration, controller frequency, collision elimination and idle elimination. Use the same engine build and backend for a direct comparison. Each policy retains its own identity. Different conditions are shown as incompatible rather than silently converted.
28
+
29
+ Look at completed laps, clean-lap eligibility, contacts, distance and lap time together. A short evaluation that ends before the second lap cannot establish two-lap reliability. A fast lap with contacts is not a clean-lap result. Replica lap times remain simulator measurements; they do not establish an official Steam leaderboard result.
30
+
31
+ ## Data lifetime
32
+
33
+ Primary network and analysis are one temporary server result. The browser checks both before acknowledging deletion. Bucket saving then uses the verified bytes held in the browser. If a bucket save fails, the local result remains available for retry or download. Closing the page can lose an unsaved local result.
34
+
35
+ Analysis can be much larger than a network. Bounded size limits protect the service and browser. The app must report an oversized or incomplete result explicitly; it must not silently drop cars, generations or reward channels and call the archive complete.
36
+
37
+ The bucket is user-owned storage. Application cleanup applies to temporary processing copies, not the files you have chosen to save in that bucket. Provider-wide retention is outside this app's guarantee. Recipes remain under your control; this feature does not choose or adapt them for you.
analysis_result.py ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Convert bounded native observations into portable, byte-bound analysis files.
2
+
3
+ No selection is performed here: native snapshots were taken before turnover.
4
+ """
5
+ import copy
6
+ import gzip
7
+ import hashlib
8
+ import json
9
+ import math
10
+ import struct
11
+ import uuid
12
+ from pathlib import Path
13
+
14
+ MAX_ANALYSIS_BYTES = 256 * 1024 * 1024
15
+ METRIC_NAMES = ('total_score','total_speed','total_abs_steering','total_actual_distance',
16
+ 'total_drifting_amount','total_momentum_change_frequency','total_throttle_change_frequency',
17
+ 'total_braking_frequency','total_distance_from_wall','total_distance_from_center',
18
+ 'network_novelty','collision_count','best_lap_performance','total_racing_line_efficiency')
19
+ AVERAGE = set(METRIC_NAMES[:10])
20
+ AVERAGE_ONLY = {'total_abs_steering','total_momentum_change_frequency','total_throttle_change_frequency',
21
+ 'total_braking_frequency','total_distance_from_wall','total_distance_from_center'}
22
+ CHANNELS = [{'metric':metric,'type':kind} for metric in METRIC_NAMES for kind in ('default','average')
23
+ if not (kind=='default' and metric in AVERAGE_ONLY) and not (kind=='average' and metric not in AVERAGE)]
24
+ ROLES = ('fitness','fastest_lap','clean_laps')
25
+
26
+ def encoded(value):
27
+ return json.dumps(value,allow_nan=False,separators=(',',':')).encode()
28
+
29
+ def bounded_read(path,limit=MAX_ANALYSIS_BYTES):
30
+ path=Path(path)
31
+ if path.is_symlink() or not path.is_file() or path.stat().st_size>limit:
32
+ raise ValueError('Invalid or oversized native analysis file')
33
+ with path.open('rb') as stream:data=stream.read(limit+1)
34
+ if len(data)>limit:raise ValueError('Native analysis exceeds complete artifact limit')
35
+ return data
36
+
37
+ def parsed(data):
38
+ return json.loads(data,parse_constant=lambda _: (_ for _ in ()).throw(ValueError('Nonfinite native analysis')))
39
+
40
+ def parameter_hash(network):
41
+ params=[]
42
+ for matrix,bias in zip(network['weights'],network['biases']):
43
+ params.extend(value for row in matrix for value in row)
44
+ params.extend(bias)
45
+ return hashlib.sha256(b''.join(struct.pack('<d',value) for value in params)).hexdigest()
46
+
47
+ def file_descriptor(name,data):
48
+ return {'name':name,'bytes':len(data),'sha256':hashlib.sha256(data).hexdigest()}
49
+
50
+ def checked_row(row,generation,car):
51
+ if not isinstance(row,dict) or row.get('schema')!=1 or row.get('generation')!=generation or row.get('car')!=car:
52
+ raise ValueError('Missing or reordered native observations')
53
+ if row.get('candidate_id')!=f'g{generation:06}-c{car:06}':raise ValueError('Native candidate identity mismatch')
54
+ if set(row.get('raw_metrics',{}))!=set(METRIC_NAMES):raise ValueError('Incomplete native raw metrics')
55
+ channels=row.get('channels')
56
+ keys=('values','population_relative','unavailable_reasons')
57
+ if not isinstance(channels,dict) or set(channels)!=set(keys) or any(not isinstance(channels[k],list) or len(channels[k])!=18 for k in keys):
58
+ raise ValueError('Incomplete native channels')
59
+ for value,rank,reason in zip(*(channels[k] for k in keys)):
60
+ for number in (value,rank):
61
+ if number is not None and (type(number) not in (int,float) or not math.isfinite(number)):
62
+ raise ValueError('Invalid native measurement')
63
+ if value is None:
64
+ if rank is not None or not isinstance(reason,str) or not reason:raise ValueError('Missing channel fabricated')
65
+ elif rank is None or not 0<=rank<=1 or reason is not None:raise ValueError('Invalid native channel rank')
66
+ return row
67
+
68
+ def assemble_analysis(out,template,primary,network_bytes,track_bytes,config,backend,validator):
69
+ native_dir=Path(out)/'analysis'
70
+ native=parsed(bounded_read(native_dir/'analysis.json',1024*1024))
71
+ if native.get('schema')!='ald.native-analysis' or native.get('version')!=1 or native.get('mode')!=config['mode']:
72
+ raise ValueError('Native analysis contract unavailable')
73
+ descriptors=bounded_read(native_dir/'descriptors.jsonl')
74
+ population=1 if config['mode']=='evaluate' else config['population']
75
+ generations=1 if config['mode']=='evaluate' else config['generations']
76
+ expected=population*generations
77
+ winner_rows=native.get('winners',{})
78
+ if set(winner_rows)!=set(ROLES):raise ValueError('Incomplete native winner categories')
79
+ selected_ids={r['candidate_id']:r for r in winner_rows.values() if r is not None}
80
+ seen_selected=set();count=0;evaluation_row=None
81
+ # Stream rows instead of retaining every decoded population in memory.
82
+ import io
83
+ for line in io.BytesIO(descriptors):
84
+ if not line.strip():raise ValueError('Empty native descriptor row')
85
+ count+=1
86
+ if count>expected:raise ValueError('Extra native observations')
87
+ row=checked_row(parsed(line),(count-1)//population+1,(count-1)%population)
88
+ if row['candidate_id'] in selected_ids:
89
+ if row!=selected_ids[row['candidate_id']]:raise ValueError('Winner observation not in descriptor stream')
90
+ seen_selected.add(row['candidate_id'])
91
+ if config['mode']=='evaluate':evaluation_row=row
92
+ if count!=expected or native.get('rows')!=count or set(selected_ids)!=seen_selected:
93
+ raise ValueError('Incomplete native analysis')
94
+ files={'descriptors.jsonl':descriptors}
95
+ conditions={'track_sha256':hashlib.sha256(track_bytes).hexdigest(),
96
+ 'network_sha256':hashlib.sha256(network_bytes).hexdigest(),'vehicle_type':template['Type'],
97
+ 'requested_ticks':config['ticks'],'control_hz':config['control_hz'],
98
+ 'collision':config['collision'],'idle':config['idle'],'math_profile':'proton','clean_min_laps':config['clean_min_laps']}
99
+ winners={}
100
+ for role,row in winner_rows.items():
101
+ metadata={'status':'unavailable','reason':('frozen_evaluation_does_not_select_winners' if config['mode']=='evaluate'
102
+ else 'no_completed_lap' if role=='fastest_lap' else 'no_eligible_clean_laps'),
103
+ 'candidate_id':None,'generation':None,'car':None,'selection_fitness':None,
104
+ 'best_lap_seconds':None,'forward_laps':None,'physical_contacts':None,'collision_updates':None,
105
+ 'total_actual_distance':None,'artifact':None}
106
+ if row is not None:
107
+ if config['mode']!='train':raise ValueError('Frozen evaluation selected a winner')
108
+ objective=row['objectives'];lap=objective['best_lap_seconds']
109
+ if role=='fitness' and row['generation']!=generations:raise ValueError('Fitness winner is not final population')
110
+ if role!='fitness' and (lap is None or lap<=0 or objective['forward_laps']<1):raise ValueError('Winner did not complete a lap')
111
+ if role=='clean_laps' and (objective['forward_laps']<config['clean_min_laps'] or objective['physical_contacts']!=0):
112
+ raise ValueError('Clean winner not eligible')
113
+ selected=parsed(bounded_read(native_dir/'winners'/f'{role}.json',1024*1024))
114
+ if selected.get('observation')!=row or parameter_hash(selected)!=row['weights_sha256']:
115
+ raise ValueError('Selected weights do not match observed controller')
116
+ game=copy.deepcopy(template)
117
+ game['NeuralNetwork']['Weights']=selected['weights'];game['NeuralNetwork']['Biases']=selected['biases']
118
+ if role=='fitness':data=primary
119
+ else:
120
+ game['Id']=str(uuid.uuid4());game['Name']='HF '+('fastest lap' if role=='fastest_lap' else 'clean laps')+' winner'
121
+ data=gzip.compress(encoded(game),mtime=0)
122
+ checked=validator(data)
123
+ observed={'weights':checked['NeuralNetwork']['Weights'],'biases':checked['NeuralNetwork']['Biases']}
124
+ if parameter_hash(observed)!=row['weights_sha256']:raise ValueError('Game export changed selected weights')
125
+ name=f'winners/{role}.evolution';files[name]=data
126
+ metadata.update(status='available',reason=None,candidate_id=row['candidate_id'],generation=row['generation'],car=row['car'],
127
+ selection_fitness=row['selection_fitness'],artifact=file_descriptor(name,data),**objective)
128
+ metadata.pop('active',None)
129
+ winners[role]=metadata
130
+ if config['mode']=='train' and winners['fitness']['status']!='available':raise ValueError('Missing fitness winner')
131
+ summary={'schema':'ald.run-analysis','version':1,'mode':config['mode'],'execution_backend':backend,
132
+ 'conditions':conditions,'observations':{'generations':generations,'cars':count,'raw_metric_count':14,'reward_channel_count':18},
133
+ 'winners':winners,'semantics':{
134
+ 'fitness':'Final evaluated population leader; rank fitness is relative to that population',
135
+ 'fastest_lap':'Run-global positive completed lap; earliest generation/car breaks ties',
136
+ 'clean_laps':'Run-global zero physical contacts and required forward laps; fastest eligible lap; earliest generation/car breaks ties',
137
+ 'channels':'Unsigned ranks within the observed population; averages divide by 0.1 * stats updates; null is unavailable',
138
+ 'collision_count':'Reward collision updates differ from physical contacts',
139
+ 'training':'Training reuses physical vehicle state between generations; winner observations need not equal a fresh replay',
140
+ 'evaluation':'Fresh cars, exact fixed weights, no reproduction/mutation/decay; novelty is relative to the evaluation population'}}
141
+ analysis={**summary,'metric_names':list(METRIC_NAMES),'reward_channels':CHANNELS,'settings':config,
142
+ 'descriptors':{**file_descriptor('descriptors.jsonl',descriptors),'rows':count}}
143
+ files['analysis.json']=encoded(analysis)
144
+ evaluation=None
145
+ if config['mode']=='evaluate':
146
+ original={'weights':template['NeuralNetwork']['Weights'],'biases':template['NeuralNetwork']['Biases']}
147
+ if evaluation_row['weights_sha256']!=parameter_hash(original) or primary!=network_bytes or evaluation_row['selection_fitness'] is not None:
148
+ raise ValueError('Frozen evaluation altered input policy')
149
+ evaluation={'schema':'ald.frozen-evaluation','version':1,'conditions':conditions,
150
+ 'input_sha256':hashlib.sha256(network_bytes).hexdigest(),'weights_unchanged':True,'measurements':evaluation_row}
151
+ files['evaluation.json']=encoded(evaluation)
152
+ if sum(map(len,files.values()))>MAX_ANALYSIS_BYTES:raise ValueError('Complete analysis exceeds 256 MiB; no truncation')
153
+ return files,summary,evaluation
app.py CHANGED
@@ -17,14 +17,14 @@ from fastapi.responses import FileResponse,JSONResponse
17
  from app_release import ROOT,cpu_worker,verify_package
18
  from gpu_tickets import InputTickets
19
  from server import create_app
20
- from delivery import CapacityExceeded
21
  from worker import run_native,track_input,network_input
22
  from training_config import validate_config
23
  from build_info import build_identity
24
  from hf_identity import visitor_identity
25
 
26
- def build_app(cuda_call):
27
- base=create_app(worker=cpu_worker)
28
  tickets=InputTickets()
29
  admission=threading.BoundedSemaphore(1)
30
 
@@ -93,7 +93,9 @@ def build_app(cuda_call):
93
  track,network,automatic,*config=tickets.take(ticket)
94
  try:
95
  payload,report=cuda_call(track,network,*config)
96
- return {**base.state.results.publish(payload,automatic),'backend':'gpu','report':report}
 
 
97
  except CapacityExceeded:
98
  raise ValueError('Result capacity reached; no result was published.') from None
99
  except Exception:
@@ -125,7 +127,7 @@ def main():
125
  importer=ROOT/'bin/extract_saved_track',scene_template=ROOT/'assets/formula_template.json',
126
  gpu_library=ROOT/'bin/libaltd_gpu.so',with_report=True,config=config)
127
 
128
- build_app(cuda).launch(server_name='0.0.0.0',server_port=7860,run_history=False,
129
  enable_monitoring=False,show_error=False)
130
 
131
  if __name__=='__main__':main()
 
17
  from app_release import ROOT,cpu_worker,verify_package
18
  from gpu_tickets import InputTickets
19
  from server import create_app
20
+ from delivery import CapacityExceeded, publish_worker_result
21
  from worker import run_native,track_input,network_input
22
  from training_config import validate_config
23
  from build_info import build_identity
24
  from hf_identity import visitor_identity
25
 
26
+ def build_app(cuda_call,require_analysis=False):
27
+ base=create_app(worker=cpu_worker,require_analysis=require_analysis)
28
  tickets=InputTickets()
29
  admission=threading.BoundedSemaphore(1)
30
 
 
93
  track,network,automatic,*config=tickets.take(ticket)
94
  try:
95
  payload,report=cuda_call(track,network,*config)
96
+ published,public_report=publish_worker_result(
97
+ base.state.results,payload,report,automatic,require_analysis=require_analysis)
98
+ return {**published,'backend':'gpu','report':public_report}
99
  except CapacityExceeded:
100
  raise ValueError('Result capacity reached; no result was published.') from None
101
  except Exception:
 
127
  importer=ROOT/'bin/extract_saved_track',scene_template=ROOT/'assets/formula_template.json',
128
  gpu_library=ROOT/'bin/libaltd_gpu.so',with_report=True,config=config)
129
 
130
+ build_app(cuda,require_analysis=True).launch(server_name='0.0.0.0',server_port=7860,run_history=False,
131
  enable_monitoring=False,show_error=False)
132
 
133
  if __name__=='__main__':main()
bin/altd-sim CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:0135ecc3d435a9d327be7a92e048cfb5c5291a02ef1e0dddd6ead1cc19b31166
3
- size 15774792
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:6488c7ec651701841999687708ff128a8ffb0b88feb53e23168a3ce678b5393d
3
+ size 3580296
bucket_storage.py CHANGED
@@ -15,17 +15,22 @@ import sys
15
  import tempfile
16
  import uuid
17
 
18
- from portable_bundle import BundleError, MAX_BUNDLE, MAX_NETWORK, load_bundle
 
 
 
19
  from worker import network_input
20
 
21
  HF_ENDPOINT = "https://huggingface.co"
22
  MAX_REQUEST = 2 * MAX_BUNDLE + 64 * 1024
 
23
  MAX_CHILD_INPUT = 4 * ((MAX_BUNDLE + 2) // 3) + 64 * 1024
24
  MAX_CHILD_OUTPUT = 128 * 1024
25
  CHILD_TIMEOUT_SECONDS = 55
26
  MAX_LIST_SCAN = 10_000
27
  MAX_LIST_RESULTS = 50
28
  LEGACY_APP_KEY = re.compile(r"^ald-exports/([0-9a-f]{32}|[0-9a-f]{64})/bundle\.json$")
 
29
  AUTOSAVE_APP_KEY = re.compile(
30
  r"^ald-exports/(?P<timestamp>[0-9]{8}T[0-9]{6}Z)-"
31
  r"(?P<track>[a-z0-9][a-z0-9-]{0,63})-"
@@ -34,7 +39,7 @@ AUTOSAVE_APP_KEY = re.compile(
34
  r"lap(?P<lap>none|[0-9]{1,16}ms)-(?P<uuid>[0-9a-f]{32})/bundle\.json$"
35
  )
36
  VEHICLE_NAMES = ("Formula", "Rally", "Snowmobile", "Truck")
37
- MAX_SIDECAR_TOTAL = 1024 * 1024
38
  BUCKET_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}/[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$")
39
 
40
 
@@ -54,6 +59,149 @@ def valid_bundle_key(value: object) -> bool:
54
  return isinstance(value, str) and bool(LEGACY_APP_KEY.fullmatch(value) or AUTOSAVE_APP_KEY.fullmatch(value))
55
 
56
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  def _autosave_prefix(bundle: dict, files: dict[str, bytes], now: datetime | None = None) -> tuple[str, str, str]:
58
  artifacts = bundle["artifacts"]
59
  receipt = artifacts.get("run_receipt")
@@ -86,6 +234,17 @@ def _readable_track(track_id: str, track: dict | None) -> str:
86
  return "custom-track"
87
 
88
 
 
 
 
 
 
 
 
 
 
 
 
89
  def _track_path_slug(track: dict | None) -> str:
90
  if isinstance(track, dict) and track.get("source") == "builtin":
91
  display_name = track.get("display_name")
@@ -98,7 +257,7 @@ def _track_path_slug(track: dict | None) -> str:
98
  fragment = re.sub(r"[^A-Za-z0-9]+", "-", track_id).strip("-").lower()[:48].strip("-")
99
  if fragment:
100
  return "track-" + fragment
101
- return "custom"
102
 
103
 
104
  def _render_history_csv(report: dict) -> bytes:
@@ -116,11 +275,13 @@ def _render_report(bundle: dict) -> bytes:
116
  receipt = artifacts["run_receipt"]
117
  report = receipt["report"]
118
  track = artifacts.get("selected_track")
 
 
119
  track_label = track.get("display_name", "Built-in track") if isinstance(track, dict) and track.get("source") == "builtin" else "Custom or uploaded track"
120
  lap = report["best_lap_seconds"]
121
  lap_label = "No completed lap" if lap is None else f"{lap:.3f} s"
122
  lines = [
123
- "# ALD training run",
124
  "",
125
  f"- Track: {track_label}",
126
  f"- Vehicle: {report['vehicle_name']}",
@@ -131,7 +292,6 @@ def _render_report(bundle: dict) -> bytes:
131
  f"- Final-generation best score: {report['best_score']}",
132
  f"- Final-generation mean score: {report['mean_score']}",
133
  f"- Best completed lap observed during this run: {lap_label}",
134
- "- The best completed lap may belong to a different car than the exported candidate.",
135
  f"- Lapped cars in the final generation: {report['lapped_cars']}",
136
  f"- Simulated ticks: {report['simulated_ticks']} of {report['requested_ticks']} requested per car",
137
  f"- Elapsed seconds: {report['elapsed_seconds']:.3f}",
@@ -142,6 +302,20 @@ def _render_report(bundle: dict) -> bytes:
142
  ]
143
  for key in sorted(report["settings"]):
144
  lines.append(f"- {key}: `{json.dumps(report['settings'][key], ensure_ascii=True, allow_nan=False, separators=(',', ':'))}`")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
145
  return ("\n".join(lines) + "\n").encode("utf-8")
146
 
147
 
@@ -156,10 +330,22 @@ def _save_files(bundle_bytes: bytes, bundle: dict, files: dict[str, bytes], *, n
156
  vehicle = VEHICLE_NAMES[network_input(files["selected_network.evolution"])["Type"]].lower()
157
  uploads = {"bundle.json": bundle_bytes}
158
  network_suffix = prefix.rsplit("-", 1)[-1][:8]
159
- uploads[f"{_readable_track(track, artifacts.get('selected_track'))}-{vehicle}-{timestamp}-{network_suffix}.evolution"] = files["selected_network.evolution"]
 
160
  if receipt is not None:
161
  uploads["report.md"] = _render_report(bundle)
162
  uploads["generation-history.csv"] = _render_history_csv(receipt["report"])
 
 
 
 
 
 
 
 
 
 
 
163
  if sum(map(len, uploads.values())) > MAX_BUNDLE + MAX_NETWORK + MAX_SIDECAR_TOTAL:
164
  raise BucketStorageError("invalid_bundle")
165
  return prefix, uploads
@@ -231,7 +417,7 @@ def _child_environment(cache_root: Path) -> dict[str, str]:
231
 
232
  def run_bucket_operation(operation: str, token: str, bucket_id: str, **args):
233
  """Run one HF operation in an isolated process with a temporary Xet cache."""
234
- if operation not in {"connect", "list", "save", "restore"}:
235
  raise BucketStorageError("invalid_request")
236
  if not isinstance(token, str) or not token or len(token) > 4096 or any(ch.isspace() or ord(ch) < 33 for ch in token):
237
  raise BucketStorageError("unauthorized")
@@ -266,24 +452,37 @@ def run_bucket_operation(operation: str, token: str, bucket_id: str, **args):
266
  if category in {"unauthorized", "forbidden", "not_found", "invalid_request", "storage_unavailable"}:
267
  raise BucketStorageError(category)
268
  raise BucketStorageError("storage_unavailable")
269
- if operation in {"save", "restore"}:
270
- expected_name = "readback.json" if operation == "save" else "restore.json"
 
 
 
 
271
  file_path = temp / expected_name
272
  if not file_path.is_file() or file_path.is_symlink():
273
  raise BucketStorageError("storage_unavailable")
274
- if file_path.stat().st_size > MAX_BUNDLE:
 
275
  raise BucketStorageError("invalid_request")
276
  content = file_path.read_bytes()
277
- if operation == "save":
278
- if hashlib.sha256(content).hexdigest() != result.get("sha256"):
 
279
  raise BucketStorageError("storage_unavailable")
280
  result["verified_bytes"] = content
281
  else:
282
- try:
283
- load_bundle(content)
284
- except BundleError:
285
- raise BucketStorageError("invalid_bundle") from None
286
- result["bundle_bytes"] = content
 
 
 
 
 
 
 
287
  return result
288
  except subprocess.TimeoutExpired:
289
  raise BucketStorageError("timeout") from None
@@ -311,14 +510,14 @@ def _verify_bucket(api, token: str, bucket_id: str):
311
  return username
312
 
313
 
314
- def _download_descriptor(api, token: str, bucket_id: str, key: str, destination: Path):
315
  from huggingface_hub._buckets import BucketFile
316
 
317
  metadata = api.get_bucket_file_metadata(bucket_id, key, token=token)
318
  size = getattr(metadata, "size", None)
319
  xet_data = getattr(metadata, "xet_file_data", None)
320
  xet_hash = getattr(xet_data, "file_hash", None)
321
- if type(size) is not int or size < 0 or size > MAX_BUNDLE or not isinstance(xet_hash, str) or not xet_hash:
322
  raise BucketStorageError("invalid_request")
323
  descriptor = BucketFile(
324
  type="file",
@@ -349,6 +548,66 @@ def _perform_operation(request: dict, api, temp: Path) -> dict:
349
  username = _verify_bucket(api, token, bucket_id)
350
  if operation == "connect":
351
  return {"status": "connected", "bucket_id": bucket_id, "username": username, "private": True}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
352
  if operation == "list":
353
  items = []
354
  scan_incomplete = False
@@ -384,7 +643,12 @@ def _perform_operation(request: dict, api, temp: Path) -> dict:
384
  source = temp / "upload"
385
  source.mkdir(mode=0o700)
386
  for filename, content in uploads.items():
387
- (source / filename).write_bytes(content)
 
 
 
 
 
388
  try:
389
  api.sync_bucket(
390
  str(source), f"hf://buckets/{bucket_id}/{prefix}",
@@ -396,7 +660,7 @@ def _perform_operation(request: dict, api, temp: Path) -> dict:
396
  for index, (filename, expected) in enumerate(uploads.items()):
397
  remote_path = f"{prefix}/{filename}"
398
  readback = temp / f"readback-{index}.bin"
399
- size, _ = _download_descriptor(api, token, bucket_id, remote_path, readback)
400
  content = readback.read_bytes()
401
  expected_sha = hashlib.sha256(expected).hexdigest()
402
  if size != len(expected) or hashlib.sha256(content).hexdigest() != expected_sha:
@@ -429,7 +693,8 @@ def _child_main() -> int:
429
  return 2
430
  try:
431
  request = json.loads(raw.decode("utf-8"))
432
- if not isinstance(request, dict) or request.get("operation") not in {"connect", "list", "save", "restore"}:
 
433
  raise BucketStorageError("invalid_request")
434
  operation = request["operation"]
435
  token = request.get("token")
 
15
  import tempfile
16
  import uuid
17
 
18
+ from portable_bundle import (
19
+ BundleError, MAX_BUNDLE, MAX_NETWORK, MAX_ANALYSIS_ARCHIVE,
20
+ MAX_ANALYSIS_FILE_BYTES, load_bundle,
21
+ )
22
  from worker import network_input
23
 
24
  HF_ENDPOINT = "https://huggingface.co"
25
  MAX_REQUEST = 2 * MAX_BUNDLE + 64 * 1024
26
+ MAX_RECIPE_LIBRARY = 32 * 1024
27
  MAX_CHILD_INPUT = 4 * ((MAX_BUNDLE + 2) // 3) + 64 * 1024
28
  MAX_CHILD_OUTPUT = 128 * 1024
29
  CHILD_TIMEOUT_SECONDS = 55
30
  MAX_LIST_SCAN = 10_000
31
  MAX_LIST_RESULTS = 50
32
  LEGACY_APP_KEY = re.compile(r"^ald-exports/([0-9a-f]{32}|[0-9a-f]{64})/bundle\.json$")
33
+ RECIPE_APP_KEY = re.compile(r"^ald-recipes/(?P<timestamp>[0-9]{8}T[0-9]{6}Z)-(?P<uuid>[0-9a-f]{32})/library\.json$")
34
  AUTOSAVE_APP_KEY = re.compile(
35
  r"^ald-exports/(?P<timestamp>[0-9]{8}T[0-9]{6}Z)-"
36
  r"(?P<track>[a-z0-9][a-z0-9-]{0,63})-"
 
39
  r"lap(?P<lap>none|[0-9]{1,16}ms)-(?P<uuid>[0-9a-f]{32})/bundle\.json$"
40
  )
41
  VEHICLE_NAMES = ("Formula", "Rally", "Snowmobile", "Truck")
42
+ MAX_SIDECAR_TOTAL = 320 * 1024 * 1024
43
  BUCKET_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}/[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$")
44
 
45
 
 
59
  return isinstance(value, str) and bool(LEGACY_APP_KEY.fullmatch(value) or AUTOSAVE_APP_KEY.fullmatch(value))
60
 
61
 
62
+ def valid_recipe_key(value: object) -> bool:
63
+ if not isinstance(value, str):
64
+ return False
65
+ match = RECIPE_APP_KEY.fullmatch(value)
66
+ if match is None:
67
+ return False
68
+ try:
69
+ datetime.strptime(match.group("timestamp"), "%Y%m%dT%H%M%SZ")
70
+ except ValueError:
71
+ return False
72
+ return True
73
+
74
+
75
+ RECIPE_DEFAULT_SETTINGS = {
76
+ "population": 4, "generations": 1, "ticks": 60, "mutation": 0.01,
77
+ "seed": 42, "mutation_end": 0.01, "schedule": "fixed", "selection": "best",
78
+ "parents": 1, "elite": 1, "crossover": "none", "decay": 0,
79
+ "adaptive_mutation": False, "control_hz": 30, "workers": 1,
80
+ "collision": False, "idle": False,
81
+ "rewards": [{"metric": "total_score", "type": "default", "weight": 100}],
82
+ }
83
+ RECIPE_METRICS = {
84
+ "total_score", "total_speed", "total_abs_steering", "total_actual_distance",
85
+ "total_drifting_amount", "total_momentum_change_frequency", "total_throttle_change_frequency",
86
+ "total_braking_frequency", "total_distance_from_wall", "total_distance_from_center",
87
+ "network_novelty", "collision_count", "best_lap_performance", "total_racing_line_efficiency",
88
+ }
89
+
90
+
91
+ def validate_recipe_library(raw: bytes) -> dict:
92
+ """Validate the existing browser RecipeLibrary v1 format, without storing other app data."""
93
+ if not isinstance(raw, bytes) or len(raw) > MAX_RECIPE_LIBRARY:
94
+ raise ValueError("Invalid recipe library")
95
+
96
+ def unique_object(pairs):
97
+ value = {}
98
+ for key, item in pairs:
99
+ if key in value:
100
+ raise ValueError("Duplicate recipe field")
101
+ value[key] = item
102
+ return value
103
+
104
+ try:
105
+ book = json.loads(raw.decode("utf-8"), object_pairs_hook=unique_object,
106
+ parse_constant=lambda _value: (_ for _ in ()).throw(ValueError("Invalid number")))
107
+ except Exception:
108
+ raise ValueError("Invalid recipe library") from None
109
+ if not isinstance(book, dict) or set(book) not in ({"version", "recipes"}, {"version", "recipes", "batches"}):
110
+ raise ValueError("Invalid recipe library")
111
+ if type(book.get("version")) is not int or book["version"] != 1:
112
+ raise ValueError("Invalid recipe library")
113
+ recipes = book.get("recipes")
114
+ if not isinstance(recipes, list) or len(recipes) > 10:
115
+ raise ValueError("Invalid recipe library")
116
+ seen = set()
117
+ for recipe in recipes:
118
+ if not isinstance(recipe, dict) or set(recipe) != {"slot", "name", "settings"}:
119
+ raise ValueError("Invalid recipe library")
120
+ slot, name = recipe["slot"], recipe["name"]
121
+ if not isinstance(slot, str) or not re.fullmatch(r"[A-J]", slot) or slot in seen:
122
+ raise ValueError("Invalid recipe library")
123
+ if not isinstance(name, str) or not name.strip() or len(name) > 80:
124
+ raise ValueError("Invalid recipe library")
125
+ seen.add(slot)
126
+ settings = recipe["settings"]
127
+ if not isinstance(settings, dict) or set(settings) - set(RECIPE_DEFAULT_SETTINGS):
128
+ raise ValueError("Invalid recipe library")
129
+ clean = {**RECIPE_DEFAULT_SETTINGS, **settings}
130
+ numeric = {
131
+ "population": (1, 128, True), "generations": (1, 1000, True),
132
+ "ticks": (1, 18000, True), "mutation": (0, 4.2, False),
133
+ "mutation_end": (0, 4.2, False), "seed": (0, 2147483647, True),
134
+ "parents": (1, 128, True), "elite": (0, 128, True),
135
+ "decay": (0, 1, False), "workers": (1, 4, True),
136
+ }
137
+ for key, (low, high, integer) in numeric.items():
138
+ value = clean[key]
139
+ if (type(value) not in (int, float) or not low <= value <= high
140
+ or (integer and not float(value).is_integer())):
141
+ raise ValueError("Invalid recipe library")
142
+ enums = {"schedule": {"fixed", "linear", "geometric"},
143
+ "selection": {"best", "tournament", "roulette"},
144
+ "crossover": {"none", "single_point", "uniform"},
145
+ "control_hz": {60, 30, 15}}
146
+ for key, choices in enums.items():
147
+ if type(clean[key]) not in (str, int, float) or clean[key] not in choices:
148
+ raise ValueError("Invalid recipe library")
149
+ for key in ("adaptive_mutation", "collision", "idle"):
150
+ if type(clean[key]) is not bool:
151
+ raise ValueError("Invalid recipe library")
152
+ if clean["parents"] > clean["population"] or clean["elite"] > clean["population"] or clean["mutation"] <= 0:
153
+ raise ValueError("Invalid recipe library")
154
+ if clean["schedule"] != "fixed" and clean["mutation_end"] <= 0:
155
+ raise ValueError("Invalid recipe library")
156
+ rewards = clean["rewards"]
157
+ if not isinstance(rewards, list) or not 1 <= len(rewards) <= 5:
158
+ raise ValueError("Invalid recipe library")
159
+ reward_metrics = set()
160
+ for reward in rewards:
161
+ if not isinstance(reward, dict) or set(reward) != {"metric", "type", "weight"}:
162
+ raise ValueError("Invalid recipe library")
163
+ metric, reward_type, weight = reward["metric"], reward["type"], reward["weight"]
164
+ if (not isinstance(metric, str) or metric not in RECIPE_METRICS
165
+ or not isinstance(reward_type, str) or reward_type not in {"default", "average"}
166
+ or metric in reward_metrics or type(weight) not in (int, float)
167
+ or not -100 <= weight <= 100 or not float(weight).is_integer()):
168
+ raise ValueError("Invalid recipe library")
169
+ reward_metrics.add(metric)
170
+ if not any(reward["weight"] for reward in rewards):
171
+ raise ValueError("Invalid recipe library")
172
+
173
+ batches = book.get("batches", [])
174
+ if not isinstance(batches, list) or len(batches) > 10:
175
+ raise ValueError("Invalid recipe library")
176
+ batch_slots = set()
177
+ for batch in batches:
178
+ if not isinstance(batch, dict) or set(batch) != {"slot", "name", "carry", "runs"}:
179
+ raise ValueError("Invalid recipe library")
180
+ slot, name, runs = batch["slot"], batch["name"], batch["runs"]
181
+ if (not isinstance(slot, str) or not re.fullmatch(r"[A-J]", slot) or slot in batch_slots
182
+ or not isinstance(name, str) or not name.strip() or len(name) > 80
183
+ or type(batch["carry"]) is not bool or not isinstance(runs, list) or not 1 <= len(runs) <= 100
184
+ or any(not isinstance(run, str) or run not in seen for run in runs)):
185
+ raise ValueError("Invalid recipe library")
186
+ batch_slots.add(slot)
187
+ return book
188
+
189
+
190
+ def _recipe_list_metadata(key: str, uploaded: object) -> tuple[str, str | None]:
191
+ match = RECIPE_APP_KEY.fullmatch(key)
192
+ if match is None:
193
+ raise BucketStorageError("invalid_request")
194
+ try:
195
+ path_when = datetime.strptime(match.group("timestamp"), "%Y%m%dT%H%M%SZ").replace(tzinfo=timezone.utc)
196
+ except ValueError:
197
+ path_when = None
198
+ when = _valid_utc_datetime(uploaded) or path_when
199
+ if when is None:
200
+ return f"Saved recipe library · {match.group('uuid')[:8]}", None
201
+ created_at = when.isoformat(timespec="microseconds").replace("+00:00", "Z")
202
+ return f"Recipe library · {when.strftime('%Y-%m-%d %H:%M UTC')} · {match.group('uuid')[:8]}", created_at
203
+
204
+
205
  def _autosave_prefix(bundle: dict, files: dict[str, bytes], now: datetime | None = None) -> tuple[str, str, str]:
206
  artifacts = bundle["artifacts"]
207
  receipt = artifacts.get("run_receipt")
 
234
  return "custom-track"
235
 
236
 
237
+ def winner_sidecar_filename(track_slug: str, vehicle: str, timestamp: str, unique: str, role: str) -> str:
238
+ role_label = {"fitness": "fitness-winner", "fastest_lap": "fastest-lap-winner",
239
+ "clean_laps": "clean-laps-winner"}.get(role)
240
+ if (not role_label or not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,63}", track_slug)
241
+ or vehicle not in {"formula", "rally", "snowmobile", "truck"}
242
+ or not re.fullmatch(r"[0-9]{8}T[0-9]{6}Z", timestamp)
243
+ or not re.fullmatch(r"[0-9a-f]{8}", unique)):
244
+ raise BucketStorageError("invalid_bundle")
245
+ return f"{track_slug}-{vehicle}-{timestamp}-{unique}-{role_label}.evolution"
246
+
247
+
248
  def _track_path_slug(track: dict | None) -> str:
249
  if isinstance(track, dict) and track.get("source") == "builtin":
250
  display_name = track.get("display_name")
 
257
  fragment = re.sub(r"[^A-Za-z0-9]+", "-", track_id).strip("-").lower()[:48].strip("-")
258
  if fragment:
259
  return "track-" + fragment
260
+ return "custom-track"
261
 
262
 
263
  def _render_history_csv(report: dict) -> bytes:
 
275
  receipt = artifacts["run_receipt"]
276
  report = receipt["report"]
277
  track = artifacts.get("selected_track")
278
+ summary = report.get("analysis_summary")
279
+ evaluation = isinstance(summary, dict) and summary.get("mode") == "evaluate"
280
  track_label = track.get("display_name", "Built-in track") if isinstance(track, dict) and track.get("source") == "builtin" else "Custom or uploaded track"
281
  lap = report["best_lap_seconds"]
282
  lap_label = "No completed lap" if lap is None else f"{lap:.3f} s"
283
  lines = [
284
+ "# ALD frozen evaluation" if evaluation else "# ALD training run",
285
  "",
286
  f"- Track: {track_label}",
287
  f"- Vehicle: {report['vehicle_name']}",
 
292
  f"- Final-generation best score: {report['best_score']}",
293
  f"- Final-generation mean score: {report['mean_score']}",
294
  f"- Best completed lap observed during this run: {lap_label}",
 
295
  f"- Lapped cars in the final generation: {report['lapped_cars']}",
296
  f"- Simulated ticks: {report['simulated_ticks']} of {report['requested_ticks']} requested per car",
297
  f"- Elapsed seconds: {report['elapsed_seconds']:.3f}",
 
302
  ]
303
  for key in sorted(report["settings"]):
304
  lines.append(f"- {key}: `{json.dumps(report['settings'][key], ensure_ascii=True, allow_nan=False, separators=(',', ':'))}`")
305
+ if isinstance(summary, dict):
306
+ lines.extend(["", "## Candidate analysis", "", f"- Mode: {summary['mode']}"])
307
+ if evaluation:
308
+ lines.append("- This is a frozen evaluation; the input network bytes and weights were preserved.")
309
+ else:
310
+ for role in ("fitness", "fastest_lap", "clean_laps"):
311
+ winner = summary["winners"][role]
312
+ if winner["status"] == "available":
313
+ lines.append(f"- {role.replace('_', ' ').title()}: generation {winner['generation']}, car {winner['car']}, candidate `{winner['candidate_id']}`")
314
+ else:
315
+ lines.append(f"- {role.replace('_', ' ').title()}: unavailable ({winner['reason']})")
316
+ lines.append("- A run-wide lap winner may be a different car from the exported fitness candidate.")
317
+ elif not evaluation:
318
+ lines.append("- The best completed lap may belong to a different car than the exported candidate.")
319
  return ("\n".join(lines) + "\n").encode("utf-8")
320
 
321
 
 
330
  vehicle = VEHICLE_NAMES[network_input(files["selected_network.evolution"])["Type"]].lower()
331
  uploads = {"bundle.json": bundle_bytes}
332
  network_suffix = prefix.rsplit("-", 1)[-1][:8]
333
+ readable_track = _readable_track(track, artifacts.get('selected_track'))
334
+ uploads[f"{readable_track}-{vehicle}-{timestamp}-{network_suffix}.evolution"] = files["selected_network.evolution"]
335
  if receipt is not None:
336
  uploads["report.md"] = _render_report(bundle)
337
  uploads["generation-history.csv"] = _render_history_csv(receipt["report"])
338
+ if bundle.get("version") == 2:
339
+ if "analysis_archive.gz" not in files:
340
+ raise BucketStorageError("invalid_bundle")
341
+ uploads["analysis-archive.gz"] = files["analysis_archive.gz"]
342
+ for name, content in files.items():
343
+ if name.startswith("analysis/"):
344
+ uploads[name] = content
345
+ for role in ("fitness", "fastest_lap", "clean_laps"):
346
+ name = f"analysis/winners/{role}.evolution"
347
+ if name in files:
348
+ uploads[winner_sidecar_filename(readable_track, vehicle, timestamp, network_suffix, role)] = files[name]
349
  if sum(map(len, uploads.values())) > MAX_BUNDLE + MAX_NETWORK + MAX_SIDECAR_TOTAL:
350
  raise BucketStorageError("invalid_bundle")
351
  return prefix, uploads
 
417
 
418
  def run_bucket_operation(operation: str, token: str, bucket_id: str, **args):
419
  """Run one HF operation in an isolated process with a temporary Xet cache."""
420
+ if operation not in {"connect", "list", "save", "restore", "recipes_list", "recipes_save", "recipes_restore"}:
421
  raise BucketStorageError("invalid_request")
422
  if not isinstance(token, str) or not token or len(token) > 4096 or any(ch.isspace() or ord(ch) < 33 for ch in token):
423
  raise BucketStorageError("unauthorized")
 
452
  if category in {"unauthorized", "forbidden", "not_found", "invalid_request", "storage_unavailable"}:
453
  raise BucketStorageError(category)
454
  raise BucketStorageError("storage_unavailable")
455
+ if operation in {"save", "restore", "recipes_save", "recipes_restore"}:
456
+ is_recipe = operation.startswith("recipes_")
457
+ is_save = operation in {"save", "recipes_save"}
458
+ expected_name = "recipe-readback.json" if operation == "recipes_save" else (
459
+ "recipe-restore.json" if operation == "recipes_restore" else (
460
+ "readback.json" if is_save else "restore.json"))
461
  file_path = temp / expected_name
462
  if not file_path.is_file() or file_path.is_symlink():
463
  raise BucketStorageError("storage_unavailable")
464
+ max_bytes = MAX_RECIPE_LIBRARY if is_recipe else MAX_BUNDLE
465
+ if file_path.stat().st_size > max_bytes:
466
  raise BucketStorageError("invalid_request")
467
  content = file_path.read_bytes()
468
+ if is_save:
469
+ if (hashlib.sha256(content).hexdigest() != result.get("sha256")
470
+ or len(content) != result.get("bytes")):
471
  raise BucketStorageError("storage_unavailable")
472
  result["verified_bytes"] = content
473
  else:
474
+ if is_recipe:
475
+ try:
476
+ validate_recipe_library(content)
477
+ except Exception:
478
+ raise BucketStorageError("invalid_request") from None
479
+ result["recipe_bytes"] = content
480
+ else:
481
+ try:
482
+ load_bundle(content)
483
+ except BundleError:
484
+ raise BucketStorageError("invalid_bundle") from None
485
+ result["bundle_bytes"] = content
486
  return result
487
  except subprocess.TimeoutExpired:
488
  raise BucketStorageError("timeout") from None
 
510
  return username
511
 
512
 
513
+ def _download_descriptor(api, token: str, bucket_id: str, key: str, destination: Path, *, max_bytes: int = MAX_BUNDLE):
514
  from huggingface_hub._buckets import BucketFile
515
 
516
  metadata = api.get_bucket_file_metadata(bucket_id, key, token=token)
517
  size = getattr(metadata, "size", None)
518
  xet_data = getattr(metadata, "xet_file_data", None)
519
  xet_hash = getattr(xet_data, "file_hash", None)
520
+ if type(size) is not int or size < 0 or size > max_bytes or not isinstance(xet_hash, str) or not xet_hash:
521
  raise BucketStorageError("invalid_request")
522
  descriptor = BucketFile(
523
  type="file",
 
548
  username = _verify_bucket(api, token, bucket_id)
549
  if operation == "connect":
550
  return {"status": "connected", "bucket_id": bucket_id, "username": username, "private": True}
551
+ if operation == "recipes_list":
552
+ items = []
553
+ scan_incomplete = False
554
+ for index, item in enumerate(api.list_bucket_tree(bucket_id, prefix="ald-recipes", recursive=True, token=token)):
555
+ if index >= MAX_LIST_SCAN:
556
+ scan_incomplete = True
557
+ break
558
+ key = getattr(item, "path", None)
559
+ size = getattr(item, "size", None)
560
+ if valid_recipe_key(key) and type(size) is int and 0 <= size <= MAX_RECIPE_LIBRARY:
561
+ uploaded = getattr(item, "uploaded_at", None) or getattr(item, "mtime", None)
562
+ label, created_at = _recipe_list_metadata(key, uploaded)
563
+ items.append({"key": key, "bytes": size, "label": label, "created_at": created_at})
564
+ items.sort(key=lambda item: item["created_at"] or "", reverse=True)
565
+ truncated = len(items) > MAX_LIST_RESULTS
566
+ return {"items": items[:MAX_LIST_RESULTS], "truncated": truncated,
567
+ "scan_incomplete": scan_incomplete}
568
+ if operation == "recipes_save":
569
+ encoded = request.get("library_base64")
570
+ if not isinstance(encoded, str) or len(encoded) > 4 * ((MAX_RECIPE_LIBRARY + 2) // 3):
571
+ raise BucketStorageError("invalid_request")
572
+ try:
573
+ library = base64.b64decode(encoded, validate=True)
574
+ validate_recipe_library(library)
575
+ except Exception:
576
+ raise BucketStorageError("invalid_request") from None
577
+ digest = hashlib.sha256(library).hexdigest()
578
+ timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
579
+ prefix = f"ald-recipes/{timestamp}-{uuid.uuid4().hex}"
580
+ source = temp / "recipe-upload"
581
+ source.mkdir(mode=0o700)
582
+ (source / "library.json").write_bytes(library)
583
+ try:
584
+ api.sync_bucket(str(source), f"hf://buckets/{bucket_id}/{prefix}",
585
+ delete=False, ignore_existing=True, quiet=True, token=token)
586
+ except Exception:
587
+ raise BucketStorageError("storage_unavailable") from None
588
+ key = f"{prefix}/library.json"
589
+ readback = temp / "recipe-readback.json"
590
+ size, _ = _download_descriptor(api, token, bucket_id, key, readback,
591
+ max_bytes=MAX_RECIPE_LIBRARY)
592
+ content = readback.read_bytes()
593
+ if size != len(library) or content != library or hashlib.sha256(content).hexdigest() != digest:
594
+ raise BucketStorageError("storage_unavailable")
595
+ return {"status": "saved_verified", "bucket_id": bucket_id, "key": key,
596
+ "bytes": len(library), "sha256": digest}
597
+ if operation == "recipes_restore":
598
+ key = request.get("key")
599
+ if not valid_recipe_key(key):
600
+ raise BucketStorageError("invalid_request")
601
+ restore = temp / "recipe-restore.json"
602
+ size, _ = _download_descriptor(api, token, bucket_id, key, restore,
603
+ max_bytes=MAX_RECIPE_LIBRARY)
604
+ content = restore.read_bytes()
605
+ try:
606
+ validate_recipe_library(content)
607
+ except Exception:
608
+ raise BucketStorageError("invalid_request") from None
609
+ return {"status": "restored", "key": key, "bytes": size,
610
+ "sha256": hashlib.sha256(content).hexdigest()}
611
  if operation == "list":
612
  items = []
613
  scan_incomplete = False
 
643
  source = temp / "upload"
644
  source.mkdir(mode=0o700)
645
  for filename, content in uploads.items():
646
+ if (not isinstance(filename, str) or filename.startswith("/")
647
+ or any(part in {"", ".", ".."} for part in filename.split("/"))):
648
+ raise BucketStorageError("invalid_bundle")
649
+ path = source.joinpath(*filename.split("/"))
650
+ path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
651
+ path.write_bytes(content)
652
  try:
653
  api.sync_bucket(
654
  str(source), f"hf://buckets/{bucket_id}/{prefix}",
 
660
  for index, (filename, expected) in enumerate(uploads.items()):
661
  remote_path = f"{prefix}/{filename}"
662
  readback = temp / f"readback-{index}.bin"
663
+ size, _ = _download_descriptor(api, token, bucket_id, remote_path, readback, max_bytes=len(expected))
664
  content = readback.read_bytes()
665
  expected_sha = hashlib.sha256(expected).hexdigest()
666
  if size != len(expected) or hashlib.sha256(content).hexdigest() != expected_sha:
 
693
  return 2
694
  try:
695
  request = json.loads(raw.decode("utf-8"))
696
+ if not isinstance(request, dict) or request.get("operation") not in {
697
+ "connect", "list", "save", "restore", "recipes_list", "recipes_save", "recipes_restore"}:
698
  raise BucketStorageError("invalid_request")
699
  operation = request["operation"]
700
  token = request.get("token")
build_info.py CHANGED
@@ -1,4 +1,4 @@
1
- """Content-addressed package identity; no environment or credentials in reports."""
2
  import hashlib
3
  import json
4
  from pathlib import Path
@@ -6,15 +6,19 @@ from pathlib import Path
6
  ROOT = Path(__file__).resolve().parent
7
 
8
  def build_identity():
9
- manifest = json.loads((ROOT / 'RELEASE_MANIFEST.json').read_text())
10
- upstream = json.loads((ROOT / 'UPSTREAM.json').read_text())
11
- members = json.dumps(manifest['files'], sort_keys=True, separators=(',', ':')).encode()
 
12
  return {
13
- 'schema_version': 1,
14
- 'build_id': 'ald-beta-' + hashlib.sha256(members).hexdigest(),
15
- 'source_repository': upstream['repository'],
16
- 'simulator_source_revision': upstream['commit'],
17
- 'math_profile': upstream['math_profile'],
18
- 'package_role': upstream['role'],
19
- 'binary_sha256': {name: digest for name, digest in manifest['files'].items() if name.startswith('bin/')},
 
 
 
20
  }
 
1
+ """Content-addressed package identity with explicit native patch provenance."""
2
  import hashlib
3
  import json
4
  from pathlib import Path
 
6
  ROOT = Path(__file__).resolve().parent
7
 
8
  def build_identity():
9
+ manifest = json.loads((ROOT / "RELEASE_MANIFEST.json").read_text())
10
+ upstream = json.loads((ROOT / "UPSTREAM.json").read_text())
11
+ members = json.dumps(manifest["files"], sort_keys=True, separators=(",", ":")).encode()
12
+ native = upstream["native_build"]
13
  return {
14
+ "schema_version": 1,
15
+ "build_id": "ald-winners-" + hashlib.sha256(members).hexdigest(),
16
+ "source_repository": upstream["repository"],
17
+ "simulator_source_revision": upstream["commit"],
18
+ "math_profile": upstream["math_profile"],
19
+ "package_role": 'gpu',
20
+ "binary_sha256": {name: digest for name, digest in manifest["files"].items() if name.startswith("bin/")},
21
+ "native_source_revision": native["source_revision"],
22
+ "native_patch_sha256": native["patch_sha256"],
23
+ "native_build_receipt_sha256": native["build_receipt_sha256"],
24
  }
client.js CHANGED
@@ -3,6 +3,11 @@ const $ = id => document.getElementById(id);
3
  let job = null;
4
  let busy = false;
5
  let localResult = null;
 
 
 
 
 
6
  let capabilitiesReady = false;
7
  let gpuTransport = null;
8
  let gpuAvailable = false;
@@ -23,6 +28,8 @@ let bucketId = null;
23
  let bucketUsername = null;
24
  let bucketItems = [];
25
  let bucketSelectedKey = null;
 
 
26
  let bucketOperation = false;
27
  let bucketOperationId = 0;
28
  let bucketEpoch = 0;
@@ -50,30 +57,53 @@ function buttons() {
50
  $('download').disabled = busy || !job;
51
  $('erase').disabled = busy || !job;
52
  const autosaving=!!bucketToken&&!!$('bucket_autosave')?.checked;
53
- $('keepServer').disabled = busy || !!job || autosaving;
54
  $('saveLocal').disabled = busy || !localResult;
55
  $('forgetLocal').disabled = busy || !localResult;
56
  $('track').disabled=unavailable;
57
  $('network').disabled=unavailable;
58
- if($('batch_start'))$('batch_start').disabled=unavailable || !capabilitiesReady || ($('backend').value==='gpu'&&!gpuAvailable);
59
  if($('batch_stop'))$('batch_stop').disabled=!batchActive || batchStopRequested;
60
- if($('run_export'))$('run_export').disabled=busy || !runReceipt;
61
- const report=cleanRunReceipt(runReceipt)||cleanRunReceipt(portableReceipt);
62
  if($('player_report_download'))$('player_report_download').disabled=busy||!report;
63
  if($('history_csv_download'))$('history_csv_download').disabled=busy||!report?.report?.history?.length;
64
  if($('batch_export'))$('batch_export').disabled=busy || !batchReceipt;
65
- if($('bundle_export'))$('bundle_export').disabled=unavailable;
 
 
 
 
 
 
66
  if($('bundle_import'))$('bundle_import').disabled=unavailable;
67
  const storageToken=window.HFAuth?.getStorageToken?.()??null;
68
  if($('bucket_connect'))$('bucket_connect').disabled=unavailable||!storageToken||!!bucketToken;
69
  if($('bucket_id'))$('bucket_id').disabled=unavailable||!storageToken||!!bucketToken;
70
  if($('bucket_disconnect'))$('bucket_disconnect').disabled=unavailable||!bucketToken;
71
- if($('bucket_save'))$('bucket_save').disabled=unavailable||!bucketToken||(!networkSource()&&!localResult);
72
  if($('bucket_refresh'))$('bucket_refresh').disabled=unavailable||!bucketToken;
73
  if($('bucket_restore'))$('bucket_restore').disabled=unavailable||!bucketToken||!$('bucket_items')?.value;
74
  if($('bucket_items'))$('bucket_items').disabled=unavailable||!bucketToken;
 
 
 
 
75
  if($('bucket_autosave'))$('bucket_autosave').disabled=unavailable||!bucketToken;
 
76
  }
 
 
 
 
 
 
 
 
 
 
 
 
77
  function renderBucketConnection(){
78
  const connected=!!bucketToken&&!!bucketId;
79
  const authorized=!!window.HFAuth?.getStorageToken?.();
@@ -89,11 +119,20 @@ function renderBucketConnection(){
89
  select.replaceChildren(...options);
90
  if(options.length)select.value=bucketSelectedKey&&bucketItems.some(item=>item.key===bucketSelectedKey)?bucketSelectedKey:'';
91
  }
 
 
 
 
 
 
 
 
92
  buttons();
93
  }
94
  const bucketKeyValid=key=>typeof key==='string'&&(
95
  /^ald-exports\/(?:[0-9a-f]{32}|[0-9a-f]{64})\/bundle\.json$/.test(key)||
96
  /^ald-exports\/\d{8}T\d{6}Z-[a-z0-9-]{1,64}-(?:formula|rally|snowmobile|truck)-g\d{1,4}-lap(?:\d{1,16}ms|none)-[0-9a-f]{32}\/bundle\.json$/.test(key));
 
97
  function bucketSnapshot(){return {token:bucketToken,id:bucketId,epoch:bucketEpoch};}
98
  function bucketSnapshotCurrent(snapshot){return !!snapshot?.token&&snapshot.epoch===bucketEpoch&&snapshot.token===bucketToken&&snapshot.id===bucketId;}
99
  function clearBucketConnection(snapshot=null){
@@ -101,14 +140,16 @@ function clearBucketConnection(snapshot=null){
101
  bucketEpoch++;
102
  bucketOperationId++;bucketOperation=false;
103
  bundleRestoreId++;bundleRestoring=false;
104
- bucketToken=null;bucketId=null;bucketUsername=null;bucketItems=[];bucketSelectedKey=null;
105
  if($('bucket_id'))$('bucket_id').value='';
106
  if($('bucket_status'))$('bucket_status').textContent='Bucket connection cleared. Reconnect to continue.';
 
107
  renderBucketConnection();
108
  return true;
109
  }
110
  async function bucketRequest(endpoint,body,snapshot){
111
- if(!['connect','list','save','restore'].includes(endpoint))throw Error('Unsupported bucket action');
 
112
  const current=()=>endpoint==='connect'
113
  ? snapshot.epoch===bucketEpoch&&bucketToken===null&&bucketId===null
114
  : bucketSnapshotCurrent(snapshot);
@@ -122,7 +163,7 @@ function beginBucketOperation(){bucketOperation=true;const id=++bucketOperationI
122
  function endBucketOperation(id){if(id!==bucketOperationId)return;bucketOperation=false;buttons();}
123
  async function saveBucketBundleText(text,snapshot){
124
  if(!bucketSnapshotCurrent(snapshot))throw Error('Bucket connection changed; no save was attempted.');
125
- const bytes=new TextEncoder().encode(text);if(bytes.byteLength>window.PortableBundle.LIMITS.bundle)throw Error('Portable bundle exceeds 8 MiB');
126
  const sha256=await window.PortableBundle.sha(bytes);
127
  const response=await bucketRequest('save',{bucket_id:snapshot.id,bundle:text},snapshot),result=await response.json();
128
  if(!bucketSnapshotCurrent(snapshot))throw Error('Bucket connection changed; the old save response was ignored.');
@@ -209,6 +250,81 @@ if($('bucket_refresh'))$('bucket_refresh').onclick=async()=>{
209
  catch(error){if(bucketSnapshotCurrent(snapshot))$('bucket_status').textContent=error.message||'Could not refresh saved bundles.';}
210
  finally{endBucketOperation(operationId);}
211
  };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212
  if($('bucket_items'))$('bucket_items').addEventListener('change',()=>{bucketSelectedKey=$('bucket_items').value||null;buttons();});
213
  if($('bucket_autosave'))$('bucket_autosave').addEventListener('change',()=>{
214
  if($('bucket_autosave').checked&&$('keepServer').checked){$('keepServer').checked=false;$('bucket_status').textContent='Automatic bucket saving requires deletion after a verified download; the repeat-download option was turned off.';}
@@ -235,7 +351,7 @@ if($('bucket_restore'))$('bucket_restore').onclick=async()=>{
235
  const response=await bucketRequest('restore',{bucket_id:snapshot.id,key},snapshot);
236
  const text=await response.text();
237
  if(!bucketSnapshotCurrent(snapshot))return;
238
- if(new TextEncoder().encode(text).byteLength>window.PortableBundle.LIMITS.bundle)throw Error('Portable bundle exceeds 8 MiB');
239
  await restorePortableText(text,{lockHeld:true,isCurrent:()=>bucketSnapshotCurrent(snapshot)});
240
  if(bucketSnapshotCurrent(snapshot))$('bucket_status').textContent='Bundle restored in this page only. No run started and the existing result remains available.';
241
  }catch(error){if(bucketSnapshotCurrent(snapshot))$('bucket_status').textContent='Bundle not restored: '+(error.message||'validation failed')+'. Existing selections and results were left unchanged.';}
@@ -263,7 +379,7 @@ function saveLocal() {
263
  setTimeout(()=>URL.revokeObjectURL(url),60000);
264
  }
265
  $('saveLocal').onclick=saveLocal;
266
- $('forgetLocal').onclick=()=>{localResult=null;buttons();say('Browser result discarded. Downloaded files are unaffected.');};
267
  async function request(path, options = {}) {
268
  const response = await fetch(path, {...options, cache:'no-store', credentials:'same-origin'});
269
  if (!response.ok) {
@@ -280,7 +396,7 @@ async function encoded(file) {
280
  }
281
  const headers = () => ({'X-Job-Key':job.key});
282
  const networkSource=()=>stagedNetwork || $('network').files[0];
283
- $('network').onchange=()=>{stagedNetwork=null;if($('networkChoice'))$('networkChoice').textContent='Uploaded file selected.';buttons();};
284
  window.TrainingClient={
285
  isBusy:()=>busy||!!job||bundleRestoring,
286
  hasUnsavedState:()=>!!(networkSource()||localResult||$('track').files.length||trackLibrary?.info?.()?.source==='builtin'||$('editor_file')?.files.length||window.hasUnsavedNetworkDraft?.()),
@@ -372,12 +488,282 @@ async function receive(current){
372
  const bytes=await response.arrayBuffer();
373
  const digest=await sha(bytes);
374
  if(bytes.byteLength!==current.size || digest!==current.sha256)throw Error('Result validation failed; no deletion acknowledgment sent. Use Delete now or await expiry.');
375
- return {bytes,receipt:response.headers.get('x-delivery-receipt')};
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
376
  }
377
- async function acknowledge(current,receipt){
378
- return (await request('/api/jobs/'+current.id+'/ack',{method:'POST',headers:{'X-Job-Key':current.key,'Content-Type':'application/json'},body:JSON.stringify({receipt})})).json();
 
 
 
 
 
 
 
379
  }
380
  async function sha(bytes){return Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256',bytes)),x=>x.toString(16).padStart(2,'0')).join('');}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
381
  function showDetails(r){
382
  const metric=n=>typeof n==='number'?n.toFixed(3):'Not reported';
383
  if($('effectiveSettings'))$('effectiveSettings').textContent=JSON.stringify(r?.settings??{},null,2);
@@ -385,12 +771,13 @@ function showDetails(r){
385
  'Generation '+h.generation+' · mutation '+metric(h.mutation)+' · best '+metric(h.best_score)+' · mean '+metric(h.mean_score)+' · lap '+(h.best_lap_seconds??'none')+' · cars lapped '+(h.lapped_cars??'not reported')+' · ticks '+metric(h.simulated_ticks)).join('\n');
386
  }
387
  $('run').onclick = async () => {
 
388
  cancelSteamLeaderboard();
389
  if($('leaderboard_status'))$('leaderboard_status').textContent='';
390
  const started=performance.now();
391
  $('runState').textContent='RUNNING — waiting for server confirmation';
392
  $('runState').style.color='#ffd073';
393
- runReceipt=null;
394
  runResultContext=null;
395
  showDetails(null);
396
  const tick=()=>{$('runSummary').textContent='Backend: '+$('backend').value.toUpperCase()+'\nElapsed: '+((performance.now()-started)/1000).toFixed(1)+' s\nCompletion is not yet confirmed.';};
@@ -400,12 +787,22 @@ $('run').onclick = async () => {
400
  const trackInfo=trackLibrary?.info()??{source:'upload'};
401
  const trackFile=await trackSource(),networkFile=networkSource();
402
  const trackBytes=new Uint8Array(await trackFile.arrayBuffer()),networkBytes=new Uint8Array(await networkFile.arrayBuffer());
 
 
 
 
 
 
 
 
 
403
  const autosaveSnapshot=bucketToken&&$('bucket_autosave')?.checked?bucketSnapshot():null;
404
  if(autosaveSnapshot)$('keepServer').checked=false;
405
  const context={trackInfo,trackBytes,inputNetworkSha256:await sha(networkBytes),autosaveSnapshot};
406
  const body={track:await encoded(trackFile),network:await encoded(networkFile),automatic:autosaveSnapshot?true:!$('keepServer').checked,backend:$('backend').value};
407
- if(window.getTrainingSettings)body.settings=window.getTrainingSettings();
408
- job=await submit(body);localResult=null;
 
409
  clearInterval(progress);
410
  $('runState').textContent='COMPLETE — candidate ready to download';
411
  $('runState').style.color='#62dfb1';
@@ -420,7 +817,7 @@ $('run').onclick = async () => {
420
  'Track: '+(trackInfo?.source==='builtin'?trackInfo.display_name:'Custom upload'),
421
  'Generations: '+r.generations_completed+' / '+r.generations_requested+' · Population: '+r.population,
422
  'Server processing: '+metric(r.elapsed_seconds)+' s',
423
- 'Final-generation population scores — best: '+metric(r.best_score)+' · mean: '+metric(r.mean_score),
424
  'Best completed lap observed during this run (may be another car): '+(r.best_lap_seconds==null?'No completed lap':metric(r.best_lap_seconds)+' s'),
425
  'Cars completing a lap in the final generation: '+(r.lapped_cars??'Not reported'),
426
  'Actual frames (including stats cutoff): '+(r.simulated_ticks??'Not reported')+' · Configured time limit: '+r.requested_ticks+' ticks',
@@ -430,7 +827,7 @@ $('run').onclick = async () => {
430
  showDetails(r);
431
  if($('leaderboard_opt_in')?.checked)void checkSteamLeaderboard(trackInfo,r);
432
  else if($('leaderboard_status'))$('leaderboard_status').textContent='';
433
- $('track').value=''; $('network').value='';
434
  stagedNetwork=null;if($('networkChoice'))$('networkChoice').textContent='Network input released after the run.';
435
  say('Result ready ('+job.backend.toUpperCase()+'). Inputs and intermediate worker files have been removed.');
436
  if(autosaveSnapshot){
@@ -442,7 +839,14 @@ $('run').onclick = async () => {
442
  try{await refreshBucketItems(autosaveSnapshot,{selectKey:outcome.saved.key,preflight:false});}
443
  catch{$('bucket_status').textContent='Network and run log were saved and verified. The saved-bundle list could not refresh; use Refresh saved bundles.';}
444
  }catch(error){
445
- if(!context.localVerified){
 
 
 
 
 
 
 
446
  $('runSummary').textContent+='\nAutomatic receive failed: '+error.message+' No verified local copy is available. The server result may remain available until deletion or expiry; retry download or delete it.';
447
  $('bucket_status').textContent='Run completed, but the result was not verified in this browser. No bucket save was attempted.';
448
  $('runState').textContent='COMPLETE — result not received or verified';$('runState').style.color='#ffd073';
@@ -458,9 +862,44 @@ $('run').onclick = async () => {
458
  say('Run completed. A verified candidate remains in this page; bucket save needs attention.');
459
  }
460
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
461
  }
462
  } catch (error) {
 
 
 
 
 
 
463
  clearInterval(progress);
 
 
 
 
 
 
 
 
 
464
  $('runState').textContent='ERROR — no successful result confirmed';
465
  $('runState').style.color='#ffb5a2';
466
  const quotaRejected=isGpuQuotaError(error)||String(error?.message??'').startsWith('GPU allowance is exhausted for this request.');
@@ -469,19 +908,22 @@ $('run').onclick = async () => {
469
  : 'No downloadable result was received. A connection error does not prove the server stopped.');
470
  say(displayError(error));
471
  }
472
- finally {clearInterval(progress);busy=false;$('keepServer').checked=false;buttons();}
473
  };
474
  $('download').onclick = async () => {
475
  busy=true; buttons();
476
  try {
477
- const {bytes,receipt}=await receive(job);
478
  // The browser has the complete validated result. Disk-save success cannot be observed.
479
- localResult=new Blob([bytes]); saveLocal();
480
- const acknowledgment=await acknowledge(job,receipt);
481
  if(runReceipt)runReceipt.delivery={received_and_hash_verified:true,server_copy_deleted:acknowledgment.deleted===true};
482
  if (acknowledgment.deleted) { $('runState').textContent='COMPLETE — result received; server copy deleted'; job=null; say('Result received by browser. Server result deleted. Save your local download; server retrieval is no longer available.'); }
483
  else say('Result received by browser. The server copy is available for another download until its five-minute deadline; use Delete now to remove it sooner.');
484
- } catch (error) { say(error.message+' If receipt acknowledgment failed, use Delete now; automatic expiry still applies.'); }
 
 
 
485
  finally {busy=false; buttons();}
486
  };
487
  $('erase').onclick=async()=>{
@@ -504,7 +946,7 @@ if($('batch_start')){
504
  await encoded(original); // Reject missing/oversize inputs before dispatch.
505
  const backend=$('backend').value,autosaveSnapshot=bucketToken&&$('bucket_autosave')?.checked?bucketSnapshot():null;
506
  busy=true;batchActive=true;batchStopRequested=false;batchReceipt=null;buttons();
507
- localResult=null;
508
  $('batch_history').replaceChildren();
509
  $('batches_feedback').textContent='';
510
  $('runState').textContent='BATCH RUNNING — completion not yet confirmed';
@@ -514,12 +956,13 @@ if($('batch_start')){
514
  $('batch_status').textContent='RUNNING · '+plan.name+' · step '+(i+1)+' / '+plan.runs.length+' · '+plan.runs[i].slot;
515
  const inputHash=await sha(await source.arrayBuffer());
516
  job=await submit({track,network:await encoded(source),settings,backend,automatic:true});
517
- const stepJob=job,{bytes,receipt}=await receive(job);
518
- localResult=new Blob([bytes]);showDetails(job.report);
519
- const report=stepJob.report,stepRunReceipt={version:1,state:'completed',build:report?.build??appBuild,backend,output_sha256:stepJob.sha256,report};
520
- const stepContext={trackInfo,trackBytes,inputNetworkSha256:inputHash,autosaveSnapshot};
 
521
  runReceipt=stepRunReceipt;runResultContext=stepContext;runReceipt.delivery={received_and_hash_verified:true,server_copy_deleted:false};
522
- const ack=await acknowledge(job,receipt);
523
  if(!ack.deleted)throw Error('Intermediate deletion was not confirmed; batch stopped');
524
  runReceipt.delivery.server_copy_deleted=true;
525
  const summary={backend,input_network_sha256:inputHash,output_sha256:stepJob.sha256,server_result_deleted:true,report};
@@ -544,6 +987,7 @@ if($('batch_start')){
544
  $('runSummary').textContent='Backend: '+backend.toUpperCase()+'\nCompleted steps: '+history.length+'\nUse Save received result again for the last candidate. A completed batch does not establish improved driving.';
545
  say('Batch results are in browser memory only; no server result remains for completed steps.');
546
  }catch(error){
 
547
  if(failedCandidateStep&&!history.some(step=>step.step===failedCandidateStep.step)){
548
  const failed={...failedCandidateStep,autosave:'failed'};history.push(failed);
549
  const line=document.createElement('p');line.textContent='Step '+failed.step+' · '+failed.slot+' · '+failed.name+' · result received and server copy deleted · bucket save not confirmed';$('batch_history').append(line);
@@ -615,7 +1059,8 @@ if($('run_export'))$('run_export').onclick=()=>{
615
  const a=document.createElement('a');a.href=url;a.download='ald-run-receipt.json';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);
616
  };
617
  function activeCleanReceipt(){return cleanRunReceipt(runReceipt)||cleanRunReceipt(portableReceipt);}
618
- function downloadTextFile(text,name,type){const url=URL.createObjectURL(new Blob([text],{type}));const a=document.createElement('a');a.href=url;a.download=name;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);}
 
619
  function formatPlayerReport(receipt){
620
  const r=receipt.report,metric=value=>typeof value==='number'?String(value):'Not reported';
621
  const lines=['ALD driving run report','',`Build: ${receipt.build_id}`,`Backend: ${receipt.backend}`,`Vehicle: ${r.vehicle_name} (Type ${r.vehicle_type})`,`Generations: ${r.generations_completed} / ${r.generations_requested}`,`Population: ${r.population}`,`Elapsed seconds: ${metric(r.elapsed_seconds)}`,`Final-generation population best score: ${metric(r.best_score)}`,`Final-generation population mean score: ${metric(r.mean_score)}`,`Best completed lap observed during this run (may be another car): ${r.best_lap_seconds===null?'None':metric(r.best_lap_seconds)+' s'}`,`Cars completing a lap in the final generation: ${r.lapped_cars===null?'Not reported':r.lapped_cars}`,`Simulated ticks: ${r.simulated_ticks}`,`Input network SHA-256: ${r.inputs.network_sha256}`,`Track SHA-256: ${r.inputs.track_sha256}`,`Output network SHA-256: ${receipt.output_sha256}`,'','Configured training settings:',JSON.stringify(r.settings,null,2),'','Reward weights are configured values; per-reward contribution is not reported by the engine.'];
@@ -661,21 +1106,35 @@ function cleanRunReceipt(source){
661
  try{
662
  bundleExact(report.inputs,['track_sha256','network_sha256']);
663
  if(!/^[a-f0-9]{64}$/.test(report.inputs.track_sha256)||!/^[a-f0-9]{64}$/.test(report.inputs.network_sha256))return null;
664
- bundleExact(report.settings,Object.keys(window.RecipeLibrary.defaults));
665
- const settings=window.RecipeLibrary.settings(report.settings),history=cleanRunHistory(report.history);
 
 
 
 
 
 
 
 
 
 
 
 
 
666
  if(report.population!==settings.population||report.generations_requested!==settings.generations||report.requested_ticks!==settings.ticks||(history&&history.length!==report.generations_completed))return null;
667
  return {version:1,kind:'run',state:'completed',build_id:buildId,backend:source.backend,output_sha256:source.output_sha256,
668
  report:{vehicle_type:report.vehicle_type,vehicle_name:report.vehicle_name,generations_completed:report.generations_completed,
669
  generations_requested:report.generations_requested,population:report.population,elapsed_seconds:report.elapsed_seconds,
670
  best_score:report.best_score,mean_score:report.mean_score,best_lap_seconds:report.best_lap_seconds,lapped_cars:report.lapped_cars,
671
  simulated_ticks:report.simulated_ticks,requested_ticks:report.requested_ticks,candidate_validated:true,
672
- execution_backend:report.execution_backend,inputs:{track_sha256:report.inputs.track_sha256,network_sha256:report.inputs.network_sha256},settings,...(history?{history}:{})}};
673
  }catch{return null;}
674
  }
675
  function validatePortableReceipt(receipt,network,track){
676
  bundleExact(receipt,['version','kind','state','build_id','backend','output_sha256','report']);
677
  const report=receipt.report,reportKeys=['vehicle_type','vehicle_name','generations_completed','generations_requested','population','elapsed_seconds','best_score','mean_score','best_lap_seconds','lapped_cars','simulated_ticks','requested_ticks','candidate_validated','execution_backend','inputs','settings'];
678
  if(Object.hasOwn(report,'history'))reportKeys.push('history');
 
679
  bundleExact(report,reportKeys);
680
  const clean=cleanRunReceipt(receipt);
681
  if(receipt.version!==1||receipt.kind!=='run'||receipt.state!=='completed'||!clean)throw Error('Run receipt failed its allowlist or value checks');
@@ -706,9 +1165,10 @@ async function validateBundleTrack(track){
706
  return file;
707
  }
708
  async function buildPortableBundle(){
 
709
  const api=window.PortableBundle;if(!api)throw Error('Portable bundle support is unavailable');
710
- const inputNetwork=networkSource(),selected=inputNetwork||localResult;if(!selected)throw Error('Choose or download a network/result first');
711
- const kind=inputNetwork?'input':'result',networkBytes=new Uint8Array(await selected.arrayBuffer());
712
  await validateNetworkBytes(networkBytes);
713
  const network=await api.artifact('evolution',kind,networkBytes),artifacts={selected_network:network};
714
  let selectedTrack=null,trackInfo=null;
@@ -724,18 +1184,23 @@ async function buildPortableBundle(){
724
  if($('bundle_recipe').checked){
725
  const recipe=window.getPortableRecipe?.();if(recipe)artifacts.selected_recipe={version:1,slot:recipe.slot,name:recipe.name,settings:window.RecipeLibrary.settings(recipe.settings)};
726
  }
727
- let note='';
728
  const receiptSource=runReceipt||portableReceipt;
729
  if($('bundle_receipt').checked&&receiptSource){
730
  const candidate=cleanRunReceipt(receiptSource);
731
- const matches=candidate&&((kind==='result'&&candidate.output_sha256===network.sha256)||(kind==='input'&&candidate.report.inputs.network_sha256===network.sha256));
732
- if(matches&&(!trackInfo||trackInfo.source!=='builtin'&&trackInfo.source!=='upload'||!selectedTrack||candidate.report.inputs.track_sha256===selectedTrack.sha256))artifacts.run_receipt=candidate;
733
  else note=' The latest run receipt did not match this selected network/track and was omitted.';
734
  }
 
 
 
 
735
  const text=await api.pack(artifacts);
736
  return {text,note};
737
  }
738
  async function buildRunResultBundle(resultBlob,sourceReceipt,context){
 
739
  if(!(resultBlob instanceof Blob)||!sourceReceipt||!context?.trackBytes)throw Error('The completed run data is not available for a verified bundle');
740
  const networkBytes=new Uint8Array(await resultBlob.arrayBuffer()),trackBytes=context.trackBytes;
741
  await validateNetworkBytes(networkBytes);
@@ -748,7 +1213,14 @@ async function buildRunResultBundle(resultBlob,sourceReceipt,context){
748
  if(!receipt?.report?.history?.length)throw Error('The completed run has no validated generation history to save');
749
  if(receipt.output_sha256!==network.sha256||receipt.report.inputs.network_sha256!==context.inputNetworkSha256||receipt.report.inputs.track_sha256!==track.sha256)throw Error('The saved run log does not match the verified network and track bytes');
750
  validatePortableReceipt(receipt,network,track);
751
- const text=await api.pack({selected_network:network,selected_track:track,run_receipt:receipt});
 
 
 
 
 
 
 
752
  return {text,receipt,network,track};
753
  }
754
  async function saveVerifiedRunToBucket(snapshot,context){
@@ -762,12 +1234,13 @@ async function saveVerifiedRunToBucket(snapshot,context){
762
  }
763
  async function receiveAndAutosave(currentJob,snapshot,context){
764
  const received=await receive(currentJob);
765
- localResult=new Blob([received.bytes]);context.localVerified=true;buttons();
766
- const acknowledgment=await acknowledge(currentJob,received.receipt);
767
  if(acknowledgment.deleted!==true)throw Error('Candidate was verified, but server deletion was not confirmed; the bucket save was skipped. Download or delete the server result manually.');
768
  context.serverDeleted=true;
769
  if(runReceipt)runReceipt.delivery={received_and_hash_verified:true,server_copy_deleted:true};
770
  if(job===currentJob)job=null;buttons();
 
771
  const outcome=await saveVerifiedRunToBucket(snapshot,context);context.bucketSaveConfirmed=true;return outcome;
772
  }
773
  async function exportPortableBundle(){
@@ -779,9 +1252,18 @@ if($('bundle_export'))$('bundle_export').onclick=async()=>{
779
  try{await exportPortableBundle();}catch(error){bundleMessage('Bundle not exported: '+error.message);}
780
  };
781
  async function preparePortableText(text){
782
- if(new TextEncoder().encode(text).byteLength>window.PortableBundle.LIMITS.bundle)throw Error('Portable bundle exceeds 8 MiB');
783
  const parsed=await window.PortableBundle.parse(text);
784
  await validateNetworkBytes(parsed.network.bytes);
 
 
 
 
 
 
 
 
 
785
  if(parsed.track){
786
  if(parsed.track.metadata.source==='builtin'){
787
  const entry=trackLibrary?.entries().find(e=>e.id===parsed.track.metadata.track_id);
@@ -790,7 +1272,14 @@ async function preparePortableText(text){
790
  parsed.track.file=await validateBundleTrack(parsed.track);
791
  }
792
  if(parsed.recipe){bundleExact(parsed.recipe.settings,Object.keys(window.RecipeLibrary.defaults));parsed.recipe.settings=window.RecipeLibrary.settings(parsed.recipe.settings);}
793
- if(parsed.receipt)parsed.receipt=validatePortableReceipt(parsed.receipt,parsed.network,parsed.track?.metadata);
 
 
 
 
 
 
 
794
  let trackTransfer=null;
795
  if(parsed.track?.metadata.source==='upload'){
796
  if(typeof DataTransfer!=='function')throw Error('This browser cannot stage an uploaded track; no bundle data was applied');
@@ -812,6 +1301,7 @@ async function applyPreparedPortable(prepared,{isCurrent=null}={}){
812
  window.TrainingClient.useNetwork(networkFile);
813
  if(parsed.recipe)window.stagePortableRecipe(parsed.recipe);
814
  portableReceipt=parsed.receipt||null;
 
815
  $('bundle_receipt_download').disabled=!portableReceipt;
816
  bundleMessage('Bundle restored in this page only. Network and optional track/recipe are staged; no run started and no browser storage or server was changed.'+(portableReceipt?' Sanitized receipt is available for separate download.':''));
817
  buttons();
@@ -827,7 +1317,7 @@ if($('bundle_import'))$('bundle_import').onchange=async()=>{
827
  try{
828
  if(bundleRestoring||busy||job)throw Error('Finish the active run before restoring a bundle');
829
  restoreId=++bundleRestoreId;bundleRestoring=true;buttons();
830
- if(file.size>window.PortableBundle.LIMITS.bundle)throw Error('Portable bundle exceeds 8 MiB');
831
  await restorePortableText(await file.text(),{lockHeld:true});
832
  }catch(error){bundleMessage('Bundle not restored: '+error.message+'. The existing selections were left unchanged.');}
833
  finally{if(restoreId===bundleRestoreId)bundleRestoring=false;buttons();input.value='';}
 
3
  let job = null;
4
  let busy = false;
5
  let localResult = null;
6
+ let localAnalysis = null;
7
+ let pairedAnalysisPending = false;
8
+ let selectedWinner = null;
9
+ let comparisonRows = [];
10
+ let comparisonPlan = null;
11
  let capabilitiesReady = false;
12
  let gpuTransport = null;
13
  let gpuAvailable = false;
 
28
  let bucketUsername = null;
29
  let bucketItems = [];
30
  let bucketSelectedKey = null;
31
+ let bucketRecipeItems = [];
32
+ let bucketRecipeSelectedKey = null;
33
  let bucketOperation = false;
34
  let bucketOperationId = 0;
35
  let bucketEpoch = 0;
 
57
  $('download').disabled = busy || !job;
58
  $('erase').disabled = busy || !job;
59
  const autosaving=!!bucketToken&&!!$('bucket_autosave')?.checked;
60
+ $('keepServer').disabled = busy || !!job || autosaving || !!comparisonPlan;
61
  $('saveLocal').disabled = busy || !localResult;
62
  $('forgetLocal').disabled = busy || !localResult;
63
  $('track').disabled=unavailable;
64
  $('network').disabled=unavailable;
65
+ if($('batch_start'))$('batch_start').disabled=unavailable || !capabilitiesReady || $('run_mode')?.value==='evaluate' || ($('backend').value==='gpu'&&!gpuAvailable);
66
  if($('batch_stop'))$('batch_stop').disabled=!batchActive || batchStopRequested;
67
+ if($('run_export'))$('run_export').disabled=busy || !runReceipt || pairedAnalysisPending;
68
+ const report=pairedAnalysisPending?null:(cleanRunReceipt(runReceipt)||cleanRunReceipt(portableReceipt));
69
  if($('player_report_download'))$('player_report_download').disabled=busy||!report;
70
  if($('history_csv_download'))$('history_csv_download').disabled=busy||!report?.report?.history?.length;
71
  if($('batch_export'))$('batch_export').disabled=busy || !batchReceipt;
72
+ if($('run_mode'))$('run_mode').disabled=unavailable;
73
+ if($('clean_min_laps'))$('clean_min_laps').disabled=unavailable;
74
+ if($('analysis_download'))$('analysis_download').disabled=busy||!localAnalysis;
75
+ if($('descriptor_show'))$('descriptor_show').disabled=busy||!localAnalysis||localAnalysis.mode!=='train';
76
+ if($('compare_winners'))$('compare_winners').disabled=busy||!localAnalysis||!selectedWinnerRoles().length;
77
+ if($('compare_add'))$('compare_add').disabled=busy||!localAnalysis||localAnalysis.mode!=='evaluate';
78
+ if($('bundle_export'))$('bundle_export').disabled=unavailable||pairedAnalysisPending;
79
  if($('bundle_import'))$('bundle_import').disabled=unavailable;
80
  const storageToken=window.HFAuth?.getStorageToken?.()??null;
81
  if($('bucket_connect'))$('bucket_connect').disabled=unavailable||!storageToken||!!bucketToken;
82
  if($('bucket_id'))$('bucket_id').disabled=unavailable||!storageToken||!!bucketToken;
83
  if($('bucket_disconnect'))$('bucket_disconnect').disabled=unavailable||!bucketToken;
84
+ if($('bucket_save'))$('bucket_save').disabled=unavailable||!bucketToken||(!networkSource()&&!localResult)||pairedAnalysisPending;
85
  if($('bucket_refresh'))$('bucket_refresh').disabled=unavailable||!bucketToken;
86
  if($('bucket_restore'))$('bucket_restore').disabled=unavailable||!bucketToken||!$('bucket_items')?.value;
87
  if($('bucket_items'))$('bucket_items').disabled=unavailable||!bucketToken;
88
+ if($('bucket_recipe_save'))$('bucket_recipe_save').disabled=unavailable||!bucketToken;
89
+ if($('bucket_recipe_refresh'))$('bucket_recipe_refresh').disabled=unavailable||!bucketToken;
90
+ if($('bucket_recipe_items'))$('bucket_recipe_items').disabled=unavailable||!bucketToken;
91
+ if($('bucket_recipe_import'))$('bucket_recipe_import').disabled=unavailable||!bucketToken||!$('bucket_recipe_items')?.value;
92
  if($('bucket_autosave'))$('bucket_autosave').disabled=unavailable||!bucketToken;
93
+ syncRunModeUI(unavailable);
94
  }
95
+ const trainingOnlyControls=['population','generations','mutation','mutation_end','schedule','adaptive_mutation','selection','parents','elite','crossover','decay','preset','reward_preset','add','rewards'];
96
+ function syncRunModeUI(unavailable=busy||!!job||bundleRestoring||bucketOperation){
97
+ const evaluation=$('run_mode')?.value==='evaluate';
98
+ for(const id of trainingOnlyControls){const node=$(id);if(node)node.disabled=unavailable||evaluation;}
99
+ if($('native_settings'))$('native_settings').hidden=evaluation;
100
+ if($('reward_settings'))$('reward_settings').hidden=evaluation;
101
+ if($('run'))$('run').textContent=evaluation?'Evaluate policy (weights frozen)':'Run training';
102
+ if($('run_mode_help'))$('run_mode_help').textContent=evaluation
103
+ ?'Frozen evaluation uses the selected policy unchanged in a fresh car. Mutation, decay, crossover, reproduction and winner selection are disabled.'
104
+ :'Training uses your selected recipe and records winner policies and per-car measurements.';
105
+ }
106
+ if($('run_mode'))$('run_mode').addEventListener('change',()=>{syncRunModeUI();buttons();});
107
  function renderBucketConnection(){
108
  const connected=!!bucketToken&&!!bucketId;
109
  const authorized=!!window.HFAuth?.getStorageToken?.();
 
119
  select.replaceChildren(...options);
120
  if(options.length)select.value=bucketSelectedKey&&bucketItems.some(item=>item.key===bucketSelectedKey)?bucketSelectedKey:'';
121
  }
122
+ const recipeSelect=$('bucket_recipe_items');
123
+ if(recipeSelect){
124
+ const options=[];
125
+ if(!bucketRecipeItems.length){const option=document.createElement('option');option.value='';option.textContent='Refresh to list saved recipe libraries';options.push(option);}
126
+ for(const item of bucketRecipeItems){const option=document.createElement('option');option.value=item.key;option.textContent=item.label+' · '+item.bytes+' bytes';options.push(option);}
127
+ recipeSelect.replaceChildren(...options);
128
+ if(options.length)recipeSelect.value=bucketRecipeSelectedKey&&bucketRecipeItems.some(item=>item.key===bucketRecipeSelectedKey)?bucketRecipeSelectedKey:'';
129
+ }
130
  buttons();
131
  }
132
  const bucketKeyValid=key=>typeof key==='string'&&(
133
  /^ald-exports\/(?:[0-9a-f]{32}|[0-9a-f]{64})\/bundle\.json$/.test(key)||
134
  /^ald-exports\/\d{8}T\d{6}Z-[a-z0-9-]{1,64}-(?:formula|rally|snowmobile|truck)-g\d{1,4}-lap(?:\d{1,16}ms|none)-[0-9a-f]{32}\/bundle\.json$/.test(key));
135
+ const bucketRecipeKeyValid=key=>typeof key==='string'&&/^ald-recipes\/\d{8}T\d{6}Z-[0-9a-f]{32}\/library\.json$/.test(key);
136
  function bucketSnapshot(){return {token:bucketToken,id:bucketId,epoch:bucketEpoch};}
137
  function bucketSnapshotCurrent(snapshot){return !!snapshot?.token&&snapshot.epoch===bucketEpoch&&snapshot.token===bucketToken&&snapshot.id===bucketId;}
138
  function clearBucketConnection(snapshot=null){
 
140
  bucketEpoch++;
141
  bucketOperationId++;bucketOperation=false;
142
  bundleRestoreId++;bundleRestoring=false;
143
+ bucketToken=null;bucketId=null;bucketUsername=null;bucketItems=[];bucketSelectedKey=null;bucketRecipeItems=[];bucketRecipeSelectedKey=null;
144
  if($('bucket_id'))$('bucket_id').value='';
145
  if($('bucket_status'))$('bucket_status').textContent='Bucket connection cleared. Reconnect to continue.';
146
+ if($('bucket_recipe_status'))$('bucket_recipe_status').textContent='Bucket connection cleared. Reconnect to continue.';
147
  renderBucketConnection();
148
  return true;
149
  }
150
  async function bucketRequest(endpoint,body,snapshot){
151
+ if(!['connect','list','save','restore','recipes/save','recipes/list','recipes/restore'].includes(endpoint))throw Error('Unsupported bucket action');
152
+ if(endpoint!=='connect'&&!bucketSnapshotCurrent(snapshot))throw Error('Bucket connection changed; no request was sent.');
153
  const current=()=>endpoint==='connect'
154
  ? snapshot.epoch===bucketEpoch&&bucketToken===null&&bucketId===null
155
  : bucketSnapshotCurrent(snapshot);
 
163
  function endBucketOperation(id){if(id!==bucketOperationId)return;bucketOperation=false;buttons();}
164
  async function saveBucketBundleText(text,snapshot){
165
  if(!bucketSnapshotCurrent(snapshot))throw Error('Bucket connection changed; no save was attempted.');
166
+ const bytes=new TextEncoder().encode(text);if(bytes.byteLength>window.PortableBundle.LIMITS.bundle)throw Error('Portable bundle exceeds 48 MiB');
167
  const sha256=await window.PortableBundle.sha(bytes);
168
  const response=await bucketRequest('save',{bucket_id:snapshot.id,bundle:text},snapshot),result=await response.json();
169
  if(!bucketSnapshotCurrent(snapshot))throw Error('Bucket connection changed; the old save response was ignored.');
 
250
  catch(error){if(bucketSnapshotCurrent(snapshot))$('bucket_status').textContent=error.message||'Could not refresh saved bundles.';}
251
  finally{endBucketOperation(operationId);}
252
  };
253
+ function bucketRecipeTime(item){
254
+ if(typeof item.created_at==='string'){
255
+ const m=item.created_at.match(/^(\d{4})-(\d\d)-(\d\d)T(\d\d):(\d\d):(\d\d)(?:\.(\d{1,6}))?Z$/);
256
+ if(m){const p=m.slice(1,7).map(Number),t=Date.UTC(p[0],p[1]-1,p[2],p[3],p[4],p[5]),d=new Date(t);
257
+ if(d.getUTCFullYear()===p[0]&&d.getUTCMonth()===p[1]-1&&d.getUTCDate()===p[2]&&d.getUTCHours()===p[3]&&d.getUTCMinutes()===p[4]&&d.getUTCSeconds()===p[5])return t+Number((m[7]||'').padEnd(6,'0')||0)/1000;}
258
+ }
259
+ const m=item.key.match(/^ald-recipes\/(\d{4})(\d\d)(\d\d)T(\d\d)(\d\d)(\d\d)Z-/);if(!m)return null;
260
+ const p=m.slice(1).map(Number),t=Date.UTC(p[0],p[1]-1,p[2],p[3],p[4],p[5]),d=new Date(t);
261
+ return d.getUTCFullYear()===p[0]&&d.getUTCMonth()===p[1]-1&&d.getUTCDate()===p[2]&&d.getUTCHours()===p[3]&&d.getUTCMinutes()===p[4]&&d.getUTCSeconds()===p[5]?t:null;
262
+ }
263
+ async function refreshBucketRecipes(snapshot,{selectKey=null}={}){
264
+ const response=await bucketRequest('recipes/list',{bucket_id:snapshot.id},snapshot),result=await response.json();
265
+ if(!bucketSnapshotCurrent(snapshot))return;
266
+ if(!Array.isArray(result?.items)||result.items.length>50||typeof result.truncated!=='boolean'||typeof result.scan_incomplete!=='boolean')throw Error('The saved recipe library list was not valid.');
267
+ const checked=result.items.map(item=>{
268
+ if(!bucketRecipeKeyValid(item?.key)||!Number.isSafeInteger(item.bytes)||item.bytes<2||item.bytes>32768||typeof item.label!=='string'||!item.label.trim()||item.label.length>160||!(item.created_at===undefined||item.created_at===null||(typeof item.created_at==='string'&&bucketRecipeTime(item)!==null)))throw Error('The saved recipe library list was not valid.');
269
+ return {key:item.key,bytes:item.bytes,label:item.label,created_at:item.created_at??null};
270
+ }).sort((a,b)=>(bucketRecipeTime(b)??-1)-(bucketRecipeTime(a)??-1));
271
+ bucketRecipeItems=checked;
272
+ bucketRecipeSelectedKey=selectKey&&checked.some(item=>item.key===selectKey)?selectKey:(checked[0]?.key??null);
273
+ renderBucketConnection();
274
+ const caveat=result.scan_incomplete?' The bounded scan was incomplete, so a newer saved library may exist.':result.truncated?' Showing up to 50 newest listed libraries; additional libraries may exist.':'';
275
+ if($('bucket_recipe_status'))$('bucket_recipe_status').textContent=selectKey&&!bucketRecipeSelectedKey
276
+ ?'Recipe library was saved and read back but is not in the returned list. Use Refresh saved recipe libraries.'+caveat
277
+ :checked.length
278
+ ?(selectKey?'Recipe library saved, read back and selected.':'Saved recipe libraries refreshed; the newest listed library is selected.')+caveat
279
+ :'No saved recipe libraries were found.'+(result.scan_incomplete?' The bounded scan was incomplete; additional libraries may exist.':'');
280
+ }
281
+ if($('bucket_recipe_refresh'))$('bucket_recipe_refresh').onclick=async()=>{
282
+ if(!bucketToken)return;
283
+ const snapshot=bucketSnapshot(),operationId=beginBucketOperation();$('bucket_recipe_status').textContent='Refreshing saved recipe libraries…';
284
+ try{await refreshBucketRecipes(snapshot);}
285
+ catch(error){if(bucketSnapshotCurrent(snapshot))$('bucket_recipe_status').textContent=error.message||'Could not refresh saved recipe libraries.';}
286
+ finally{endBucketOperation(operationId);}
287
+ };
288
+ if($('bucket_recipe_items'))$('bucket_recipe_items').addEventListener('change',()=>{bucketRecipeSelectedKey=$('bucket_recipe_items').value||null;buttons();});
289
+ if($('bucket_recipe_save'))$('bucket_recipe_save').onclick=async()=>{
290
+ if(!bucketToken)return;
291
+ const snapshot=bucketSnapshot(),operationId=beginBucketOperation();let readbackVerified=false;$('bucket_recipe_status').textContent='Saving and verifying the validated recipe library…';
292
+ try{
293
+ const text=window.recipeLibraryText?.();if(typeof text!=='string')throw Error('The local recipe library is unavailable.');
294
+ window.RecipeLibrary?.parse(text);
295
+ const bytes=new TextEncoder().encode(text);if(bytes.byteLength>32768)throw Error('Recipe library exceeds 32 KB');
296
+ const sha256=await window.PortableBundle.sha(bytes);
297
+ const response=await bucketRequest('recipes/save',{bucket_id:snapshot.id,library:text},snapshot),result=await response.json();
298
+ if(!bucketSnapshotCurrent(snapshot))return;
299
+ if(result?.status!=='saved_verified'||result.bucket_id!==snapshot.id||!bucketRecipeKeyValid(result.key)||result.bytes!==bytes.byteLength||result.sha256!==sha256)throw Error('Recipe library save could not be verified; the remote save may have completed.');
300
+ const readback=await bucketRequest('recipes/restore',{bucket_id:snapshot.id,key:result.key},snapshot),restoredText=await readback.text();
301
+ if(!bucketSnapshotCurrent(snapshot))return;
302
+ const restoredBytes=new TextEncoder().encode(restoredText);
303
+ if(restoredBytes.byteLength!==bytes.byteLength||await window.PortableBundle.sha(restoredBytes)!==sha256||restoredText!==text)throw Error('Recipe library readback did not match the saved bytes.');
304
+ window.RecipeLibrary.parse(restoredText);
305
+ readbackVerified=true;
306
+ try{await refreshBucketRecipes(snapshot,{selectKey:result.key});}
307
+ catch{$('bucket_recipe_status').textContent='Recipe library was saved and read back successfully, but the saved list could not refresh. Use Refresh saved recipe libraries.';}
308
+ }catch(error){if(bucketSnapshotCurrent(snapshot))$('bucket_recipe_status').textContent=readbackVerified?'Recipe library was saved and read back successfully. The saved list could not refresh; use Refresh saved recipe libraries.':(error.message||'Recipe library save failed; the local library was unchanged.');}
309
+ finally{endBucketOperation(operationId);}
310
+ };
311
+ if($('bucket_recipe_import'))$('bucket_recipe_import').onclick=async()=>{
312
+ if(!bucketToken)return;
313
+ const snapshot=bucketSnapshot(),key=$('bucket_recipe_items').value;
314
+ if(!bucketRecipeKeyValid(key)||!bucketRecipeItems.some(item=>item.key===key)){$('bucket_recipe_status').textContent='Choose a listed recipe library to import.';return;}
315
+ const operationId=beginBucketOperation();$('bucket_recipe_status').textContent='Downloading and validating the selected recipe library…';
316
+ try{
317
+ const response=await bucketRequest('recipes/restore',{bucket_id:snapshot.id,key},snapshot),text=await response.text();
318
+ if(!bucketSnapshotCurrent(snapshot))return;
319
+ if(new TextEncoder().encode(text).byteLength>32768)throw Error('Recipe library exceeds 32 KB');
320
+ const book=window.RecipeLibrary?.parse(text);if(!book)throw Error('Recipe library validation is unavailable.');
321
+ if(typeof window.importRecipeLibraryText!=='function')throw Error('Recipe library import is unavailable.');
322
+ const imported=await window.importRecipeLibraryText(text,{source:'private bucket',isCurrent:()=>bucketSnapshotCurrent(snapshot)});
323
+ if(!bucketSnapshotCurrent(snapshot))return;
324
+ $('bucket_recipe_status').textContent=imported?'Recipe library imported locally after confirmation. No run was started.':'Import canceled. The local recipe library was unchanged.';
325
+ }catch(error){if(bucketSnapshotCurrent(snapshot))$('bucket_recipe_status').textContent='Recipe library not imported: '+(error.message||'validation failed')+'. The existing local library was left unchanged.';}
326
+ finally{endBucketOperation(operationId);}
327
+ };
328
  if($('bucket_items'))$('bucket_items').addEventListener('change',()=>{bucketSelectedKey=$('bucket_items').value||null;buttons();});
329
  if($('bucket_autosave'))$('bucket_autosave').addEventListener('change',()=>{
330
  if($('bucket_autosave').checked&&$('keepServer').checked){$('keepServer').checked=false;$('bucket_status').textContent='Automatic bucket saving requires deletion after a verified download; the repeat-download option was turned off.';}
 
351
  const response=await bucketRequest('restore',{bucket_id:snapshot.id,key},snapshot);
352
  const text=await response.text();
353
  if(!bucketSnapshotCurrent(snapshot))return;
354
+ if(new TextEncoder().encode(text).byteLength>window.PortableBundle.LIMITS.bundle)throw Error('Portable bundle exceeds 48 MiB');
355
  await restorePortableText(text,{lockHeld:true,isCurrent:()=>bucketSnapshotCurrent(snapshot)});
356
  if(bucketSnapshotCurrent(snapshot))$('bucket_status').textContent='Bundle restored in this page only. No run started and the existing result remains available.';
357
  }catch(error){if(bucketSnapshotCurrent(snapshot))$('bucket_status').textContent='Bundle not restored: '+(error.message||'validation failed')+'. Existing selections and results were left unchanged.';}
 
379
  setTimeout(()=>URL.revokeObjectURL(url),60000);
380
  }
381
  $('saveLocal').onclick=saveLocal;
382
+ $('forgetLocal').onclick=()=>{localResult=null;localAnalysis=null;pairedAnalysisPending=false;buttons();say('Browser result discarded. Downloaded files are unaffected.');};
383
  async function request(path, options = {}) {
384
  const response = await fetch(path, {...options, cache:'no-store', credentials:'same-origin'});
385
  if (!response.ok) {
 
396
  }
397
  const headers = () => ({'X-Job-Key':job.key});
398
  const networkSource=()=>stagedNetwork || $('network').files[0];
399
+ $('network').onchange=()=>{stagedNetwork=null;selectedWinner=null;if($('networkChoice'))$('networkChoice').textContent='Uploaded file selected.';buttons();};
400
  window.TrainingClient={
401
  isBusy:()=>busy||!!job||bundleRestoring,
402
  hasUnsavedState:()=>!!(networkSource()||localResult||$('track').files.length||trackLibrary?.info?.()?.source==='builtin'||$('editor_file')?.files.length||window.hasUnsavedNetworkDraft?.()),
 
488
  const bytes=await response.arrayBuffer();
489
  const digest=await sha(bytes);
490
  if(bytes.byteLength!==current.size || digest!==current.sha256)throw Error('Result validation failed; no deletion acknowledgment sent. Use Delete now or await expiry.');
491
+ const receipt=response.headers.get('x-delivery-receipt');if(typeof receipt!=='string'||!receipt)throw Error('Primary delivery receipt is missing; no deletion acknowledgment sent.');
492
+ if(current.analysis===undefined||current.analysis===null)return {bytes,receipt,analysisBytes:null,analysisReceipt:null,analysis:null};
493
+ const verifiedPrimary={bytes,receipt,digest};
494
+ try{
495
+ const descriptor=current.analysis;
496
+ if(!descriptor||!Number.isInteger(descriptor.size)||descriptor.size<1||descriptor.size>ANALYSIS_ARCHIVE_MAX||!hex64(descriptor.sha256))throw Error('Analysis archive metadata is invalid; no deletion acknowledgment sent.');
497
+ const analysisResponse=await request('/api/jobs/'+current.id+'/analysis/download',{method:'POST',headers:{'X-Job-Key':current.key}}),analysisBytes=await analysisResponse.arrayBuffer();
498
+ if(analysisBytes.byteLength!==descriptor.size||await sha(analysisBytes)!==descriptor.sha256)throw Error('Analysis archive validation failed; no deletion acknowledgment sent.');
499
+ const analysisReceipt=analysisResponse.headers.get('x-analysis-delivery-receipt');if(typeof analysisReceipt!=='string'||!analysisReceipt)throw Error('Analysis delivery receipt is missing; no deletion acknowledgment sent.');
500
+ const parsed=await parseRunAnalysisArchive(new Uint8Array(analysisBytes),descriptor,current.report?.analysis_summary);
501
+ try{validatePrimaryAnalysisMatch(digest,parsed);}catch(error){throw Error(error.message+'; no deletion acknowledgment sent.');}
502
+ if(comparisonPlan){const expected=comparisonPlan.conditions,actual=parsed.summary.conditions;
503
+ for(const key of ['track_sha256','vehicle_type','requested_ticks','control_hz','collision','idle','math_profile','clean_min_laps'])if(actual[key]!==expected[key])throw Error('Frozen comparison conditions changed at '+key+'; this result was not added to the comparison');
504
+ if(parsed.mode!=='evaluate'||parsed.summary.execution_backend!==(comparisonPlan.backend==='gpu'?'cuda':'cpu'))throw Error('Frozen comparison result does not match the requested mode or backend');
505
+ const buildId=current.report?.build?.build_id??current.report?.build_id;
506
+ if(comparisonPlan.build_id&&buildId!==comparisonPlan.build_id)throw Error('Frozen comparison engine build changed; this result was not added');
507
+ const role=comparisonPlan.roles[comparisonPlan.index],expectedNetwork=comparisonPlan.winnerNetworks?.[role]?.sha256;
508
+ if(expectedNetwork&&parsed.summary.conditions.network_sha256!==expectedNetwork)throw Error('Frozen evaluation used a different policy than the selected comparison candidate');
509
+ }
510
+ return {bytes,receipt,analysisBytes,analysisReceipt,analysis:parsed};
511
+ }catch(error){if(error&&typeof error==='object')error.verifiedPrimary=verifiedPrimary;throw error;}
512
  }
513
+ function retainVerifiedPrimary(error){
514
+ if(!error?.verifiedPrimary)return false;
515
+ localResult=new Blob([error.verifiedPrimary.bytes]);localAnalysis=null;pairedAnalysisPending=true;
516
+ renderAnalysisPanel();buttons();
517
+ return true;
518
+ }
519
+ async function acknowledge(current,received){
520
+ const body={receipt:received.receipt};if(received.analysisReceipt)body.analysis_receipt=received.analysisReceipt;
521
+ return (await request('/api/jobs/'+current.id+'/ack',{method:'POST',headers:{'X-Job-Key':current.key,'Content-Type':'application/json'},body:JSON.stringify(body)})).json();
522
  }
523
  async function sha(bytes){return Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256',bytes)),x=>x.toString(16).padStart(2,'0')).join('');}
524
+ const ANALYSIS_ARCHIVE_MAX=32*1024*1024,ANALYSIS_EXPANDED_MAX=256*1024*1024,ANALYSIS_ROWS_MAX=128000;
525
+ const hex64=value=>typeof value==='string'&&/^[a-f0-9]{64}$/.test(value);
526
+ function exactAnalysisKeys(value,keys){if(!value||typeof value!=='object'||Array.isArray(value)||Object.keys(value).sort().join()!==[...keys].sort().join())throw Error('Analysis archive contains unexpected fields');}
527
+ function stableJson(value){if(Array.isArray(value))return '['+value.map(stableJson).join(',')+']';if(value&&typeof value==='object')return '{'+Object.keys(value).sort().map(key=>JSON.stringify(key)+':'+stableJson(value[key])).join(',')+'}';return JSON.stringify(value);}
528
+ function finiteOrNull(value,min=-1e12,max=1e12){return value===null||(typeof value==='number'&&Number.isFinite(value)&&value>=min&&value<=max);}
529
+ function b64Bytes(text,limit){
530
+ if(typeof text!=='string'||text.length>Math.ceil(limit/3)*4+8)throw Error('Analysis file exceeds its size limit');
531
+ let binary;try{binary=atob(text);}catch{throw Error('Analysis archive contains invalid base64');}
532
+ const bytes=Uint8Array.from(binary,c=>c.charCodeAt(0));
533
+ if(bytes.byteLength>limit||btoa(binary)!==text)throw Error('Analysis archive contains noncanonical or oversized bytes');
534
+ return bytes;
535
+ }
536
+ async function expandAnalysisGzip(bytes){
537
+ if(typeof DecompressionStream!=='function')throw Error('This browser cannot expand the verified analysis archive');
538
+ const reader=new Blob([bytes]).stream().pipeThrough(new DecompressionStream('gzip')).getReader(),parts=[];let total=0;
539
+ try{while(true){const {value,done}=await reader.read();if(done)break;total+=value.byteLength;if(total>ANALYSIS_EXPANDED_MAX)throw Error('Expanded analysis exceeds 256 MiB');parts.push(value);}}
540
+ finally{reader.releaseLock();}
541
+ const out=new Uint8Array(total);let offset=0;for(const part of parts){out.set(part,offset);offset+=part.byteLength;}return out;
542
+ }
543
+ function validateAnalysisSummary(summary){
544
+ exactAnalysisKeys(summary,['schema','version','mode','execution_backend','conditions','observations','winners','semantics']);
545
+ if(summary.schema!=='ald.run-analysis'||summary.version!==1||!['train','evaluate'].includes(summary.mode)||!['cpu','cuda'].includes(summary.execution_backend))throw Error('Analysis summary identity is invalid');
546
+ const c=summary.conditions;exactAnalysisKeys(c,['track_sha256','network_sha256','vehicle_type','requested_ticks','control_hz','collision','idle','math_profile','clean_min_laps']);
547
+ if(!hex64(c.track_sha256)||!hex64(c.network_sha256)||!Number.isInteger(c.vehicle_type)||c.vehicle_type<0||c.vehicle_type>3||!Number.isInteger(c.requested_ticks)||c.requested_ticks<1||c.requested_ticks>18000||![15,30,60].includes(c.control_hz)||typeof c.collision!=='boolean'||typeof c.idle!=='boolean'||c.math_profile!=='proton'||!Number.isInteger(c.clean_min_laps)||c.clean_min_laps<1||c.clean_min_laps>100)throw Error('Analysis conditions are invalid');
548
+ const o=summary.observations;exactAnalysisKeys(o,['generations','cars','raw_metric_count','reward_channel_count']);
549
+ if(!Number.isInteger(o.generations)||o.generations<1||o.generations>1000||!Number.isInteger(o.cars)||o.cars<1||o.cars>ANALYSIS_ROWS_MAX||o.raw_metric_count!==14||o.reward_channel_count!==18)throw Error('Analysis observation counts are invalid');
550
+ exactAnalysisKeys(summary.winners,['fitness','fastest_lap','clean_laps']);
551
+ for(const role of ['fitness','fastest_lap','clean_laps']){
552
+ const w=summary.winners[role];exactAnalysisKeys(w,['status','reason','candidate_id','generation','car','selection_fitness','best_lap_seconds','forward_laps','physical_contacts','collision_updates','total_actual_distance','artifact']);
553
+ if(!['available','unavailable'].includes(w.status))throw Error('Winner status is invalid');
554
+ if(w.status==='unavailable'){
555
+ if(typeof w.reason!=='string'||!w.reason||w.reason.length>300||w.candidate_id!==null||w.artifact!==null)throw Error('Unavailable winner details are invalid');
556
+ }else{
557
+ if(w.reason!==null||typeof w.candidate_id!=='string'||!/^g\d{6}-c\d{6}$/.test(w.candidate_id)||!Number.isInteger(w.generation)||w.generation<1||w.generation>o.generations||!Number.isInteger(w.car)||w.car<0||w.car>=128||!finiteOrNull(w.selection_fitness)||!finiteOrNull(w.best_lap_seconds,Number.MIN_VALUE,1e12)||!Number.isInteger(w.forward_laps)||w.forward_laps<0||!Number.isInteger(w.physical_contacts)||w.physical_contacts<0||!Number.isInteger(w.collision_updates)||w.collision_updates<0||!finiteOrNull(w.total_actual_distance,0,1e12))throw Error('Available winner details are invalid');
558
+ exactAnalysisKeys(w.artifact,['name','bytes','sha256']);if(w.artifact.name!=='winners/'+role+'.evolution'||!Number.isInteger(w.artifact.bytes)||w.artifact.bytes<1||w.artifact.bytes>4*1024*1024||!hex64(w.artifact.sha256))throw Error('Winner artifact reference is invalid');
559
+ }
560
+ }
561
+ if(typeof summary.semantics!=='object'||Array.isArray(summary.semantics))throw Error('Analysis semantics are invalid');
562
+ exactAnalysisKeys(summary.semantics,['fitness','fastest_lap','clean_laps','channels','collision_count','training','evaluation']);
563
+ if(Object.values(summary.semantics).some(x=>typeof x!=='string'||x.length>1000))throw Error('Analysis semantics are invalid');
564
+ if(summary.mode==='evaluate'&&Object.values(summary.winners).some(w=>w.status!=='unavailable'||w.reason!=='frozen_evaluation_does_not_select_winners'))throw Error('Frozen evaluation must not select winners');
565
+ return summary;
566
+ }
567
+ function validateDescriptorRow(row,metricNames,rewardChannels){
568
+ exactAnalysisKeys(row,['schema','generation','car','candidate_id','ticks_elapsed','stats_update_count','raw_metrics','channels','selection_fitness','objectives','weights_sha256']);
569
+ if(row.schema!==1||!Number.isInteger(row.generation)||row.generation<1||row.generation>1000||!Number.isInteger(row.car)||row.car<0||row.car>=128||row.candidate_id!=='g'+String(row.generation).padStart(6,'0')+'-c'+String(row.car).padStart(6,'0')||!Number.isInteger(row.ticks_elapsed)||row.ticks_elapsed<0||row.ticks_elapsed>18012||!Number.isInteger(row.stats_update_count)||row.stats_update_count<0||row.stats_update_count>18012||!finiteOrNull(row.selection_fitness)||!hex64(row.weights_sha256))throw Error('Analysis descriptor identity is invalid');
570
+ exactAnalysisKeys(row.raw_metrics,metricNames);if(Object.values(row.raw_metrics).some(value=>!finiteOrNull(value)))throw Error('Analysis raw metric is invalid');
571
+ exactAnalysisKeys(row.channels,['values','population_relative','unavailable_reasons']);
572
+ if(!Array.isArray(row.channels.values)||row.channels.values.length!==18||!Array.isArray(row.channels.population_relative)||row.channels.population_relative.length!==18||!Array.isArray(row.channels.unavailable_reasons)||row.channels.unavailable_reasons.length!==18)throw Error('Analysis reward channels are incomplete');
573
+ row.channels.values.forEach((value,index)=>{const rank=row.channels.population_relative[index],reason=row.channels.unavailable_reasons[index];if(!finiteOrNull(value)||!finiteOrNull(rank,0,1)||!(reason===null||['no_completed_lap','no_statistics_updates','not_observed'].includes(reason))||(value===null&&(reason===null||rank!==null))||(value!==null&&reason!==null))throw Error('Analysis reward descriptor is invalid');});
574
+ const obj=row.objectives;exactAnalysisKeys(obj,['best_lap_seconds','forward_laps','physical_contacts','collision_updates','total_actual_distance','active']);
575
+ if(!finiteOrNull(obj.best_lap_seconds,Number.MIN_VALUE,1e12)||!Number.isInteger(obj.forward_laps)||obj.forward_laps<0||!Number.isInteger(obj.physical_contacts)||obj.physical_contacts<0||!Number.isInteger(obj.collision_updates)||obj.collision_updates<0||!finiteOrNull(obj.total_actual_distance,0,1e12)||typeof obj.active!=='boolean')throw Error('Analysis objective descriptor is invalid');
576
+ return row;
577
+ }
578
+ function selectedWinnerRoles(){return [...document.querySelectorAll('[data-compare-role]:checked')].map(node=>node.dataset.compareRole).filter(role=>localAnalysis?.winnerNetworks?.[role]);}
579
+ function uniqueWinnerRoles(roles,winnerNetworks){const seen=new Set();return roles.filter(role=>{const item=winnerNetworks?.[role];if(!item)return false;const identity=item.candidate_id||item.sha256;if(seen.has(identity))return false;seen.add(identity);return true;});}
580
+ function conditionIdentity(summary,backend,buildId){const c=summary.conditions;return JSON.stringify({track_sha256:c.track_sha256,vehicle_type:c.vehicle_type,requested_ticks:c.requested_ticks,control_hz:c.control_hz,collision:c.collision,idle:c.idle,math_profile:c.math_profile,clean_min_laps:c.clean_min_laps,backend,build_id:buildId});}
581
+ function validateComparisonRequest(plan,actual){
582
+ const expected=plan?.conditions,role=plan?.roles?.[plan.index],winner=plan?.winnerNetworks?.[role];
583
+ if(!expected||!winner||actual.mode!=='evaluate'||winner.sha256!==actual.network_sha256||
584
+ actual.track_sha256!==expected.track_sha256||actual.vehicle_type!==expected.vehicle_type||
585
+ actual.requested_ticks!==expected.requested_ticks||actual.control_hz!==expected.control_hz||
586
+ actual.collision!==expected.collision||actual.idle!==expected.idle||actual.clean_min_laps!==expected.clean_min_laps||
587
+ actual.backend!==plan.backend||(plan.build_id&&actual.build_id&&actual.build_id!==plan.build_id))
588
+ throw Error('The frozen comparison conditions or selected policy changed. The comparison was stopped before sending this run. Start a new comparison after restoring the matching settings.');
589
+ return true;
590
+ }
591
+ function renderAnalysisPanel(){
592
+ const panel=$('analysisPanel');if(!panel)return;
593
+ panel.hidden=!localAnalysis;
594
+ if(!localAnalysis)return;
595
+ const {summary}=localAnalysis,train=summary.mode==='train';
596
+ $('winnerTools').hidden=!train;$('evaluationTools').hidden=train;
597
+ $('analysisStatus').textContent=train?'Per evaluated car and distinct winner policies from this completed training run. Fitness is only meaningful within its evaluated generation.':'One unchanged policy was measured in a fresh car. No winner was selected.';
598
+ const tbody=$('winnerRows');tbody.replaceChildren();
599
+ const select=$('winner_continue');select.replaceChildren();
600
+ const primary=document.createElement('option');primary.value='';primary.textContent='Primary fitness result';select.append(primary);
601
+ const roles=[['fitness','Final fitness leader'],['fastest_lap','Fastest completed lap'],['clean_laps','Clean laps']];
602
+ for(const [role,label] of roles){const w=summary.winners[role],tr=document.createElement('tr');
603
+ const checkCell=document.createElement('td');if(w.status==='available'){const check=document.createElement('input');check.type='checkbox';check.dataset.compareRole=role;check.setAttribute('aria-label','Compare '+label);check.addEventListener('change',buttons);checkCell.append(check);}else checkCell.textContent='—';
604
+ const candidate=document.createElement('td');candidate.textContent=label;
605
+ const id=document.createElement('td');id.textContent=w.status==='available'?w.candidate_id:'Unavailable: '+w.reason;
606
+ const gen=document.createElement('td');gen.textContent=w.status==='available'?w.generation+' / '+w.car:'—';
607
+ const fitness=document.createElement('td');fitness.textContent=w.status==='available'&&w.selection_fitness!==null?w.selection_fitness.toFixed(5):'Not available';
608
+ const lap=document.createElement('td');lap.textContent=w.status==='available'&&w.best_lap_seconds!==null?w.best_lap_seconds.toFixed(3)+' s':'Not available';
609
+ const laps=document.createElement('td');laps.textContent=w.status==='available'?String(w.forward_laps):'—';
610
+ const contacts=document.createElement('td');contacts.textContent=w.status==='available'?String(w.physical_contacts):'—';
611
+ const distance=document.createElement('td');distance.textContent=w.status==='available'&&w.total_actual_distance!==null?w.total_actual_distance.toFixed(3):'Not reported';
612
+ const action=document.createElement('td');if(w.status==='available'){const option=document.createElement('option');option.value=role;option.textContent=label+' · '+w.candidate_id;select.append(option);const button=document.createElement('button');button.type='button';button.textContent='Use policy';button.onclick=()=>useWinner(role);const download=document.createElement('button');download.type='button';download.textContent='Download weights';download.onclick=()=>downloadBytes(localAnalysis.winnerNetworks[role].bytes,'ald-winner-'+role+'-'+w.candidate_id+'.evolution','application/octet-stream');action.append(button,download);}else action.textContent='—';
613
+ for(const cell of [checkCell,candidate,id,gen,fitness,lap,laps,contacts,distance,action])tr.append(cell);tbody.append(tr);
614
+ }
615
+ if($('evaluationStatus'))$('evaluationStatus').textContent=summary.mode==='evaluate'?'Frozen evaluation complete. Scores are not shown as winner fitness; these physical measurements describe this policy and conditions.':'Choose policies to compare by selecting winner roles.';
616
+ renderEvaluationRows();
617
+ }
618
+ async function useWinner(role){
619
+ const item=localAnalysis?.winnerNetworks?.[role]??comparisonPlan?.winnerNetworks?.[role];if(!item)throw Error('That winner policy is unavailable');
620
+ const file=new File([item.bytes],role+'-'+item.candidate_id+'.evolution',{type:'application/octet-stream'});
621
+ window.TrainingClient.useNetwork(file);selectedWinner=role;
622
+ if($('winner_continue'))$('winner_continue').value=role;
623
+ if($('run_mode'))$('run_mode').value='evaluate';syncRunModeUI();buttons();say('Selected '+role.replaceAll('_',' ')+' policy for frozen evaluation. Choose the same track and conditions for comparisons.');
624
+ }
625
+ function renderEvaluationRows(){
626
+ const tbody=$('evaluationRows');if(!tbody)return;tbody.replaceChildren();
627
+ for(const row of comparisonRows){
628
+ const tr=document.createElement('tr');
629
+ const policy=document.createElement('td');policy.textContent=(row.policy_label||'Selected policy')+' · '+row.network_sha256.slice(0,12);policy.title='Full policy SHA-256: '+row.network_sha256;
630
+ const vehicle=document.createElement('td');vehicle.textContent=row.vehicle;
631
+ const track=document.createElement('td');track.textContent=(row.track_label||'Selected track')+' · '+row.track_sha256.slice(0,12);track.title='Full track SHA-256: '+row.track_sha256;
632
+ for(const cell of [policy,vehicle,track])tr.append(cell);
633
+ for(const value of [String(row.requested_ticks),row.best_lap_seconds===null?'Not reported':row.best_lap_seconds.toFixed(3)+' s',String(row.forward_laps),String(row.physical_contacts),row.total_actual_distance===null?'Not reported':row.total_actual_distance.toFixed(3),row.compatible?'Same conditions':'Conditions differ']){const td=document.createElement('td');td.textContent=value;tr.append(td);}
634
+ tbody.append(tr);
635
+ }
636
+ }
637
+ function launchNextComparison(){
638
+ if(!comparisonPlan||comparisonPlan.index===0||comparisonPlan.index>=comparisonPlan.roles.length)return;
639
+ const expectedPlan=comparisonPlan,expectedIndex=comparisonPlan.index,role=comparisonPlan.roles[expectedIndex];
640
+ setTimeout(async()=>{
641
+ if(comparisonPlan!==expectedPlan||comparisonPlan.index!==expectedIndex||busy||job)return;
642
+ try{
643
+ await useWinner(role);
644
+ if(comparisonPlan===expectedPlan&&comparisonPlan.index===expectedIndex&&!busy&&!job)$('run').click();
645
+ }catch(error){
646
+ if(comparisonPlan===expectedPlan){comparisonPlan=null;$('evaluationStatus').textContent='Comparison stopped before '+role.replaceAll('_',' ')+' could start: '+error.message+' Completed results remain in the table.';buttons();}
647
+ }
648
+ },0);
649
+ }
650
+ function showDescriptor(){
651
+ if(!localAnalysis||localAnalysis.mode!=='train')return;
652
+ const generation=Number($('descriptor_generation').value),car=Number($('descriptor_car').value);
653
+ if(!Number.isInteger(generation)||!Number.isInteger(car)){ $('descriptor_status').textContent='Enter whole generation and car numbers.';return; }
654
+ const row=localAnalysis.descriptorRows.find(item=>item.generation===generation&&item.car===car);
655
+ if(!row){$('descriptor_status').textContent='No descriptor was recorded for that generation and car.';$('descriptor_tables').replaceChildren();return;}
656
+ const wrap=document.createElement('div');
657
+ const summary=document.createElement('p');summary.textContent='Candidate '+row.candidate_id+' · ticks '+row.ticks_elapsed+' · stats updates '+row.stats_update_count+' · weights '+row.weights_sha256;wrap.append(summary);
658
+ const table=(title,entries)=>{const h=document.createElement('h3');h.textContent=title;wrap.append(h);const t=document.createElement('table'),body=document.createElement('tbody');for(const [name,value] of entries){const tr=document.createElement('tr'),a=document.createElement('th'),b=document.createElement('td');a.textContent=name;b.textContent=value===null?'Not observed':typeof value==='number'?String(value):String(value);tr.append(a,b);body.append(tr);}t.append(body);wrap.append(t);};
659
+ table('Physical objectives',Object.entries(row.objectives));table('Raw metrics',Object.entries(row.raw_metrics));
660
+ table('Reward-channel measurements',row.channels.values.map((value,index)=>[localAnalysis.rewardChannels[index].metric+' · '+localAnalysis.rewardChannels[index].type,`${value===null?'Not observed':value} · population relative ${row.channels.population_relative[index]===null?'Not available':row.channels.population_relative[index]}${row.channels.unavailable_reasons[index]?' · '+row.channels.unavailable_reasons[index]:''}`]));
661
+ $('descriptor_tables').replaceChildren(wrap);$('descriptor_status').textContent='All recorded values for this evaluated car are shown; unavailable fields remain explicitly unavailable.';
662
+ }
663
+ async function addFrozenEvaluation(){
664
+ const receipt=runReceipt||portableReceipt;if(localAnalysis?.mode!=='evaluate'||!localAnalysis.evaluation||!receipt)return;
665
+ const evaluation=localAnalysis.evaluation,summary=localAnalysis.summary,identity=conditionIdentity(summary,receipt.backend,receipt.build?.build_id??receipt.build_id??appBuild?.build_id??'');
666
+ const m=evaluation.measurements,o=m.objectives;
667
+ const policyLabels={fitness:'Final fitness leader',fastest_lap:'Fastest lap winner',clean_laps:'Clean laps winner'},trackInfo=runResultContext?.trackInfo??trackLibrary?.info?.();
668
+ const policyRole=Object.entries(comparisonPlan?.winnerNetworks??localAnalysis?.winnerNetworks??{}).find(([,item])=>item.sha256===evaluation.input_sha256)?.[0]??selectedWinner;
669
+ const record={policy_label:policyLabels[policyRole]??'Selected policy',network_sha256:evaluation.input_sha256,vehicle:['Formula','Rally','Snowmobile','Truck'][summary.conditions.vehicle_type],track_label:trackInfo?.display_name??(trackInfo?.source==='upload'?'Custom track':'Selected track'),track_sha256:summary.conditions.track_sha256,requested_ticks:summary.conditions.requested_ticks,best_lap_seconds:o.best_lap_seconds,forward_laps:o.forward_laps,physical_contacts:o.physical_contacts,total_actual_distance:o.total_actual_distance,identity};
670
+ if(comparisonRows.some(row=>row.network_sha256===record.network_sha256&&row.identity===identity)){
671
+ if($('evaluationStatus'))$('evaluationStatus').textContent='This policy and condition set is already in the comparison.';
672
+ if(comparisonPlan){comparisonPlan.index++;if(comparisonPlan.index>=comparisonPlan.roles.length){$('evaluationStatus').textContent='All selected winner policies have been evaluated sequentially. Compare physical measurements; fitness is not shown as a cross-policy score.';comparisonPlan=null;}}
673
+ return;
674
+ }
675
+ if(comparisonRows.length&&comparisonRows.some(row=>row.identity!==identity)){record.compatible=false;comparisonRows.push(record);renderEvaluationRows();$('evaluationStatus').textContent='Conditions differ from the comparison snapshot. This policy is shown separately and is not a like-for-like comparison.';return;}
676
+ record.compatible=true;comparisonRows.push(record);renderEvaluationRows();$('evaluationStatus').textContent='Added unchanged policy measurements. Shared conditions match across '+comparisonRows.length+' evaluation(s); compare physical measures and retain each policy hash.';
677
+ if(comparisonPlan){
678
+ comparisonPlan.index++;
679
+ if(comparisonPlan.index<comparisonPlan.roles.length){const next=comparisonPlan.roles[comparisonPlan.index];$('evaluationStatus').textContent+=' Next: run '+next.replaceAll('_',' ')+' under the same conditions.';}
680
+ else{$('evaluationStatus').textContent='All selected winner policies have been evaluated sequentially. Compare physical measurements; fitness is not shown as a cross-policy score.';comparisonPlan=null;}
681
+ }
682
+ }
683
+ if($('compare_winners'))$('compare_winners').onclick=async()=>{
684
+ try{
685
+ const roles=selectedWinnerRoles();if(!roles.length)throw Error('Select at least one available winner role');
686
+ const uniqueRoles=uniqueWinnerRoles(roles,localAnalysis.winnerNetworks);roles.splice(0,roles.length,...uniqueRoles);const winners=roles.map(role=>localAnalysis.winnerNetworks[role]);
687
+ const trackFile=await trackSource(),settings=window.getTrainingSettings?.();if(!trackFile||!settings)throw Error('Choose a track and valid evaluation conditions first');
688
+ const trackBytes=new Uint8Array(await trackFile.arrayBuffer()),trackSha=await sha(trackBytes),trackInfo=trackLibrary?.info()??{source:'upload'},vehicleType=localAnalysis.summary.conditions.vehicle_type;
689
+ if(winners.some(item=>item.vehicle_type!==vehicleType))throw Error('Selected winner policies do not share one vehicle type');
690
+ const cleanMinText=$('clean_min_laps')?.value??'',cleanMinLaps=cleanMinText===''?2:Number(cleanMinText);
691
+ if(!Number.isInteger(cleanMinLaps)||cleanMinLaps<1||cleanMinLaps>100)throw Error('Minimum clean laps must be a whole number from 1 to 100');
692
+ const conditions={track_sha256:trackSha,network_sha256:'0'.repeat(64),vehicle_type:vehicleType,requested_ticks:settings.ticks,control_hz:settings.control_hz,collision:settings.collision,idle:settings.idle,math_profile:'proton',clean_min_laps:cleanMinLaps};
693
+ comparisonPlan={roles,index:0,conditions,backend:$('backend').value,build_id:appBuild?.build_id??runReceipt?.build?.build_id,trackInfo,trackBytes,winnerNetworks:{...localAnalysis.winnerNetworks}};
694
+ $('keepServer').checked=false;comparisonRows=[];renderEvaluationRows();$('run_mode').value='evaluate';syncRunModeUI();buttons();await useWinner(roles[0]);
695
+ $('evaluationStatus').textContent='Starting a frozen comparison of '+roles.length+' distinct winner policies. This runs one unchanged policy at a time using the selected track, horizon and control settings. No candidate weights are changed.';
696
+ $('run').click();
697
+ }catch(error){$('analysisStatus').textContent='Comparison could not start: '+error.message;$('evaluationStatus').textContent=error.message;}
698
+ };
699
+ if($('winner_use'))$('winner_use').onclick=()=>useWinner($('winner_continue').value||'fitness');
700
+ if($('descriptor_show'))$('descriptor_show').onclick=showDescriptor;
701
+ if($('compare_add'))$('compare_add').onclick=addFrozenEvaluation;
702
+ if($('compare_clear'))$('compare_clear').onclick=()=>{comparisonRows=[];comparisonPlan=null;renderEvaluationRows();if($('evaluationStatus'))$('evaluationStatus').textContent='Comparison cleared.';};
703
+ if($('analysis_download'))$('analysis_download').onclick=()=>{if(!localAnalysis)return;downloadTextFile(localAnalysis.archiveBytes,'ald-run-analysis.gz','application/gzip');};
704
+ async function parseRunAnalysisArchive(bytes,descriptor,reportedSummary){
705
+ if(!(bytes instanceof Uint8Array)||bytes.byteLength<1||bytes.byteLength>ANALYSIS_ARCHIVE_MAX||!descriptor||descriptor.size!==bytes.byteLength||descriptor.sha256!==await sha(bytes))throw Error('Analysis archive size or SHA-256 does not match the server report');
706
+ if(descriptor.format!==undefined&&descriptor.format!=='ald.run-analysis-archive+gzip')throw Error('Unsupported analysis archive format');
707
+ const raw=await expandAnalysisGzip(bytes);let envelope;
708
+ try{envelope=JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(raw));}catch{throw Error('Analysis archive is not valid UTF-8 JSON');}
709
+ exactAnalysisKeys(envelope,['schema','version','files']);if(envelope.schema!=='ald.run-analysis-archive'||envelope.version!==1||!Array.isArray(envelope.files)||envelope.files.length>6)throw Error('Analysis archive envelope is invalid');
710
+ const files=new Map(),allowed=new Set(['analysis.json','descriptors.jsonl','evaluation.json','winners/fitness.evolution','winners/fastest_lap.evolution','winners/clean_laps.evolution']);
711
+ for(const file of envelope.files){
712
+ exactAnalysisKeys(file,['name','bytes','sha256','data_base64']);
713
+ if(typeof file.name!=='string'||!allowed.has(file.name)||files.has(file.name)||!Number.isInteger(file.bytes)||file.bytes<1||file.bytes>ANALYSIS_EXPANDED_MAX||!hex64(file.sha256))throw Error('Analysis archive file manifest is invalid');
714
+ const content=b64Bytes(file.data_base64,ANALYSIS_EXPANDED_MAX);
715
+ if(content.byteLength!==file.bytes||await sha(content)!==file.sha256)throw Error('Analysis archive inner file failed size or SHA-256 verification');
716
+ files.set(file.name,{bytes:content,sha256:file.sha256});
717
+ }
718
+ if(!files.has('analysis.json')||!files.has('descriptors.jsonl'))throw Error('Analysis archive is missing required descriptors');
719
+ let analysis;try{analysis=JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(files.get('analysis.json').bytes));}catch{throw Error('Analysis summary file is invalid JSON');}
720
+ exactAnalysisKeys(analysis,['schema','version','mode','execution_backend','conditions','observations','winners','semantics','metric_names','reward_channels','settings','descriptors']);
721
+ const summary=validateAnalysisSummary(reportedSummary);
722
+ for(const key of ['schema','version','mode','execution_backend','conditions','observations','winners','semantics'])if(stableJson(analysis[key])!==stableJson(summary[key]))throw Error('Analysis archive does not match the public run summary');
723
+ const metricNames=analysis.metric_names;
724
+ if(!Array.isArray(metricNames)||metricNames.length!==14||metricNames.some(x=>typeof x!=='string'||x.length>80)||new Set(metricNames).size!==14)throw Error('Analysis metric catalog is invalid');
725
+ const rewardChannels=analysis.reward_channels;
726
+ if(!Array.isArray(rewardChannels)||rewardChannels.length!==18||rewardChannels.some(x=>!x||typeof x.metric!=='string'||!['default','average'].includes(x.type)))throw Error('Analysis reward catalog is invalid');
727
+ if(!analysis.settings||typeof analysis.settings!=='object'||Array.isArray(analysis.settings))throw Error('Analysis settings are invalid');
728
+ exactAnalysisKeys(analysis.descriptors,['name','bytes','sha256','rows']);
729
+ const descriptorFile=files.get('descriptors.jsonl');if(analysis.descriptors.name!=='descriptors.jsonl'||analysis.descriptors.bytes!==descriptorFile.bytes.byteLength||analysis.descriptors.sha256!==descriptorFile.sha256||!Number.isInteger(analysis.descriptors.rows)||analysis.descriptors.rows<1||analysis.descriptors.rows>ANALYSIS_ROWS_MAX||analysis.descriptors.rows!==summary.observations.cars)throw Error('Descriptor file metadata is inconsistent');
730
+ const descText=new TextDecoder('utf-8',{fatal:true}).decode(descriptorFile.bytes),offsets=[],descriptorRows=[],candidateIds=new Set();let cursor=0;
731
+ while(cursor<descText.length){const end=descText.indexOf('\n',cursor),stop=end<0?descText.length:end;if(stop===cursor){cursor=stop+1;continue;}if(offsets.length>=ANALYSIS_ROWS_MAX)throw Error('Analysis descriptor row limit exceeded');let row;try{row=JSON.parse(descText.slice(cursor,stop));}catch{throw Error('Analysis descriptor row is invalid JSON');}validateDescriptorRow(row,metricNames,rewardChannels);if(candidateIds.has(row.candidate_id))throw Error('Analysis descriptor candidate identity is duplicated');candidateIds.add(row.candidate_id);descriptorRows.push(row);offsets.push([cursor,stop]);cursor=end<0?descText.length:stop+1;}
732
+ if(offsets.length!==analysis.descriptors.rows)throw Error('Analysis descriptor count does not match its manifest');
733
+ const winnerNetworks={};
734
+ for(const role of ['fitness','fastest_lap','clean_laps']){
735
+ const winner=summary.winners[role],name='winners/'+role+'.evolution',file=files.get(name);
736
+ if(winner.status==='available'){
737
+ if(!file||winner.artifact.bytes!==file.bytes.byteLength||winner.artifact.sha256!==file.sha256)throw Error('Winner network is missing or does not match its descriptor');
738
+ const matched=descriptorRows.find(row=>row.generation===winner.generation&&row.car===winner.car&&row.candidate_id===winner.candidate_id);
739
+ if(!matched)throw Error('Winner candidate is missing from the descriptor rows');
740
+ const network=await validateNetworkBytes(file.bytes);if(network.Type!==summary.conditions.vehicle_type)throw Error('Winner network vehicle type does not match analysis conditions');
741
+ winnerNetworks[role]={bytes:file.bytes,sha256:file.sha256,candidate_id:winner.candidate_id,vehicle_type:summary.conditions.vehicle_type};
742
+ }else if(file)throw Error('Unavailable winner unexpectedly has a network artifact');
743
+ }
744
+ let evaluation=null;
745
+ if(summary.mode==='evaluate'){
746
+ const f=files.get('evaluation.json');if(!f)throw Error('Frozen evaluation archive is missing its result');
747
+ try{evaluation=JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(f.bytes));}catch{throw Error('Frozen evaluation result is invalid JSON');}
748
+ exactAnalysisKeys(evaluation,['schema','version','conditions','input_sha256','weights_unchanged','measurements']);
749
+ if(evaluation.schema!=='ald.frozen-evaluation'||evaluation.version!==1||stableJson(evaluation.conditions)!==stableJson(summary.conditions)||evaluation.input_sha256!==summary.conditions.network_sha256||evaluation.weights_unchanged!==true)throw Error('Frozen evaluation identity is invalid');
750
+ validateDescriptorRow(evaluation.measurements,metricNames,rewardChannels);
751
+ }else if(files.has('evaluation.json'))throw Error('Training archive unexpectedly contains frozen evaluation data');
752
+ const expectedFiles=new Set(['analysis.json','descriptors.jsonl',...(summary.mode==='evaluate'?['evaluation.json']:[]),...Object.entries(summary.winners).filter(([,w])=>w.status==='available').map(([role])=>'winners/'+role+'.evolution')]);
753
+ if(files.size!==expectedFiles.size||[...files.keys()].some(name=>!expectedFiles.has(name)))throw Error('Analysis archive contains missing or unexpected files');
754
+ return {mode:summary.mode,summary,analysis,winnerNetworks,evaluation,archiveBytes:bytes,descriptorText:descText,descriptorOffsets:offsets,descriptorRows,rawMetrics:metricNames,rewardChannels};
755
+ }
756
+ function validatePrimaryAnalysisMatch(primarySha,parsed){
757
+ if(!hex64(primarySha)||!parsed?.summary)throw Error('Primary result and analysis are not verifiable');
758
+ if(parsed.mode==='train'){
759
+ const fitness=parsed.winnerNetworks?.fitness;
760
+ if(!fitness||fitness.sha256!==primarySha)throw Error('Primary result is not the archived final fitness winner');
761
+ }else if(parsed.mode==='evaluate'){
762
+ if(parsed.summary.conditions.network_sha256!==primarySha)throw Error('Frozen evaluation result does not preserve the submitted policy bytes');
763
+ }else throw Error('Analysis mode is unsupported');
764
+ return true;
765
+ }
766
+ window.RunAnalysis={parseArchive:parseRunAnalysisArchive,validatePrimaryMatch:validatePrimaryAnalysisMatch,validateComparisonRequest,uniqueWinnerRoles};
767
  function showDetails(r){
768
  const metric=n=>typeof n==='number'?n.toFixed(3):'Not reported';
769
  if($('effectiveSettings'))$('effectiveSettings').textContent=JSON.stringify(r?.settings??{},null,2);
 
771
  'Generation '+h.generation+' · mutation '+metric(h.mutation)+' · best '+metric(h.best_score)+' · mean '+metric(h.mean_score)+' · lap '+(h.best_lap_seconds??'none')+' · cars lapped '+(h.lapped_cars??'not reported')+' · ticks '+metric(h.simulated_ticks)).join('\n');
772
  }
773
  $('run').onclick = async () => {
774
+ const comparisonAttempt=!!comparisonPlan,comparisonRole=comparisonAttempt?comparisonPlan.roles[comparisonPlan.index]:null;
775
  cancelSteamLeaderboard();
776
  if($('leaderboard_status'))$('leaderboard_status').textContent='';
777
  const started=performance.now();
778
  $('runState').textContent='RUNNING — waiting for server confirmation';
779
  $('runState').style.color='#ffd073';
780
+ runReceipt=null;pairedAnalysisPending=false;
781
  runResultContext=null;
782
  showDetails(null);
783
  const tick=()=>{$('runSummary').textContent='Backend: '+$('backend').value.toUpperCase()+'\nElapsed: '+((performance.now()-started)/1000).toFixed(1)+' s\nCompletion is not yet confirmed.';};
 
787
  const trackInfo=trackLibrary?.info()??{source:'upload'};
788
  const trackFile=await trackSource(),networkFile=networkSource();
789
  const trackBytes=new Uint8Array(await trackFile.arrayBuffer()),networkBytes=new Uint8Array(await networkFile.arrayBuffer());
790
+ const networkValue=await validateNetworkBytes(networkBytes),settings=window.getTrainingSettings?.()??{};
791
+ const cleanMinText=$('clean_min_laps')?.value??'',cleanMinLaps=cleanMinText===''?2:Number(cleanMinText);
792
+ if(!Number.isInteger(cleanMinLaps)||cleanMinLaps<1||cleanMinLaps>100)throw Error('Minimum clean laps must be a whole number from 1 to 100.');
793
+ if(comparisonPlan){
794
+ try{validateComparisonRequest(comparisonPlan,{mode:$('run_mode')?.value==='evaluate'?'evaluate':'train',network_sha256:await sha(networkBytes),
795
+ track_sha256:await sha(trackBytes),vehicle_type:networkValue?.Type,requested_ticks:settings.ticks,control_hz:settings.control_hz,
796
+ collision:settings.collision,idle:settings.idle,clean_min_laps:cleanMinLaps,backend:$('backend').value,build_id:appBuild?.build_id});}
797
+ catch(error){comparisonPlan=null;throw error;}
798
+ }
799
  const autosaveSnapshot=bucketToken&&$('bucket_autosave')?.checked?bucketSnapshot():null;
800
  if(autosaveSnapshot)$('keepServer').checked=false;
801
  const context={trackInfo,trackBytes,inputNetworkSha256:await sha(networkBytes),autosaveSnapshot};
802
  const body={track:await encoded(trackFile),network:await encoded(networkFile),automatic:autosaveSnapshot?true:!$('keepServer').checked,backend:$('backend').value};
803
+ if(window.getTrainingSettings){body.settings={...settings};body.settings.mode=$('run_mode')?.value==='evaluate'?'evaluate':'train';body.settings.clean_min_laps=cleanMinLaps;
804
+ if(body.settings.mode==='evaluate')Object.assign(body.settings,{population:1,generations:1,mutation:0,mutation_end:0,schedule:'fixed',selection:'best',parents:1,elite:0,crossover:'none',decay:0,adaptive_mutation:false});}
805
+ job=await submit(body);localResult=null;localAnalysis=null;pairedAnalysisPending=false;selectedWinner=null;renderAnalysisPanel();
806
  clearInterval(progress);
807
  $('runState').textContent='COMPLETE — candidate ready to download';
808
  $('runState').style.color='#62dfb1';
 
817
  'Track: '+(trackInfo?.source==='builtin'?trackInfo.display_name:'Custom upload'),
818
  'Generations: '+r.generations_completed+' / '+r.generations_requested+' · Population: '+r.population,
819
  'Server processing: '+metric(r.elapsed_seconds)+' s',
820
+ ...(r.mode==='evaluate'||r.analysis_summary?.mode==='evaluate'?['Mode: FROZEN EVALUATION · weights unchanged']:['Final-generation population scores — best: '+metric(r.best_score)+' · mean: '+metric(r.mean_score)]),
821
  'Best completed lap observed during this run (may be another car): '+(r.best_lap_seconds==null?'No completed lap':metric(r.best_lap_seconds)+' s'),
822
  'Cars completing a lap in the final generation: '+(r.lapped_cars??'Not reported'),
823
  'Actual frames (including stats cutoff): '+(r.simulated_ticks??'Not reported')+' · Configured time limit: '+r.requested_ticks+' ticks',
 
827
  showDetails(r);
828
  if($('leaderboard_opt_in')?.checked)void checkSteamLeaderboard(trackInfo,r);
829
  else if($('leaderboard_status'))$('leaderboard_status').textContent='';
830
+ if(!comparisonPlan)$('track').value=''; $('network').value='';
831
  stagedNetwork=null;if($('networkChoice'))$('networkChoice').textContent='Network input released after the run.';
832
  say('Result ready ('+job.backend.toUpperCase()+'). Inputs and intermediate worker files have been removed.');
833
  if(autosaveSnapshot){
 
839
  try{await refreshBucketItems(autosaveSnapshot,{selectKey:outcome.saved.key,preflight:false});}
840
  catch{$('bucket_status').textContent='Network and run log were saved and verified. The saved-bundle list could not refresh; use Refresh saved bundles.';}
841
  }catch(error){
842
+ if(comparisonAttempt&&context.serverDeleted)comparisonPlan=null;
843
+ if(retainVerifiedPrimary(error)){
844
+ context.localPrimaryVerified=true;
845
+ $('runSummary').textContent+='\nThe primary network was verified and is available locally, but its paired analysis did not verify. No acknowledgment or bucket save was attempted. Retry Download result while the server result remains available.';
846
+ $('bucket_status').textContent='Primary network verified locally; paired analysis is pending. No bucket save was attempted.';
847
+ $('runState').textContent='COMPLETE — primary network verified; analysis pending';$('runState').style.color='#ffd073';
848
+ say('Primary network verified. Paired analysis is pending; retry Download result.');
849
+ }else if(!context.localVerified){
850
  $('runSummary').textContent+='\nAutomatic receive failed: '+error.message+' No verified local copy is available. The server result may remain available until deletion or expiry; retry download or delete it.';
851
  $('bucket_status').textContent='Run completed, but the result was not verified in this browser. No bucket save was attempted.';
852
  $('runState').textContent='COMPLETE — result not received or verified';$('runState').style.color='#ffd073';
 
862
  say('Run completed. A verified candidate remains in this page; bucket save needs attention.');
863
  }
864
  }
865
+ }else if(comparisonPlan){
866
+ try{
867
+ const received=await receive(job);
868
+ localResult=new Blob([received.bytes]);localAnalysis=received.analysis;pairedAnalysisPending=false;context.localVerified=true;renderAnalysisPanel();buttons();
869
+ runResultContext=context;
870
+ const acknowledgment=await acknowledge(job,received);
871
+ if(acknowledgment.deleted!==true)throw Error('Frozen comparison result was verified, but server deletion was not confirmed. No next comparison run was started.');
872
+ if(runReceipt)runReceipt.delivery={received_and_hash_verified:true,server_copy_deleted:true};
873
+ job=null;buttons();await addFrozenEvaluation();
874
+ }catch(error){
875
+ if(retainVerifiedPrimary(error)){
876
+ $('runState').textContent='COMPLETE — primary network verified; analysis pending';$('runState').style.color='#ffd073';
877
+ $('runSummary').textContent='The primary network was verified and remains available locally. Paired analysis is pending; no acknowledgment was sent and the comparison stopped. Retry Download result while the server result remains available.';
878
+ say('Primary network verified. Paired analysis is pending; comparison stopped until the result pair is verified.');
879
+ }else{
880
+ comparisonPlan=null;
881
+ $('evaluationStatus').textContent='Comparison stopped: '+error.message+' No later policy was run.';
882
+ throw error;
883
+ }
884
+ }
885
  }
886
  } catch (error) {
887
+ if(retainVerifiedPrimary(error)){
888
+ $('runState').textContent='COMPLETE — primary network verified; analysis pending';$('runState').style.color='#ffd073';
889
+ $('runSummary').textContent='The primary network was verified and is available to save locally. Its paired analysis is still pending, so no receipt, analysis, acknowledgment or bucket save is available. Retry Download result while the server result remains available.';
890
+ say('Primary network verified. Analysis is pending; retry the paired download before using the run report or saving a bundle.');
891
+ clearInterval(progress);return;
892
+ }
893
  clearInterval(progress);
894
+ if(comparisonAttempt&&comparisonRows.length){
895
+ comparisonPlan=null;
896
+ $('runState').textContent='COMPARISON STOPPED — completed results retained';
897
+ $('runState').style.color='#ffd073';
898
+ $('evaluationStatus').textContent='Comparison stopped before '+String(comparisonRole??'the next policy').replaceAll('_',' ')+' completed: '+displayError(error)+' Earlier verified results remain in the table.';
899
+ $('runSummary').textContent='Completed evaluations retained: '+comparisonRows.length+'\nThe current policy did not produce a fully verified result. Earlier comparison rows remain valid and available.\n'+displayError(error);
900
+ say('Comparison stopped. Earlier verified results remain in the table. '+displayError(error));
901
+ return;
902
+ }
903
  $('runState').textContent='ERROR — no successful result confirmed';
904
  $('runState').style.color='#ffb5a2';
905
  const quotaRejected=isGpuQuotaError(error)||String(error?.message??'').startsWith('GPU allowance is exhausted for this request.');
 
908
  : 'No downloadable result was received. A connection error does not prove the server stopped.');
909
  say(displayError(error));
910
  }
911
+ finally {clearInterval(progress);busy=false;$('keepServer').checked=false;buttons();if(comparisonPlan)launchNextComparison();}
912
  };
913
  $('download').onclick = async () => {
914
  busy=true; buttons();
915
  try {
916
+ const received=await receive(job);
917
  // The browser has the complete validated result. Disk-save success cannot be observed.
918
+ localResult=new Blob([received.bytes]);localAnalysis=received.analysis;pairedAnalysisPending=false;renderAnalysisPanel();saveLocal();if(localAnalysis?.mode==='evaluate')await addFrozenEvaluation();
919
+ const acknowledgment=await acknowledge(job,received);
920
  if(runReceipt)runReceipt.delivery={received_and_hash_verified:true,server_copy_deleted:acknowledgment.deleted===true};
921
  if (acknowledgment.deleted) { $('runState').textContent='COMPLETE — result received; server copy deleted'; job=null; say('Result received by browser. Server result deleted. Save your local download; server retrieval is no longer available.'); }
922
  else say('Result received by browser. The server copy is available for another download until its five-minute deadline; use Delete now to remove it sooner.');
923
+ } catch (error) {
924
+ if(retainVerifiedPrimary(error))say('Primary network verified and saved locally. The analysis did not verify, so the job remains available; retry Download result. No acknowledgment was sent.');
925
+ else say(error.message+' If receipt acknowledgment failed, use Delete now; automatic expiry still applies.');
926
+ }
927
  finally {busy=false; buttons();}
928
  };
929
  $('erase').onclick=async()=>{
 
946
  await encoded(original); // Reject missing/oversize inputs before dispatch.
947
  const backend=$('backend').value,autosaveSnapshot=bucketToken&&$('bucket_autosave')?.checked?bucketSnapshot():null;
948
  busy=true;batchActive=true;batchStopRequested=false;batchReceipt=null;buttons();
949
+ localResult=null;localAnalysis=null;pairedAnalysisPending=false;
950
  $('batch_history').replaceChildren();
951
  $('batches_feedback').textContent='';
952
  $('runState').textContent='BATCH RUNNING — completion not yet confirmed';
 
956
  $('batch_status').textContent='RUNNING · '+plan.name+' · step '+(i+1)+' / '+plan.runs.length+' · '+plan.runs[i].slot;
957
  const inputHash=await sha(await source.arrayBuffer());
958
  job=await submit({track,network:await encoded(source),settings,backend,automatic:true});
959
+ const stepJob=job,report=stepJob.report,stepRunReceipt={version:1,state:'completed',build:report?.build??appBuild,backend,output_sha256:stepJob.sha256,report};
960
+ let received;
961
+ try{received=await receive(job);}catch(error){if(retainVerifiedPrimary(error)){runReceipt=stepRunReceipt;runResultContext={trackInfo,trackBytes,inputNetworkSha256:inputHash,autosaveSnapshot};}throw error;}
962
+ localResult=new Blob([received.bytes]);localAnalysis=received.analysis;pairedAnalysisPending=false;renderAnalysisPanel();showDetails(job.report);
963
+ const stepContext={trackInfo,trackBytes,inputNetworkSha256:inputHash,autosaveSnapshot,analysisArchiveBytes:received.analysisBytes};
964
  runReceipt=stepRunReceipt;runResultContext=stepContext;runReceipt.delivery={received_and_hash_verified:true,server_copy_deleted:false};
965
+ const ack=await acknowledge(job,received);
966
  if(!ack.deleted)throw Error('Intermediate deletion was not confirmed; batch stopped');
967
  runReceipt.delivery.server_copy_deleted=true;
968
  const summary={backend,input_network_sha256:inputHash,output_sha256:stepJob.sha256,server_result_deleted:true,report};
 
987
  $('runSummary').textContent='Backend: '+backend.toUpperCase()+'\nCompleted steps: '+history.length+'\nUse Save received result again for the last candidate. A completed batch does not establish improved driving.';
988
  say('Batch results are in browser memory only; no server result remains for completed steps.');
989
  }catch(error){
990
+ retainVerifiedPrimary(error);
991
  if(failedCandidateStep&&!history.some(step=>step.step===failedCandidateStep.step)){
992
  const failed={...failedCandidateStep,autosave:'failed'};history.push(failed);
993
  const line=document.createElement('p');line.textContent='Step '+failed.step+' · '+failed.slot+' · '+failed.name+' · result received and server copy deleted · bucket save not confirmed';$('batch_history').append(line);
 
1059
  const a=document.createElement('a');a.href=url;a.download='ald-run-receipt.json';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);
1060
  };
1061
  function activeCleanReceipt(){return cleanRunReceipt(runReceipt)||cleanRunReceipt(portableReceipt);}
1062
+ function downloadBytes(bytes,name,type){const url=URL.createObjectURL(new Blob([bytes],{type}));const a=document.createElement('a');a.href=url;a.download=name;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);}
1063
+ function downloadTextFile(text,name,type){downloadBytes(text,name,type);}
1064
  function formatPlayerReport(receipt){
1065
  const r=receipt.report,metric=value=>typeof value==='number'?String(value):'Not reported';
1066
  const lines=['ALD driving run report','',`Build: ${receipt.build_id}`,`Backend: ${receipt.backend}`,`Vehicle: ${r.vehicle_name} (Type ${r.vehicle_type})`,`Generations: ${r.generations_completed} / ${r.generations_requested}`,`Population: ${r.population}`,`Elapsed seconds: ${metric(r.elapsed_seconds)}`,`Final-generation population best score: ${metric(r.best_score)}`,`Final-generation population mean score: ${metric(r.mean_score)}`,`Best completed lap observed during this run (may be another car): ${r.best_lap_seconds===null?'None':metric(r.best_lap_seconds)+' s'}`,`Cars completing a lap in the final generation: ${r.lapped_cars===null?'Not reported':r.lapped_cars}`,`Simulated ticks: ${r.simulated_ticks}`,`Input network SHA-256: ${r.inputs.network_sha256}`,`Track SHA-256: ${r.inputs.track_sha256}`,`Output network SHA-256: ${receipt.output_sha256}`,'','Configured training settings:',JSON.stringify(r.settings,null,2),'','Reward weights are configured values; per-reward contribution is not reported by the engine.'];
 
1106
  try{
1107
  bundleExact(report.inputs,['track_sha256','network_sha256']);
1108
  if(!/^[a-f0-9]{64}$/.test(report.inputs.track_sha256)||!/^[a-f0-9]{64}$/.test(report.inputs.network_sha256))return null;
1109
+ const recipeSettings={...report.settings},sourceMode=recipeSettings.mode,sourceCleanMinLaps=recipeSettings.clean_min_laps;delete recipeSettings.mode;delete recipeSettings.clean_min_laps;
1110
+ bundleExact(recipeSettings,Object.keys(window.RecipeLibrary.defaults));
1111
+ const analysisSummary=report.analysis_summary?validateAnalysisSummary(report.analysis_summary):null,mode=analysisSummary?.mode??sourceMode;
1112
+ const cleanMinLaps=analysisSummary?.conditions.clean_min_laps??sourceCleanMinLaps??2;
1113
+ if((sourceMode!==undefined&&!['train','evaluate'].includes(sourceMode))||(analysisSummary&&sourceMode!==undefined&&sourceMode!==analysisSummary.mode)||
1114
+ (sourceCleanMinLaps!==undefined&&(!Number.isInteger(sourceCleanMinLaps)||sourceCleanMinLaps<1||sourceCleanMinLaps>100))||
1115
+ (analysisSummary&&sourceCleanMinLaps!==undefined&&sourceCleanMinLaps!==analysisSummary.conditions.clean_min_laps)||!Number.isInteger(cleanMinLaps)||cleanMinLaps<1||cleanMinLaps>100)return null;
1116
+ let settings;
1117
+ if(mode==='evaluate'){
1118
+ if(recipeSettings.population!==1||recipeSettings.generations!==1||recipeSettings.mutation!==0||recipeSettings.mutation_end!==0||recipeSettings.schedule!=='fixed'||recipeSettings.selection!=='best'||recipeSettings.parents!==1||recipeSettings.elite!==0||recipeSettings.crossover!=='none'||recipeSettings.decay!==0||recipeSettings.adaptive_mutation!==false)return null;
1119
+ const checked=window.RecipeLibrary.settings({...recipeSettings,mutation:0.01,mutation_end:0.01});
1120
+ settings={...checked,mutation:0,mutation_end:0};
1121
+ }else settings=window.RecipeLibrary.settings(recipeSettings);
1122
+ const history=cleanRunHistory(report.history);
1123
+ settings.mode=mode??'train';settings.clean_min_laps=cleanMinLaps;
1124
  if(report.population!==settings.population||report.generations_requested!==settings.generations||report.requested_ticks!==settings.ticks||(history&&history.length!==report.generations_completed))return null;
1125
  return {version:1,kind:'run',state:'completed',build_id:buildId,backend:source.backend,output_sha256:source.output_sha256,
1126
  report:{vehicle_type:report.vehicle_type,vehicle_name:report.vehicle_name,generations_completed:report.generations_completed,
1127
  generations_requested:report.generations_requested,population:report.population,elapsed_seconds:report.elapsed_seconds,
1128
  best_score:report.best_score,mean_score:report.mean_score,best_lap_seconds:report.best_lap_seconds,lapped_cars:report.lapped_cars,
1129
  simulated_ticks:report.simulated_ticks,requested_ticks:report.requested_ticks,candidate_validated:true,
1130
+ execution_backend:report.execution_backend,inputs:{track_sha256:report.inputs.track_sha256,network_sha256:report.inputs.network_sha256},settings,...(history?{history}:{}),...(analysisSummary?{analysis_summary:analysisSummary}:{})}};
1131
  }catch{return null;}
1132
  }
1133
  function validatePortableReceipt(receipt,network,track){
1134
  bundleExact(receipt,['version','kind','state','build_id','backend','output_sha256','report']);
1135
  const report=receipt.report,reportKeys=['vehicle_type','vehicle_name','generations_completed','generations_requested','population','elapsed_seconds','best_score','mean_score','best_lap_seconds','lapped_cars','simulated_ticks','requested_ticks','candidate_validated','execution_backend','inputs','settings'];
1136
  if(Object.hasOwn(report,'history'))reportKeys.push('history');
1137
+ if(Object.hasOwn(report,'analysis_summary'))reportKeys.push('analysis_summary');
1138
  bundleExact(report,reportKeys);
1139
  const clean=cleanRunReceipt(receipt);
1140
  if(receipt.version!==1||receipt.kind!=='run'||receipt.state!=='completed'||!clean)throw Error('Run receipt failed its allowlist or value checks');
 
1165
  return file;
1166
  }
1167
  async function buildPortableBundle(){
1168
+ if(pairedAnalysisPending)throw Error('The paired analysis is still pending; retry the server download before exporting a bundle');
1169
  const api=window.PortableBundle;if(!api)throw Error('Portable bundle support is unavailable');
1170
+ const inputNetwork=networkSource(),winner=selectedWinner&&localAnalysis?.winnerNetworks?.[selectedWinner],selected=winner?new Blob([winner.bytes]):inputNetwork||localResult;if(!selected)throw Error('Choose or download a network/result first');
1171
+ const kind=winner||!inputNetwork?'result':'input',networkBytes=new Uint8Array(await selected.arrayBuffer());
1172
  await validateNetworkBytes(networkBytes);
1173
  const network=await api.artifact('evolution',kind,networkBytes),artifacts={selected_network:network};
1174
  let selectedTrack=null,trackInfo=null;
 
1184
  if($('bundle_recipe').checked){
1185
  const recipe=window.getPortableRecipe?.();if(recipe)artifacts.selected_recipe={version:1,slot:recipe.slot,name:recipe.name,settings:window.RecipeLibrary.settings(recipe.settings)};
1186
  }
1187
+ let note='',attachedRunReceipt=false;
1188
  const receiptSource=runReceipt||portableReceipt;
1189
  if($('bundle_receipt').checked&&receiptSource){
1190
  const candidate=cleanRunReceipt(receiptSource);
1191
+ const matches=candidate&&((kind==='result'&&candidate.output_sha256===network.sha256)||(kind==='input'&&candidate.report.inputs.network_sha256===network.sha256)||(winner&&localAnalysis?.summary?.winners?.[selectedWinner]?.artifact?.sha256===network.sha256));
1192
+ if(matches&&(!trackInfo||trackInfo.source!=='builtin'&&trackInfo.source!=='upload'||!selectedTrack||candidate.report.inputs.track_sha256===selectedTrack.sha256)){artifacts.run_receipt=candidate;attachedRunReceipt=true;}
1193
  else note=' The latest run receipt did not match this selected network/track and was omitted.';
1194
  }
1195
+ if(localAnalysis?.archiveBytes&&(kind==='result'||winner)){
1196
+ if(attachedRunReceipt)artifacts.analysis_archive=await api.analysisSlot(localAnalysis.archiveBytes);
1197
+ else note+=' The analysis archive was omitted because its matching run receipt is not included.';
1198
+ }
1199
  const text=await api.pack(artifacts);
1200
  return {text,note};
1201
  }
1202
  async function buildRunResultBundle(resultBlob,sourceReceipt,context){
1203
+ if(pairedAnalysisPending)throw Error('The paired analysis is still pending; retry the server download before saving this run');
1204
  if(!(resultBlob instanceof Blob)||!sourceReceipt||!context?.trackBytes)throw Error('The completed run data is not available for a verified bundle');
1205
  const networkBytes=new Uint8Array(await resultBlob.arrayBuffer()),trackBytes=context.trackBytes;
1206
  await validateNetworkBytes(networkBytes);
 
1213
  if(!receipt?.report?.history?.length)throw Error('The completed run has no validated generation history to save');
1214
  if(receipt.output_sha256!==network.sha256||receipt.report.inputs.network_sha256!==context.inputNetworkSha256||receipt.report.inputs.track_sha256!==track.sha256)throw Error('The saved run log does not match the verified network and track bytes');
1215
  validatePortableReceipt(receipt,network,track);
1216
+ const artifacts={selected_network:network,selected_track:track,run_receipt:receipt};
1217
+ if(localAnalysis?.archiveBytes){
1218
+ if(!receipt.report.analysis_summary)throw Error('The analysis archive cannot be saved without its matching validated summary');
1219
+ if(localAnalysis.mode==='train'&&localAnalysis.winnerNetworks?.fitness?.sha256!==network.sha256)throw Error('The primary network does not match the archived fitness winner');
1220
+ if(localAnalysis.mode==='evaluate'&&localAnalysis.summary.conditions.network_sha256!==network.sha256)throw Error('The frozen result does not match the submitted policy');
1221
+ artifacts.analysis_archive=await api.analysisSlot(localAnalysis.archiveBytes);
1222
+ }
1223
+ const text=await api.pack(artifacts);
1224
  return {text,receipt,network,track};
1225
  }
1226
  async function saveVerifiedRunToBucket(snapshot,context){
 
1234
  }
1235
  async function receiveAndAutosave(currentJob,snapshot,context){
1236
  const received=await receive(currentJob);
1237
+ localResult=new Blob([received.bytes]);localAnalysis=received.analysis;context.analysisArchiveBytes=received.analysisBytes;context.localVerified=true;renderAnalysisPanel();buttons();
1238
+ const acknowledgment=await acknowledge(currentJob,received);
1239
  if(acknowledgment.deleted!==true)throw Error('Candidate was verified, but server deletion was not confirmed; the bucket save was skipped. Download or delete the server result manually.');
1240
  context.serverDeleted=true;
1241
  if(runReceipt)runReceipt.delivery={received_and_hash_verified:true,server_copy_deleted:true};
1242
  if(job===currentJob)job=null;buttons();
1243
+ if(localAnalysis?.mode==='evaluate')await addFrozenEvaluation();
1244
  const outcome=await saveVerifiedRunToBucket(snapshot,context);context.bucketSaveConfirmed=true;return outcome;
1245
  }
1246
  async function exportPortableBundle(){
 
1252
  try{await exportPortableBundle();}catch(error){bundleMessage('Bundle not exported: '+error.message);}
1253
  };
1254
  async function preparePortableText(text){
1255
+ if(new TextEncoder().encode(text).byteLength>window.PortableBundle.LIMITS.bundle)throw Error('Portable bundle exceeds 48 MiB');
1256
  const parsed=await window.PortableBundle.parse(text);
1257
  await validateNetworkBytes(parsed.network.bytes);
1258
+ if(parsed.analysisArchive){
1259
+ if(!parsed.receipt?.report?.analysis_summary)throw Error('Version 2 analysis bundles need their matching sanitized run summary');
1260
+ parsed.analysis=await parseRunAnalysisArchive(parsed.analysisArchive.bytes,{size:parsed.analysisArchive.bytes.byteLength,sha256:parsed.analysisArchive.sha256,format:parsed.analysisArchive.format},parsed.receipt.report.analysis_summary);
1261
+ const summary=parsed.analysis.summary,selectedHash=parsed.network.sha256,fitnessHash=summary.winners.fitness.status==='available'?summary.winners.fitness.artifact.sha256:null;
1262
+ const selectedIsWinner=Object.values(parsed.analysis.winnerNetworks).some(w=>w.sha256===selectedHash);
1263
+ if(summary.mode==='evaluate'&&selectedHash!==summary.conditions.network_sha256)throw Error('Frozen evaluation bundle network does not match its unchanged input');
1264
+ if(summary.mode==='train'&&selectedHash!==fitnessHash&&!selectedIsWinner)throw Error('Selected network is not the primary result or a verified winner from this analysis');
1265
+ if(parsed.receipt.output_sha256!==fitnessHash&&summary.mode==='train')throw Error('Run receipt output does not match the fitness leader in its analysis');
1266
+ }
1267
  if(parsed.track){
1268
  if(parsed.track.metadata.source==='builtin'){
1269
  const entry=trackLibrary?.entries().find(e=>e.id===parsed.track.metadata.track_id);
 
1272
  parsed.track.file=await validateBundleTrack(parsed.track);
1273
  }
1274
  if(parsed.recipe){bundleExact(parsed.recipe.settings,Object.keys(window.RecipeLibrary.defaults));parsed.recipe.settings=window.RecipeLibrary.settings(parsed.recipe.settings);}
1275
+ if(parsed.receipt){
1276
+ if(parsed.analysis){
1277
+ const clean=cleanRunReceipt(parsed.receipt);if(!clean)throw Error('Run receipt failed its allowlist or value checks');
1278
+ if(parsed.receipt.report.inputs.track_sha256!==parsed.track?.metadata?.sha256&&parsed.track)throw Error('Run receipt does not describe the bundled track');
1279
+ if(parsed.receipt.report.analysis_summary?.conditions?.network_sha256!==parsed.receipt.report.inputs.network_sha256)throw Error('Analysis summary does not describe the receipt input');
1280
+ parsed.receipt=clean;
1281
+ }else parsed.receipt=validatePortableReceipt(parsed.receipt,parsed.network,parsed.track?.metadata);
1282
+ }
1283
  let trackTransfer=null;
1284
  if(parsed.track?.metadata.source==='upload'){
1285
  if(typeof DataTransfer!=='function')throw Error('This browser cannot stage an uploaded track; no bundle data was applied');
 
1301
  window.TrainingClient.useNetwork(networkFile);
1302
  if(parsed.recipe)window.stagePortableRecipe(parsed.recipe);
1303
  portableReceipt=parsed.receipt||null;
1304
+ localAnalysis=parsed.analysis||null;selectedWinner=null;renderAnalysisPanel();
1305
  $('bundle_receipt_download').disabled=!portableReceipt;
1306
  bundleMessage('Bundle restored in this page only. Network and optional track/recipe are staged; no run started and no browser storage or server was changed.'+(portableReceipt?' Sanitized receipt is available for separate download.':''));
1307
  buttons();
 
1317
  try{
1318
  if(bundleRestoring||busy||job)throw Error('Finish the active run before restoring a bundle');
1319
  restoreId=++bundleRestoreId;bundleRestoring=true;buttons();
1320
+ if(file.size>window.PortableBundle.LIMITS.bundle)throw Error('Portable bundle exceeds 48 MiB');
1321
  await restorePortableText(await file.text(),{lockHeld:true});
1322
  }catch(error){bundleMessage('Bundle not restored: '+error.message+'. The existing selections were left unchanged.');}
1323
  finally{if(restoreId===bundleRestoreId)bundleRestoring=false;buttons();input.value='';}
delivery.py CHANGED
@@ -16,6 +16,32 @@ from pydantic import BaseModel, Field
16
 
17
  class Unavailable(Exception): pass
18
  class CapacityExceeded(Exception): pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
 
20
  class ResultStore:
21
  def __init__(self, clock=time.monotonic, wall_clock=time.time, max_bytes=64*1024*1024):
@@ -26,12 +52,17 @@ class ResultStore:
26
 
27
  @property
28
  def retained_bytes(self):
29
- with self.lock: return sum(len(j['data']) for j in self.jobs.values())
 
 
30
 
31
  def _delete(self, ident):
32
  record = self.jobs.pop(ident)
33
- record['data'][:] = b'\0'*len(record['data'])
34
- record['data'].clear()
 
 
 
35
 
36
  def expire(self):
37
  with self.lock:
@@ -42,19 +73,28 @@ class ResultStore:
42
  with self.lock:
43
  for ident in list(self.jobs): self._delete(ident)
44
 
45
- def publish(self, data, automatic=True):
46
- if not isinstance(data, bytes) or not data or len(data)>4*1024*1024:
 
 
47
  raise CapacityExceeded()
 
48
  with self.lock:
49
  self.expire()
50
- if len(self.jobs)>=16 or self.retained_bytes+len(data)>self.max_bytes:
51
  raise CapacityExceeded()
52
  ident, key = secrets.token_urlsafe(24), secrets.token_urlsafe(32)
53
  expires = self.wall_clock()+300
54
- self.jobs[ident] = dict(key=key, data=bytearray(data), automatic=automatic,
55
- deadline=self.clock()+300, expires_at=expires, delivered=False, receipt=None)
56
- return dict(id=ident,key=key,expires_at=expires,automatic=automatic,
57
- sha256=hashlib.sha256(data).hexdigest(),size=len(data))
 
 
 
 
 
 
58
 
59
  def _get(self, ident, key):
60
  self.expire()
@@ -70,15 +110,35 @@ class ResultStore:
70
  def download(self, ident, key):
71
  with self.lock:
72
  r = self._get(ident,key)
73
- if r['automatic'] and r['delivered']: raise Unavailable()
 
 
74
  r['delivered'] = True
75
- r['receipt'] = secrets.token_urlsafe(32)
 
76
  return bytes(r['data']), r['receipt']
77
 
78
- def acknowledge(self, ident, key, receipt):
 
 
 
 
 
 
 
 
 
 
79
  with self.lock:
80
  r = self._get(ident,key)
81
  if not r['receipt'] or not hmac.compare_digest(r['receipt'],receipt): raise Unavailable()
 
 
 
 
 
 
 
82
  if r['automatic']:
83
  self._delete(ident)
84
  return dict(deleted=True)
@@ -92,6 +152,7 @@ class ResultStore:
92
 
93
  class Acknowledgment(BaseModel):
94
  receipt: str = Field(min_length=1,max_length=128)
 
95
 
96
  def create_api(store=None):
97
  store = store if store is not None else ResultStore()
@@ -140,9 +201,16 @@ def create_api(store=None):
140
  'Content-Disposition':'attachment; filename="candidate.evolution"',
141
  'X-Delivery-Receipt':receipt})
142
 
 
 
 
 
 
 
 
143
  @app.post('/api/jobs/{ident}/ack')
144
  def ack(ident:str,body:Acknowledgment,x_job_key:str|None=Header(default=None)):
145
- return call(store.acknowledge,ident,key(x_job_key),body.receipt)
146
 
147
  @app.delete('/api/jobs/{ident}')
148
  def delete(ident:str,x_job_key:str|None=Header(default=None)):
 
16
 
17
  class Unavailable(Exception): pass
18
  class CapacityExceeded(Exception): pass
19
+ class InvalidAnalysisOutput(Exception): pass
20
+
21
+ MAX_PRIMARY_BYTES = 4 * 1024 * 1024
22
+ MAX_ANALYSIS_BYTES = 32 * 1024 * 1024
23
+
24
+
25
+ def publish_worker_result(store, data, report, automatic=True, *, require_analysis=False):
26
+ """Remove private worker artifacts before returning the public run report."""
27
+ if not isinstance(report, dict):
28
+ if require_analysis:
29
+ raise InvalidAnalysisOutput()
30
+ return store.publish(data, automatic), report
31
+ missing = object()
32
+ private_files = report.pop('_analysis_files', missing)
33
+ archive = None
34
+ if private_files is missing:
35
+ if require_analysis:
36
+ raise InvalidAnalysisOutput()
37
+ else:
38
+ try:
39
+ from portable_bundle import BundleError, make_analysis_archive
40
+ archive = make_analysis_archive(
41
+ private_files, primary=data, analysis_summary=report.get('analysis_summary'))
42
+ except BundleError:
43
+ raise InvalidAnalysisOutput() from None
44
+ return store.publish(data, automatic, analysis=archive), report
45
 
46
  class ResultStore:
47
  def __init__(self, clock=time.monotonic, wall_clock=time.time, max_bytes=64*1024*1024):
 
52
 
53
  @property
54
  def retained_bytes(self):
55
+ with self.lock:
56
+ return sum(len(j['data']) + (len(j['analysis']) if j['analysis'] is not None else 0)
57
+ for j in self.jobs.values())
58
 
59
  def _delete(self, ident):
60
  record = self.jobs.pop(ident)
61
+ for name in ('data', 'analysis'):
62
+ payload = record[name]
63
+ if payload is not None:
64
+ payload[:] = b'\0' * len(payload)
65
+ payload.clear()
66
 
67
  def expire(self):
68
  with self.lock:
 
73
  with self.lock:
74
  for ident in list(self.jobs): self._delete(ident)
75
 
76
+ def publish(self, data, automatic=True, analysis=None):
77
+ if (not isinstance(data, bytes) or not data or len(data) > MAX_PRIMARY_BYTES
78
+ or (analysis is not None and (not isinstance(analysis, bytes) or not analysis
79
+ or len(analysis) > MAX_ANALYSIS_BYTES))):
80
  raise CapacityExceeded()
81
+ retained = len(data) + (len(analysis) if analysis is not None else 0)
82
  with self.lock:
83
  self.expire()
84
+ if len(self.jobs) >= 16 or self.retained_bytes + retained > self.max_bytes:
85
  raise CapacityExceeded()
86
  ident, key = secrets.token_urlsafe(24), secrets.token_urlsafe(32)
87
  expires = self.wall_clock()+300
88
+ self.jobs[ident] = dict(key=key, data=bytearray(data),
89
+ analysis=bytearray(analysis) if analysis is not None else None,
90
+ automatic=automatic, deadline=self.clock()+300, expires_at=expires,
91
+ delivered=False, receipt=None, analysis_delivered=False,
92
+ analysis_receipt=None)
93
+ published = dict(id=ident,key=key,expires_at=expires,automatic=automatic,
94
+ sha256=hashlib.sha256(data).hexdigest(),size=len(data))
95
+ if analysis is not None:
96
+ published['analysis'] = {'size': len(analysis), 'sha256': hashlib.sha256(analysis).hexdigest()}
97
+ return published
98
 
99
  def _get(self, ident, key):
100
  self.expire()
 
110
  def download(self, ident, key):
111
  with self.lock:
112
  r = self._get(ident,key)
113
+ # Paired results are retryable as a pair until ACK or expiry. Legacy
114
+ # primary-only automatic deliveries retain their one-attempt behavior.
115
+ if r['automatic'] and r['delivered'] and r['analysis'] is None: raise Unavailable()
116
  r['delivered'] = True
117
+ if r['receipt'] is None:
118
+ r['receipt'] = secrets.token_urlsafe(32)
119
  return bytes(r['data']), r['receipt']
120
 
121
+ def download_analysis(self, ident, key):
122
+ with self.lock:
123
+ r = self._get(ident,key)
124
+ if r['analysis'] is None or not r['delivered'] or r['receipt'] is None:
125
+ raise Unavailable()
126
+ r['analysis_delivered'] = True
127
+ if r['analysis_receipt'] is None:
128
+ r['analysis_receipt'] = secrets.token_urlsafe(32)
129
+ return bytes(r['analysis']), r['analysis_receipt']
130
+
131
+ def acknowledge(self, ident, key, receipt, analysis_receipt=None):
132
  with self.lock:
133
  r = self._get(ident,key)
134
  if not r['receipt'] or not hmac.compare_digest(r['receipt'],receipt): raise Unavailable()
135
+ if r['analysis'] is not None:
136
+ if (not r['analysis_delivered'] or not r['analysis_receipt']
137
+ or not isinstance(analysis_receipt, str)
138
+ or not hmac.compare_digest(r['analysis_receipt'],analysis_receipt)):
139
+ raise Unavailable()
140
+ elif analysis_receipt is not None:
141
+ raise Unavailable()
142
  if r['automatic']:
143
  self._delete(ident)
144
  return dict(deleted=True)
 
152
 
153
  class Acknowledgment(BaseModel):
154
  receipt: str = Field(min_length=1,max_length=128)
155
+ analysis_receipt: str | None = Field(default=None,min_length=1,max_length=128)
156
 
157
  def create_api(store=None):
158
  store = store if store is not None else ResultStore()
 
201
  'Content-Disposition':'attachment; filename="candidate.evolution"',
202
  'X-Delivery-Receipt':receipt})
203
 
204
+ @app.post('/api/jobs/{ident}/analysis/download')
205
+ def download_analysis(ident:str,x_job_key:str|None=Header(default=None)):
206
+ data,receipt = call(store.download_analysis,ident,key(x_job_key))
207
+ return Response(data,media_type='application/gzip',headers={
208
+ 'Content-Disposition':'attachment; filename="analysis.json.gz"',
209
+ 'X-Analysis-Delivery-Receipt':receipt})
210
+
211
  @app.post('/api/jobs/{ident}/ack')
212
  def ack(ident:str,body:Acknowledgment,x_job_key:str|None=Header(default=None)):
213
+ return call(store.acknowledge,ident,key(x_job_key),body.receipt,body.analysis_receipt)
214
 
215
  @app.delete('/api/jobs/{ident}')
216
  def delete(ident:str,x_job_key:str|None=Header(default=None)):
index.html CHANGED
@@ -29,7 +29,7 @@
29
  })();
30
  </script>
31
  <header><div class="eyebrow">AI LEARNS TO DRIVE · TRAINING LAB</div><h1>Train, inspect, repeat.</h1><p class="intro">Pick a track and network, run a bounded training session, and keep your recipes in this browser.</p><details class="about"><summary>About this beta and its data handling</summary><p>Simulator by <a href="https://github.com/Zorro909/driving_sim_rs">Zorro909</a>; interface and hosting by <a href="https://github.com/seeker-cyber-maker/driving_sim_rs">TiGa</a>. Inspired by the <a href="https://store.steampowered.com/app/3312030/AI_Learns_To_Drive/">game by ActivationFunction</a>. Built-in tracks are reconstructed replicas. Star challenges are not evaluated here.</p><p>Uploaded files are used for the requested run. Results are held briefly in application memory and deleted after verified download or expiry. Browser recipes contain settings only. <a href="/policy" target="_blank" rel="noopener">Read the data policy</a>.</p></details><p><a id="feedbackLink" href="https://github.com/seeker-cyber-maker/driving_sim_rs/issues/new" target="_blank" rel="noopener">Report a problem on GitHub</a> · Opens an editable draft and never submits it. Review details before posting; export a run receipt separately if it helps explain the issue.</p><p id="buildLabel" class="hint">Build: connecting…</p></header>
32
- <nav class="tabs" aria-label="Lab sections"><button data-tab="drive" aria-selected="true">1 · Drive & view</button><button data-tab="training" aria-selected="false">2 · Training runs & presets</button><button data-tab="batch" aria-selected="false">3 · Batches</button><button data-tab="editor" aria-selected="false">4 · Network editor</button></nav><div id="drive" class="pane"><div class="grid"><section class="card"><h2>Simulation</h2><button id="run" disabled>Run training</button><p class="hint">Select or create a network below to enable training. Review settings on tab 2 before running.</p><p>Supports Formula, Rally, Snowmobile and Truck networks. Control outputs follow the original vehicle editor rules. Network shape and supported sensors are bounded by the original game contract.</p>
33
  <p id="computeBlock"><label for="backend">Compute</label><select id="backend" disabled><option value="cpu">CPU — no GPU allowance</option><option id="gpuOption" value="gpu" disabled>GPU — uses GPU allowance</option></select></p><p id="backendNote" class="hint">Checking available backends…</p>
34
  <div id="connectionRecovery" hidden><button id="retry" type="button">Reconnect to app</button><p class="hint">Checks server availability. Does not restart training, sign you in, or reset GPU allowance.</p></div>
35
  <div id="gpuAccount" aria-label="Optional GPU account"><p><button id="hfSignIn" type="button" disabled>Sign in with HF for account GPU allowance</button><span id="hfAccount" hidden></span> <button id="hfSignOut" type="button" disabled hidden>Disconnect account</button></p><p id="hfIdentity" role="status" class="hint">CPU needs no sign-in. Checking optional GPU account connection…</p>
@@ -44,10 +44,23 @@
44
  <p><button id="saveLocal" disabled>Download received result (browser only)</button> <button id="forgetLocal" disabled>Discard browser result</button></p>
45
  <p>The browser keeps the received result in memory until discarded, replaced by a new run, or this page is closed. It is not saved to browser storage.</p>
46
  <h3 id="runState" role="status" aria-live="polite">READY — no run started</h3>
47
- <pre id="runSummary" style="white-space:pre-wrap;line-height:1.7" aria-live="polite"></pre><label class="keep-result"><input id="leaderboard_opt_in" type="checkbox"> After a completed lap, check its position against the public Steam leaderboard</label><p class="privacy-note">Optional, read-only check for supported built-in tracks. It sends the track ID and best lap to this app server; Steam receives only a public leaderboard read. No score is submitted. The result is an estimate, not an official rank.</p><p id="leaderboard_status" role="status" aria-live="polite"></p>
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  <p><button id="run_export" disabled>Export run receipt</button> <button id="player_report_download" disabled>Download player report (.txt)</button> <button id="history_csv_download" disabled>Download generation history (.csv)</button></p><p class="hint">Reports use only server-reported run and generation fields. The engine does not report per-reward contribution, so reward weights are shown as configured values only.</p>
49
  <details id="portableBundle"><summary>Portable bucket bundle</summary><p>Build, export, or restore one validated bundle. Bucket storage is optional. When connected, automatic save is on by default; turn it off here to keep saves manual. Disconnected runs never call bucket storage.</p><label><input id="bundle_track" type="checkbox"> Include the selected track</label><label><input id="bundle_recipe" type="checkbox"> Include the recipe in the selected slot, if saved</label><label><input id="bundle_receipt" type="checkbox"> Include a sanitized receipt for the selected completed result</label><div class="toolbar"><button id="bundle_export" type="button">Export portable bundle</button><label for="bundle_import">Restore portable bundle (.json)<input id="bundle_import" type="file" accept=".json,application/json"></label><button id="bundle_receipt_download" type="button" disabled>Download staged receipt</button></div><p id="bundle_status" role="status" aria-live="polite">No bucket action has been requested.</p>
50
- <details id="bucketStorage"><summary>Connect to an existing private bucket</summary><p>Bucket storage is optional. Authorizing storage also identifies your account for GPU requests; ordinary GPU sign-in remains available with the basic identity permission. Then enter your existing private bucket ID. Connected runs automatically save by default; uncheck this option to keep run saves manual. No personal token is requested or stored.</p><p id="bucket_scope_notice" class="privacy-note">HF describes the requested <code>write-repos</code> permission as read/write access to your personal repositories. That grant is broader than one selected bucket and is not a bucket-only permission; the app limits its own operations to the bucket you enter. The minimum current scope for bucket read/write has not been confirmed by HF.</p><button id="bucket_authorize" type="button" hidden disabled>Authorize bucket access with HF</button><p id="bucket_auth_unavailable" class="hint" hidden>HF bucket authorization is unavailable for this app. Local bundle export remains available.</p><p id="bucket_auth_status" role="status" aria-live="polite">Checking optional HF bucket authorization…</p><div id="bucketConnectForm" hidden><label for="bucket_id">Private bucket ID</label><input id="bucket_id" type="text" autocomplete="off" placeholder="username/bucket-name" disabled><button id="bucket_connect" type="button" disabled>Connect bucket</button></div><div id="bucketConnected" hidden><p id="bucket_account"></p><label class="keep-result"><input id="bucket_autosave" type="checkbox" checked> Automatically save every verified completed network and run log to this bucket</label><p class="hint">For each completed run, the browser fetches and verifies the network, then confirms server deletion before sending the network and sanitized log to your bucket. If deletion is not confirmed, no save occurs. If bucket saving is not confirmed, the verified local candidate stays available to download or retry; the remote save may have completed. Each batch step saves before the next starts, and a failed save stops the batch.</p><button id="bucket_disconnect" type="button">Disconnect bucket and clear HF authorization</button><div class="toolbar"><button id="bucket_save" type="button" disabled>Save selected bundle</button><button id="bucket_refresh" type="button" disabled>Refresh saved bundles</button></div><label for="bucket_items">Saved bundles from this app</label><select id="bucket_items" size="4" disabled><option value="">Refresh to list saved bundles</option></select><button id="bucket_restore" type="button" disabled>Restore selected bundle</button></div><p id="bucket_status" role="status" aria-live="polite">Not connected. Bucket storage is optional.</p></details></details>
51
  <p id="deadline"></p><p id="status" role="status">Ready. Inputs remain on this device until you run the test.</p>
52
  <details><summary>Effective training settings</summary><pre id="effectiveSettings"></pre></details><details><summary>Generation results</summary><pre id="generationHistory"></pre></details><details><summary>Star challenges — not evaluated</summary><p>The current runner has star-challenge reporting. Its evaluator and required statistics have not been ported to HF; no star result is inferred from a completed test.</p></details></section></div>
53
  </div><section id="training" class="pane" hidden><div class="card"><h2>Training settings</h2><p class="hint">Choose the track on Drive & view. Choose the training settings and reward mix here, then start the run on Drive & view. Recipes and batch sequences are saved in this browser.</p><p><label for="trackPresetTraining">Training track</label><select id="trackPresetTraining" data-track-library disabled><option value="">Upload a custom track</option></select></p><p data-track-status role="status" class="hint">Loading built-in replica tracks…</p><div id="training_controls"><label for="training_ticks">Simulated ticks per generation</label><input id="training_ticks" type="number" min="1" max="18000" value="60"><small>60 ticks = one simulated second. Current preview limit: population × generations × ticks ≤ 4,000,000; execution timeout 25 seconds.</small>
@@ -68,13 +81,13 @@
68
  <label for="reward_preset">Reward preset</label><select id="reward_preset" disabled><option value="custom">Custom</option><option value="distance">First laps · distance only</option><option value="center">Example mix · center, clearance, collisions</option><option value="clean">Clean driving · distance + collision penalty</option><option value="lap">Lap refinement · best lap only</option></select>
69
  <div id="rewards"></div><button id="add" disabled>+ Add reward</button><small>Weights −100…+100; absolute shares normalize to 100%. Five distinct metrics maximum. The current vehicle runner accepts these metrics; choose them for the behavior you want to train.</small>
70
  </details></div>
71
- <details id="run_settings"><summary>Run settings · timing and elimination</summary><div class="row"><div><label for="control_hz">Decisions per second</label><select id="control_hz" disabled><option>60</option><option>30</option><option>15</option></select></div><div><label for="workers">Parallel simulations</label><select id="workers" disabled><option>1</option><option selected>2</option><option>3</option><option>4</option></select></div></div><small>Physics runs at 60 Hz. CPU jobs can use 1–4 worker threads.</small><div id="seed_group"><label for="seed">Random seed</label><input id="seed" type="number" min="0" max="2147483647" value="42105" disabled></div><label><input id="collision" type="checkbox" disabled> Eliminate on wall contact</label><label><input id="idle" type="checkbox" disabled> Eliminate when stuck</label></details></div><div id="libraries" class="card"><h3>Recipes · A–J · saved in this browser</h3>
72
  <p>Each recipe contains the training settings and reward mix. Tracks, networks and results stay out of the recipe library. Saving a recipe sends nothing to the server. Settings are sent when you start a run.</p>
73
  <div class="row"><label for="recipe_slot">Recipe slot<select id="recipe_slot"><option>A</option><option>B</option><option>C</option><option>D</option><option>E</option><option>F</option><option>G</option><option>H</option><option>I</option><option>J</option></select></label><label for="recipe_name">Recipe name<input id="recipe_name" maxlength="80" value="My recipe"></label></div>
74
  <button id="recipe_save">Save current settings</button> <button id="recipe_load">Load recipe</button>
75
- <p><button id="recipe_export">Export recipe library</button> <label for="recipe_import">Import recipe library (.json)<input id="recipe_import" type="file" accept=".json"></label></p>
76
  <button id="recipe_clear">Clear saved recipes and batches on this device</button><p id="recipe_feedback" role="status" aria-live="polite"></p>
77
- <p>Recipes are specific to this browser and site. Clearing site data removes them. Export a backup to move them between the local runner and HF. Browser privacy settings may prevent saving.</p>
78
  <details><summary>Advanced recipe JSON</summary><textarea id="recipe_draft" rows="12" style="width:100%" aria-label="Recipe settings JSON"></textarea><button id="recipe_apply_draft">Apply JSON to form</button></details></div></section>
79
  <section id="batch" class="pane" hidden><div class="card"><h2>Training batches · A–J</h2><p>Choose files and CPU/GPU on Drive & view, then start a saved batch here. Up to 20 runs and 4,000,000 vehicle-ticks in total. Recipes freeze at start. Each server result is automatically deleted after verified receipt. The last candidate is kept in browser memory. Stop prevents the next run; the current run finishes.</p><label for="batch_slot">Batch slot</label><select id="batch_slot" disabled><option>A</option><option>B</option><option>C</option><option>D</option><option>E</option><option>F</option><option>G</option><option>H</option><option>I</option><option>J</option></select><label for="batch_name">Display name</label><input id="batch_name" type="text" maxlength="80" value="My curriculum" disabled><label><input id="carry" type="checkbox" checked disabled> Continue from the previous run’s candidate</label><p class="hint">Unchecked: each run starts from the original selected network. Repeated run references are allowed. Recipes freeze when the batch starts.</p><label for="batch_recipe_sequence">Recipe sequence (comma-separated slots)</label><input id="batch_recipe_sequence" value="A" maxlength="300"><label for="batch_add_recipe">Add recipe<select id="batch_add_recipe"><option>A</option><option>B</option><option>C</option><option>D</option><option>E</option><option>F</option><option>G</option><option>H</option><option>I</option><option>J</option></select></label><button id="queue_add" disabled>+ Add run</button><div class="toolbar"><button id="batch_save" disabled>Save batch</button><button id="batch_load" disabled>Load batch</button><button class="primary" id="batch_start" disabled>Start saved batch</button><button id="batch_stop" disabled>Stop after current run</button><button id="batch_export" disabled>Export batch receipt</button></div><div id="batch_status" role="status">No batch selected.</div><div id="batch_history"></div><div id="batches_feedback" role="status" aria-live="polite"></div></div></section>
80
  <section id="editor" class="pane" hidden><div class="card"><h2>Network editor</h2><p class="hint">An original editor inspired by <a href="https://the-mars-guy.github.io/AI-Learns-To-Drive-Tool/" target="_blank" rel="noopener">Mars’s network creator</a>. Create fresh networks with the selected vehicle’s game-legal controls, or use its largest original-game shape. Loaded networks keep their original vehicle, output order and sensor parameters. Unsupported files can still be inspected without changing the original.</p><div class="toolbar"><label for="editor_file">Load an existing .evolution file</label><input id="editor_file" type="file" accept=".evolution"><button id="editor_import" disabled>Inspect selected network</button><button id="editor_create" disabled>Create fresh network</button><button id="editor_apply" disabled>Use edited network</button><a id="editor_download" hidden download>Download .evolution</a></div><div class="row"><div><label for="editor_name">Network name</label><input id="editor_name" type="text" maxlength="80" value="Fresh network" disabled><label for="editor_vehicle">Vehicle type for fresh networks</label><select id="editor_vehicle" disabled><option value="0">Formula</option><option value="1">Rally</option><option value="2">Snowmobile</option><option value="3">Truck</option></select><small>This selector applies only when you create a fresh network. Imported vehicle type is retained and shown with the current network.</small><label for="editor_preset">Fresh network preset</label><select id="editor_preset" disabled><option id="editor_small_label" value="small">Small starter · 6 inputs, 4 hidden nodes, 5 outputs</option><option id="editor_maximum_label" value="maximum">Largest game network · 20 inputs, 10 × 16 hidden nodes, 5 outputs</option><option value="custom">Custom</option></select><label for="editor_shape">Input and hidden layer sizes</label><input id="editor_shape" type="text" value="6,4" disabled><small id="editor_shape_hint">Enter 1–20 inputs, followed by 0–10 hidden layers of 1–16 nodes. Output width follows the selected controls. Maximum shape: 20 → ten layers of 16 → 5 (2,869 parameters).</small><fieldset id="editor_outputs" disabled><legend id="editor_output_legend">Fresh network outputs</legend><div class="output-options"><label><input id="editor_output_0" type="checkbox" value="0" checked disabled> Steering (0)</label><label><input id="editor_output_1" type="checkbox" value="1" checked disabled> Acceleration (1)</label><label><input id="editor_output_2" type="checkbox" value="2" checked disabled> Handbrake (2)</label><label><input id="editor_output_3" type="checkbox" value="3" checked disabled> Brake (3)</label><label><input id="editor_output_4" type="checkbox" value="4" checked disabled> Boost (4)</label></div><small id="editor_output_hint">Steering and Acceleration are required. Fresh output IDs preserve this order.</small></fieldset></div><div><label for="editor_seed">Initialization seed</label><input id="editor_seed" type="number" min="0" max="2147483647" value="42105" disabled><label><input id="editor_xavier" type="checkbox" checked disabled> Seeded Xavier uniform weights (browser algorithm)</label><label><input id="editor_bias" type="checkbox" disabled> Xavier uniform biases</label><small>Unchecked: zeros. Biases default to zero.</small></div></div><p id="editor_status" role="status">Choose a file or create a fresh network. Editor changes are browser-local until you explicitly start a run.</p><p id="editor_metadata" class="hint"></p><p id="editor_legality" class="hint"></p><details id="editor_history_panel"><summary>Training history · recorded generations</summary><div class="table-scroll"><table><thead><tr><th>Record</th><th>Time</th><th>Mutation</th><th>Best score</th><th>Best lap</th></tr></thead><tbody id="editor_history"></tbody></table></div><details><summary>Full saved training metadata</summary><pre id="editor_stats"></pre></details></details><canvas aria-label="Network architecture preview" id="network_map" width="1100" height="450"></canvas><details id="weight_values"><summary>Individual weight and bias values</summary><div class="table-scroll" style="max-height:420px;overflow:auto"><table><thead><tr><th>Connection / bias</th><th>Value</th></tr></thead><tbody id="editor_parameters"></tbody></table></div></details><details><summary>Weights, biases and input contract</summary><pre id="editor_contract"></pre><label for="editor_json">NeuralNetwork JSON · editable weights and biases</label><textarea id="editor_json" rows="16" style="width:100%;box-sizing:border-box;background:#0c1725;color:#e5edf5" disabled></textarea></details></div></section><script>/* Settings-only recipe contract. Storage is injected; no network or job data. */
@@ -329,6 +342,14 @@
329
  try{store=api.createStore(window.localStorage);store.load();message('Browser-local library ready.');}
330
  catch(error){message('Local library unavailable: '+error.message+'. Existing data was not changed.');}
331
  const read=()=>{if(!store)throw Error('Browser storage unavailable');return store.load();};
 
 
 
 
 
 
 
 
332
  window.getBatchPlan=()=>window.BatchTraining.plan(read(),q('batch_slot').value);
333
  const action=fn=>async()=>{try{await fn();}catch(error){message('Not completed: '+error.message);}};
334
  q('recipe_apply_draft').onclick=action(()=>{applySettings(JSON.parse(q('recipe_draft').value));q('preset').value='custom';q('reward_preset').value='custom';message('Draft applied to the training form. No run started.');});
@@ -350,10 +371,7 @@
350
  });
351
  q('recipe_import').onchange=action(async()=>{
352
  const input=q('recipe_import'),file=input.files[0];if(!file)return;input.value='';if(file.size>32768)throw Error('Recipe file exceeds 32 KB');
353
- const book=api.parse(await file.text());
354
- if(!(await window.appConfirm('Replace this browser’s recipe library with '+book.recipes.length+' imported recipes? Export a backup first if needed.',{heading:'Import recipe library',confirmLabel:'Replace library'})))return;
355
- if(!store)throw Error('Browser storage unavailable');store.save(book);
356
- message('Imported '+book.recipes.length+' recipes locally. No run was started.');q('recipe_import').value='';
357
  });
358
  q('recipe_clear').onclick=action(async()=>{
359
  if(!(await window.appConfirm('Clear saved recipes and batches for this app on this device? Server run data and downloaded files are unaffected.',{heading:'Clear local recipes and batches',confirmLabel:'Clear local data',danger:true})))return;
@@ -468,7 +486,7 @@
468
  if(typeof module==='object'&&module.exports)module.exports=factory();
469
  else root.PortableBundle=factory();
470
  })(typeof window==='undefined'?globalThis:window,function(){
471
- const SCHEMA='ald.portable-bundle',LIMITS={network:4*1024*1024,track:1024*1024,recipe:32768,receipt:524288,bundle:8*1024*1024};
472
  const exact=(value,keys)=>{
473
  if(!value||typeof value!=='object'||Array.isArray(value)||Object.keys(value).sort().join()!==[...keys].sort().join())throw Error('Unexpected portable bundle fields');
474
  };
@@ -476,7 +494,7 @@
476
  const sha=async bytes=>Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256',bytes)),b=>b.toString(16).padStart(2,'0')).join('');
477
  function b64(bytes){let text='';for(let i=0;i<bytes.length;i+=8192)text+=String.fromCharCode(...bytes.subarray(i,i+8192));return btoa(text);}
478
  function unb64(text){
479
- if(typeof text!=='string'||text.length>Math.ceil(LIMITS.network/3)*4+8)throw Error('Invalid portable artifact encoding');
480
  let binary;try{binary=atob(text);}catch{throw Error('Invalid portable artifact encoding');}
481
  const bytes=Uint8Array.from(binary,c=>c.charCodeAt(0));if(b64(bytes)!==text)throw Error('Non-canonical portable artifact encoding');return bytes;
482
  }
@@ -486,21 +504,25 @@
486
  if(!data.length||data.byteLength>limit)throw Error(format==='evolution'?'Network exceeds 4 MiB bundle limit':'Track exceeds 1 MiB bundle limit');
487
  return {format,kind,bytes:data.byteLength,sha256:await sha(data),data_base64:b64(data)};
488
  }
 
489
  async function pack(artifacts){
490
  if(!artifacts||!artifacts.selected_network)throw Error('A selected network is required');
491
- const clean={schema:SCHEMA,version:1,artifacts};
492
- const text=JSON.stringify(clean,null,2);if(utf8(text).byteLength>LIMITS.bundle)throw Error('Portable bundle exceeds 8 MiB');
 
 
493
  return text;
494
  }
495
  async function parse(text){
496
- if(typeof text!=='string'||utf8(text).byteLength>LIMITS.bundle)throw Error('Portable bundle exceeds 8 MiB');
497
  let bundle;try{bundle=JSON.parse(text);}catch{throw Error('Portable bundle is not valid JSON');}
498
- exact(bundle,['schema','version','artifacts']);if(bundle.schema!==SCHEMA||bundle.version!==1)throw Error('Unsupported portable bundle version');
499
- const allowed=['selected_network','selected_track','selected_recipe','run_receipt'];
 
500
  if(!bundle.artifacts||typeof bundle.artifacts!=='object'||Array.isArray(bundle.artifacts)||Object.keys(bundle.artifacts).some(k=>!allowed.includes(k))||!bundle.artifacts.selected_network)throw Error('Portable bundle needs a selected network and fixed artifact slots');
501
  const a=bundle.artifacts,networkMeta=a.selected_network,networkBytes=await checkArtifact(networkMeta,'evolution',['format','kind','bytes','sha256','data_base64'],LIMITS.network);
502
  if(!['input','result'].includes(networkMeta.kind))throw Error('Invalid selected network kind');
503
- let track=null,recipe=null,receipt=null;
504
  if(Object.hasOwn(a,'selected_track')){
505
  const t=a.selected_track;if(!t||typeof t!=='object')throw Error('Invalid selected track');
506
  const builtin=t.source==='builtin';
@@ -521,20 +543,34 @@
521
  const text=JSON.stringify(r);if(utf8(text).byteLength>LIMITS.receipt)throw Error('Run receipt exceeds 512 KiB bundle limit');
522
  receipt=r;
523
  }
524
- return {bundle,network:{...networkMeta,bytes:networkBytes},track,recipe,receipt};
 
 
 
 
 
525
  }
526
  async function checkArtifact(value,format,keys,limit){
527
  exact(value,keys);if(value.format!==format||!Number.isInteger(value.bytes)||value.bytes<1||value.bytes>limit||! /^[a-f0-9]{64}$/.test(value.sha256))throw Error('Invalid '+format+' artifact metadata');
528
  const bytes=unb64(value.data_base64);if(bytes.byteLength!==value.bytes||await sha(bytes)!==value.sha256)throw Error(format+' bytes or SHA-256 do not match');
529
  return bytes;
530
  }
531
- return {SCHEMA,LIMITS,sha,artifact,pack,parse};
532
  });</script><script src="/client.js"></script><script src="/hf-auth.js"></script><script>'use strict';
533
  // Navigation only. Editor and training/storage adapters are separate layers.
534
  document.querySelectorAll('[data-tab]').forEach(button=>button.addEventListener('click',()=>{
535
  document.querySelectorAll('.pane').forEach(pane=>pane.hidden=pane.id!==button.dataset.tab);
536
  document.querySelectorAll('[data-tab]').forEach(tab=>tab.setAttribute('aria-selected',String(tab===button)));
537
  }));
 
 
 
 
 
 
 
 
 
538
  </script><script>// Browser-only editor; upload is controlled by the separate training client.
539
  (() => {
540
  const q=id=>document.getElementById(id),api=window.NetworkEditor;
 
29
  })();
30
  </script>
31
  <header><div class="eyebrow">AI LEARNS TO DRIVE · TRAINING LAB</div><h1>Train, inspect, repeat.</h1><p class="intro">Pick a track and network, run a bounded training session, and keep your recipes in this browser.</p><details class="about"><summary>About this beta and its data handling</summary><p>Simulator by <a href="https://github.com/Zorro909/driving_sim_rs">Zorro909</a>; interface and hosting by <a href="https://github.com/seeker-cyber-maker/driving_sim_rs">TiGa</a>. Inspired by the <a href="https://store.steampowered.com/app/3312030/AI_Learns_To_Drive/">game by ActivationFunction</a>. Built-in tracks are reconstructed replicas. Star challenges are not evaluated here.</p><p>Uploaded files are used for the requested run. Results are held briefly in application memory and deleted after verified download or expiry. Browser recipes contain settings only. <a href="/policy" target="_blank" rel="noopener">Read the data policy</a>.</p></details><p><a id="feedbackLink" href="https://github.com/seeker-cyber-maker/driving_sim_rs/issues/new" target="_blank" rel="noopener">Report a problem on GitHub</a> · Opens an editable draft and never submits it. Review details before posting; export a run receipt separately if it helps explain the issue.</p><p id="buildLabel" class="hint">Build: connecting…</p></header>
32
+ <nav class="tabs" aria-label="Lab sections"><button data-tab="drive" aria-selected="true">1 · Drive & view</button><button data-tab="training" aria-selected="false">2 · Training runs & presets</button><button data-tab="batch" aria-selected="false">3 · Batches</button><button data-tab="editor" aria-selected="false">4 · Network editor</button></nav><div id="drive" class="pane"><div class="grid"><section class="card"><h2>Simulation</h2><label for="run_mode">Action</label><select id="run_mode"><option value="train" selected>Train a population</option><option value="evaluate">Frozen evaluation · one policy, unchanged weights</option></select><p id="run_mode_help" class="hint">Training uses the selected recipe. Frozen evaluation runs the selected policy in a fresh car without changing its weights.</p><button id="run" disabled>Run training</button><p class="hint">Select or create a network below to enable training. Review settings on tab 2 before running.</p><p>Supports Formula, Rally, Snowmobile and Truck networks. Control outputs follow the original vehicle editor rules. Network shape and supported sensors are bounded by the original game contract.</p>
33
  <p id="computeBlock"><label for="backend">Compute</label><select id="backend" disabled><option value="cpu">CPU — no GPU allowance</option><option id="gpuOption" value="gpu" disabled>GPU — uses GPU allowance</option></select></p><p id="backendNote" class="hint">Checking available backends…</p>
34
  <div id="connectionRecovery" hidden><button id="retry" type="button">Reconnect to app</button><p class="hint">Checks server availability. Does not restart training, sign you in, or reset GPU allowance.</p></div>
35
  <div id="gpuAccount" aria-label="Optional GPU account"><p><button id="hfSignIn" type="button" disabled>Sign in with HF for account GPU allowance</button><span id="hfAccount" hidden></span> <button id="hfSignOut" type="button" disabled hidden>Disconnect account</button></p><p id="hfIdentity" role="status" class="hint">CPU needs no sign-in. Checking optional GPU account connection…</p>
 
44
  <p><button id="saveLocal" disabled>Download received result (browser only)</button> <button id="forgetLocal" disabled>Discard browser result</button></p>
45
  <p>The browser keeps the received result in memory until discarded, replaced by a new run, or this page is closed. It is not saved to browser storage.</p>
46
  <h3 id="runState" role="status" aria-live="polite">READY — no run started</h3>
47
+ <pre id="runSummary" style="white-space:pre-wrap;line-height:1.7" aria-live="polite"></pre>
48
+ <section id="analysisPanel" hidden aria-labelledby="analysisTitle">
49
+ <h2 id="analysisTitle">Winners and frozen evaluation</h2>
50
+ <p id="analysisStatus" role="status" aria-live="polite">No complete analysis is available yet.</p>
51
+ <div id="winnerTools" hidden>
52
+ <p class="hint">A network can fill more than one winner role. Select roles to compare; duplicate policies run once. Fitness is final-generation population-relative and is not compared across generations or policy evaluations.</p>
53
+ <div class="table-scroll"><table><thead><tr><th>Compare</th><th>Role</th><th>Candidate</th><th>Generation / car</th><th>Selection fitness</th><th>Best lap</th><th>Forward laps</th><th>Physical contacts</th><th>Distance (simulation units)</th><th>Action</th></tr></thead><tbody id="winnerRows"></tbody></table></div>
54
+ <p><label for="winner_continue">Network to continue or save</label><select id="winner_continue"><option value="">Primary fitness result</option></select> <button id="winner_use" type="button">Use selected network</button></p>
55
+ <div class="toolbar"><button id="compare_winners" type="button" disabled>Compare selected winners</button><button id="analysis_download" type="button" disabled>Download full analysis archive</button></div><p class="hint">Comparison starts one frozen evaluation per distinct selected policy, in sequence. It uses the currently selected track, vehicle, tick limit, control rate and collision/idle settings, then holds those conditions fixed for the full comparison.</p>
56
+ <details><summary>Inspect complete measurements for an evaluated car</summary><div class="row"><label for="descriptor_generation">Generation<input id="descriptor_generation" type="number" min="1" value="1"></label><label for="descriptor_car">Car index<input id="descriptor_car" type="number" min="0" value="0"></label></div><button id="descriptor_show" type="button" disabled>Show all descriptors</button><p id="descriptor_status" role="status"></p><div id="descriptor_tables"></div></details>
57
+ </div>
58
+ <div id="evaluationTools" hidden><p id="evaluationStatus" role="status" aria-live="polite"></p><p class="hint">Frozen evaluation does not select winners or compare population-relative fitness. Each comparison row keeps its network hash and must match the same track, vehicle, horizon, control rate, collision/idle rules, backend and engine build.</p><button id="compare_add" type="button">Add this frozen result to comparison</button><div class="table-scroll"><table><thead><tr><th>Policy</th><th>Vehicle</th><th>Track</th><th>Ticks</th><th>Best lap</th><th>Forward laps</th><th>Physical contacts</th><th>Distance (simulation units)</th><th>Comparison</th></tr></thead><tbody id="evaluationRows"></tbody></table></div><button id="compare_clear" type="button">Clear comparison</button></div>
59
+ </section>
60
+ <label class="keep-result"><input id="leaderboard_opt_in" type="checkbox"> After a completed lap, check its position against the public Steam leaderboard</label><p class="privacy-note">Optional, read-only check for supported built-in tracks. It sends the track ID and best lap to this app server; Steam receives only a public leaderboard read. No score is submitted. The result is an estimate, not an official rank.</p><p id="leaderboard_status" role="status" aria-live="polite"></p>
61
  <p><button id="run_export" disabled>Export run receipt</button> <button id="player_report_download" disabled>Download player report (.txt)</button> <button id="history_csv_download" disabled>Download generation history (.csv)</button></p><p class="hint">Reports use only server-reported run and generation fields. The engine does not report per-reward contribution, so reward weights are shown as configured values only.</p>
62
  <details id="portableBundle"><summary>Portable bucket bundle</summary><p>Build, export, or restore one validated bundle. Bucket storage is optional. When connected, automatic save is on by default; turn it off here to keep saves manual. Disconnected runs never call bucket storage.</p><label><input id="bundle_track" type="checkbox"> Include the selected track</label><label><input id="bundle_recipe" type="checkbox"> Include the recipe in the selected slot, if saved</label><label><input id="bundle_receipt" type="checkbox"> Include a sanitized receipt for the selected completed result</label><div class="toolbar"><button id="bundle_export" type="button">Export portable bundle</button><label for="bundle_import">Restore portable bundle (.json)<input id="bundle_import" type="file" accept=".json,application/json"></label><button id="bundle_receipt_download" type="button" disabled>Download staged receipt</button></div><p id="bundle_status" role="status" aria-live="polite">No bucket action has been requested.</p>
63
+ <details id="bucketStorage"><summary>Connect to an existing private bucket</summary><p>Bucket storage is optional. Authorizing storage also identifies your account for GPU requests; ordinary GPU sign-in remains available with the basic identity permission. Then enter your existing private bucket ID. Connected runs automatically save by default; uncheck this option to keep run saves manual. No personal token is requested or stored.</p><p id="bucket_scope_notice" class="privacy-note">HF describes the requested <code>write-repos</code> permission as read/write access to your personal repositories. That grant is broader than one selected bucket and is not a bucket-only permission; the app limits its own operations to the bucket you enter. The minimum current scope for bucket read/write has not been confirmed by HF.</p><button id="bucket_authorize" type="button" hidden disabled>Authorize bucket access with HF</button><p id="bucket_auth_unavailable" class="hint" hidden>HF bucket authorization is unavailable for this app. Local bundle export remains available.</p><p id="bucket_auth_status" role="status" aria-live="polite">Checking optional HF bucket authorization…</p><div id="bucketConnectForm" hidden><label for="bucket_id">Private bucket ID</label><input id="bucket_id" type="text" autocomplete="off" placeholder="username/bucket-name" disabled><button id="bucket_connect" type="button" disabled>Connect bucket</button></div><div id="bucketConnected" hidden><p id="bucket_account"></p><label class="keep-result"><input id="bucket_autosave" type="checkbox" checked> Automatically save every verified completed network and run log to this bucket</label><p class="hint">For each completed run, the browser fetches and verifies the network, then confirms server deletion before sending the network and sanitized log to your bucket. If deletion is not confirmed, no save occurs. If bucket saving is not confirmed, the verified local candidate stays available to download or retry; the remote save may have completed. Each batch step saves before the next starts, and a failed save stops the batch.</p><button id="bucket_disconnect" type="button">Disconnect bucket and clear HF authorization</button><div class="toolbar"><button id="bucket_save" type="button" disabled>Save selected bundle</button><button id="bucket_refresh" type="button" disabled>Refresh saved bundles</button></div><label for="bucket_items">Saved bundles from this app</label><select id="bucket_items" size="4" disabled><option value="">Refresh to list saved bundles</option></select><button id="bucket_restore" type="button" disabled>Restore selected bundle</button><hr><h3 id="bucketRecipeTitle" tabindex="-1">Recipe library (settings only)</h3><p class="hint">Save and import the validated recipes and batch definitions from this browser. This 32 KB library contains settings only; networks, tracks, run results and access tokens are never included. Import asks before replacing the local recipe library and never starts a run.</p><div class="toolbar"><button id="bucket_recipe_save" type="button" disabled>Save recipe library</button><button id="bucket_recipe_refresh" type="button" disabled>Refresh saved recipe libraries</button></div><label for="bucket_recipe_items">Saved recipe libraries</label><select id="bucket_recipe_items" size="4" disabled><option value="">Refresh to list saved recipe libraries</option></select><button id="bucket_recipe_import" type="button" disabled>Import selected recipe library</button><p id="bucket_recipe_status" role="status" aria-live="polite">No recipe library bucket action requested.</p></div><p id="bucket_status" role="status" aria-live="polite">Not connected. Bucket storage is optional.</p></details></details>
64
  <p id="deadline"></p><p id="status" role="status">Ready. Inputs remain on this device until you run the test.</p>
65
  <details><summary>Effective training settings</summary><pre id="effectiveSettings"></pre></details><details><summary>Generation results</summary><pre id="generationHistory"></pre></details><details><summary>Star challenges — not evaluated</summary><p>The current runner has star-challenge reporting. Its evaluator and required statistics have not been ported to HF; no star result is inferred from a completed test.</p></details></section></div>
66
  </div><section id="training" class="pane" hidden><div class="card"><h2>Training settings</h2><p class="hint">Choose the track on Drive & view. Choose the training settings and reward mix here, then start the run on Drive & view. Recipes and batch sequences are saved in this browser.</p><p><label for="trackPresetTraining">Training track</label><select id="trackPresetTraining" data-track-library disabled><option value="">Upload a custom track</option></select></p><p data-track-status role="status" class="hint">Loading built-in replica tracks…</p><div id="training_controls"><label for="training_ticks">Simulated ticks per generation</label><input id="training_ticks" type="number" min="1" max="18000" value="60"><small>60 ticks = one simulated second. Current preview limit: population × generations × ticks ≤ 4,000,000; execution timeout 25 seconds.</small>
 
81
  <label for="reward_preset">Reward preset</label><select id="reward_preset" disabled><option value="custom">Custom</option><option value="distance">First laps · distance only</option><option value="center">Example mix · center, clearance, collisions</option><option value="clean">Clean driving · distance + collision penalty</option><option value="lap">Lap refinement · best lap only</option></select>
82
  <div id="rewards"></div><button id="add" disabled>+ Add reward</button><small>Weights −100…+100; absolute shares normalize to 100%. Five distinct metrics maximum. The current vehicle runner accepts these metrics; choose them for the behavior you want to train.</small>
83
  </details></div>
84
+ <details id="run_settings"><summary>Run settings · timing and elimination</summary><div class="row"><div><label for="control_hz">Decisions per second</label><select id="control_hz" disabled><option>60</option><option>30</option><option>15</option></select></div><div><label for="workers">Parallel simulations</label><select id="workers" disabled><option>1</option><option selected>2</option><option>3</option><option>4</option></select></div></div><small>Physics runs at 60 Hz. CPU jobs can use 1–4 worker threads.</small><div id="seed_group"><label for="seed">Random seed</label><input id="seed" type="number" min="0" max="2147483647" value="42105" disabled></div><label><input id="collision" type="checkbox" disabled> Eliminate on wall contact</label><label><input id="idle" type="checkbox" disabled> Eliminate when stuck</label><label for="clean_min_laps">Minimum forward laps for a clean-lap winner</label><input id="clean_min_laps" type="number" min="1" max="100" value="2"><small>Winner classification only. Zero physical contacts are required. This does not change your reward recipe.</small></details></div><div id="libraries" class="card"><h3>Recipes · A–J · saved in this browser</h3>
85
  <p>Each recipe contains the training settings and reward mix. Tracks, networks and results stay out of the recipe library. Saving a recipe sends nothing to the server. Settings are sent when you start a run.</p>
86
  <div class="row"><label for="recipe_slot">Recipe slot<select id="recipe_slot"><option>A</option><option>B</option><option>C</option><option>D</option><option>E</option><option>F</option><option>G</option><option>H</option><option>I</option><option>J</option></select></label><label for="recipe_name">Recipe name<input id="recipe_name" maxlength="80" value="My recipe"></label></div>
87
  <button id="recipe_save">Save current settings</button> <button id="recipe_load">Load recipe</button>
88
+ <p><button id="recipe_export">Export recipe library</button> <label for="recipe_import">Import recipe library (.json)<input id="recipe_import" type="file" accept=".json"></label> <button id="recipe_bucket_nav" type="button">Bucket import / export</button></p>
89
  <button id="recipe_clear">Clear saved recipes and batches on this device</button><p id="recipe_feedback" role="status" aria-live="polite"></p>
90
+ <p>Recipes are specific to this browser and site. Clearing site data removes them. Export a backup to move them between the local runner and HF, or use the connected private bucket controls on Drive &amp; view. Browser privacy settings may prevent local saving.</p>
91
  <details><summary>Advanced recipe JSON</summary><textarea id="recipe_draft" rows="12" style="width:100%" aria-label="Recipe settings JSON"></textarea><button id="recipe_apply_draft">Apply JSON to form</button></details></div></section>
92
  <section id="batch" class="pane" hidden><div class="card"><h2>Training batches · A–J</h2><p>Choose files and CPU/GPU on Drive & view, then start a saved batch here. Up to 20 runs and 4,000,000 vehicle-ticks in total. Recipes freeze at start. Each server result is automatically deleted after verified receipt. The last candidate is kept in browser memory. Stop prevents the next run; the current run finishes.</p><label for="batch_slot">Batch slot</label><select id="batch_slot" disabled><option>A</option><option>B</option><option>C</option><option>D</option><option>E</option><option>F</option><option>G</option><option>H</option><option>I</option><option>J</option></select><label for="batch_name">Display name</label><input id="batch_name" type="text" maxlength="80" value="My curriculum" disabled><label><input id="carry" type="checkbox" checked disabled> Continue from the previous run’s candidate</label><p class="hint">Unchecked: each run starts from the original selected network. Repeated run references are allowed. Recipes freeze when the batch starts.</p><label for="batch_recipe_sequence">Recipe sequence (comma-separated slots)</label><input id="batch_recipe_sequence" value="A" maxlength="300"><label for="batch_add_recipe">Add recipe<select id="batch_add_recipe"><option>A</option><option>B</option><option>C</option><option>D</option><option>E</option><option>F</option><option>G</option><option>H</option><option>I</option><option>J</option></select></label><button id="queue_add" disabled>+ Add run</button><div class="toolbar"><button id="batch_save" disabled>Save batch</button><button id="batch_load" disabled>Load batch</button><button class="primary" id="batch_start" disabled>Start saved batch</button><button id="batch_stop" disabled>Stop after current run</button><button id="batch_export" disabled>Export batch receipt</button></div><div id="batch_status" role="status">No batch selected.</div><div id="batch_history"></div><div id="batches_feedback" role="status" aria-live="polite"></div></div></section>
93
  <section id="editor" class="pane" hidden><div class="card"><h2>Network editor</h2><p class="hint">An original editor inspired by <a href="https://the-mars-guy.github.io/AI-Learns-To-Drive-Tool/" target="_blank" rel="noopener">Mars’s network creator</a>. Create fresh networks with the selected vehicle’s game-legal controls, or use its largest original-game shape. Loaded networks keep their original vehicle, output order and sensor parameters. Unsupported files can still be inspected without changing the original.</p><div class="toolbar"><label for="editor_file">Load an existing .evolution file</label><input id="editor_file" type="file" accept=".evolution"><button id="editor_import" disabled>Inspect selected network</button><button id="editor_create" disabled>Create fresh network</button><button id="editor_apply" disabled>Use edited network</button><a id="editor_download" hidden download>Download .evolution</a></div><div class="row"><div><label for="editor_name">Network name</label><input id="editor_name" type="text" maxlength="80" value="Fresh network" disabled><label for="editor_vehicle">Vehicle type for fresh networks</label><select id="editor_vehicle" disabled><option value="0">Formula</option><option value="1">Rally</option><option value="2">Snowmobile</option><option value="3">Truck</option></select><small>This selector applies only when you create a fresh network. Imported vehicle type is retained and shown with the current network.</small><label for="editor_preset">Fresh network preset</label><select id="editor_preset" disabled><option id="editor_small_label" value="small">Small starter · 6 inputs, 4 hidden nodes, 5 outputs</option><option id="editor_maximum_label" value="maximum">Largest game network · 20 inputs, 10 × 16 hidden nodes, 5 outputs</option><option value="custom">Custom</option></select><label for="editor_shape">Input and hidden layer sizes</label><input id="editor_shape" type="text" value="6,4" disabled><small id="editor_shape_hint">Enter 1–20 inputs, followed by 0–10 hidden layers of 1–16 nodes. Output width follows the selected controls. Maximum shape: 20 → ten layers of 16 → 5 (2,869 parameters).</small><fieldset id="editor_outputs" disabled><legend id="editor_output_legend">Fresh network outputs</legend><div class="output-options"><label><input id="editor_output_0" type="checkbox" value="0" checked disabled> Steering (0)</label><label><input id="editor_output_1" type="checkbox" value="1" checked disabled> Acceleration (1)</label><label><input id="editor_output_2" type="checkbox" value="2" checked disabled> Handbrake (2)</label><label><input id="editor_output_3" type="checkbox" value="3" checked disabled> Brake (3)</label><label><input id="editor_output_4" type="checkbox" value="4" checked disabled> Boost (4)</label></div><small id="editor_output_hint">Steering and Acceleration are required. Fresh output IDs preserve this order.</small></fieldset></div><div><label for="editor_seed">Initialization seed</label><input id="editor_seed" type="number" min="0" max="2147483647" value="42105" disabled><label><input id="editor_xavier" type="checkbox" checked disabled> Seeded Xavier uniform weights (browser algorithm)</label><label><input id="editor_bias" type="checkbox" disabled> Xavier uniform biases</label><small>Unchecked: zeros. Biases default to zero.</small></div></div><p id="editor_status" role="status">Choose a file or create a fresh network. Editor changes are browser-local until you explicitly start a run.</p><p id="editor_metadata" class="hint"></p><p id="editor_legality" class="hint"></p><details id="editor_history_panel"><summary>Training history · recorded generations</summary><div class="table-scroll"><table><thead><tr><th>Record</th><th>Time</th><th>Mutation</th><th>Best score</th><th>Best lap</th></tr></thead><tbody id="editor_history"></tbody></table></div><details><summary>Full saved training metadata</summary><pre id="editor_stats"></pre></details></details><canvas aria-label="Network architecture preview" id="network_map" width="1100" height="450"></canvas><details id="weight_values"><summary>Individual weight and bias values</summary><div class="table-scroll" style="max-height:420px;overflow:auto"><table><thead><tr><th>Connection / bias</th><th>Value</th></tr></thead><tbody id="editor_parameters"></tbody></table></div></details><details><summary>Weights, biases and input contract</summary><pre id="editor_contract"></pre><label for="editor_json">NeuralNetwork JSON · editable weights and biases</label><textarea id="editor_json" rows="16" style="width:100%;box-sizing:border-box;background:#0c1725;color:#e5edf5" disabled></textarea></details></div></section><script>/* Settings-only recipe contract. Storage is injected; no network or job data. */
 
342
  try{store=api.createStore(window.localStorage);store.load();message('Browser-local library ready.');}
343
  catch(error){message('Local library unavailable: '+error.message+'. Existing data was not changed.');}
344
  const read=()=>{if(!store)throw Error('Browser storage unavailable');return store.load();};
345
+ window.recipeLibraryText=()=>{const text=JSON.stringify(api.validate(read()));if(new TextEncoder().encode(text).byteLength>32768)throw Error('Recipe library exceeds 32 KB');return text;};
346
+ window.importRecipeLibraryText=async(text,{source='selected file',isCurrent=()=>true}={})=>{
347
+ const book=api.parse(text);
348
+ if(!(await window.appConfirm('Replace this browser’s recipe library with '+book.recipes.length+' recipes and '+book.batches.length+' batches from '+source+'? Export a backup first if needed.',{heading:'Import recipe library',confirmLabel:'Replace library'})))return false;
349
+ if(!isCurrent())throw Error('Bucket connection changed before import; the local recipe library was not changed.');
350
+ if(!store)throw Error('Browser storage unavailable');store.save(book);
351
+ message('Imported '+book.recipes.length+' recipes and '+book.batches.length+' batches from '+source+'. No run was started.');return true;
352
+ };
353
  window.getBatchPlan=()=>window.BatchTraining.plan(read(),q('batch_slot').value);
354
  const action=fn=>async()=>{try{await fn();}catch(error){message('Not completed: '+error.message);}};
355
  q('recipe_apply_draft').onclick=action(()=>{applySettings(JSON.parse(q('recipe_draft').value));q('preset').value='custom';q('reward_preset').value='custom';message('Draft applied to the training form. No run started.');});
 
371
  });
372
  q('recipe_import').onchange=action(async()=>{
373
  const input=q('recipe_import'),file=input.files[0];if(!file)return;input.value='';if(file.size>32768)throw Error('Recipe file exceeds 32 KB');
374
+ await window.importRecipeLibraryText(await file.text(),{source:'selected file'});q('recipe_import').value='';
 
 
 
375
  });
376
  q('recipe_clear').onclick=action(async()=>{
377
  if(!(await window.appConfirm('Clear saved recipes and batches for this app on this device? Server run data and downloaded files are unaffected.',{heading:'Clear local recipes and batches',confirmLabel:'Clear local data',danger:true})))return;
 
486
  if(typeof module==='object'&&module.exports)module.exports=factory();
487
  else root.PortableBundle=factory();
488
  })(typeof window==='undefined'?globalThis:window,function(){
489
+ const SCHEMA='ald.portable-bundle',LIMITS={network:4*1024*1024,track:1024*1024,recipe:32768,receipt:524288,analysis:32*1024*1024,bundleV1:8*1024*1024,bundle:48*1024*1024};
490
  const exact=(value,keys)=>{
491
  if(!value||typeof value!=='object'||Array.isArray(value)||Object.keys(value).sort().join()!==[...keys].sort().join())throw Error('Unexpected portable bundle fields');
492
  };
 
494
  const sha=async bytes=>Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256',bytes)),b=>b.toString(16).padStart(2,'0')).join('');
495
  function b64(bytes){let text='';for(let i=0;i<bytes.length;i+=8192)text+=String.fromCharCode(...bytes.subarray(i,i+8192));return btoa(text);}
496
  function unb64(text){
497
+ if(typeof text!=='string'||text.length>Math.ceil(LIMITS.analysis/3)*4+8)throw Error('Invalid portable artifact encoding');
498
  let binary;try{binary=atob(text);}catch{throw Error('Invalid portable artifact encoding');}
499
  const bytes=Uint8Array.from(binary,c=>c.charCodeAt(0));if(b64(bytes)!==text)throw Error('Non-canonical portable artifact encoding');return bytes;
500
  }
 
504
  if(!data.length||data.byteLength>limit)throw Error(format==='evolution'?'Network exceeds 4 MiB bundle limit':'Track exceeds 1 MiB bundle limit');
505
  return {format,kind,bytes:data.byteLength,sha256:await sha(data),data_base64:b64(data)};
506
  }
507
+ async function analysisSlot(bytes){const data=bytes instanceof Uint8Array?bytes:new Uint8Array(bytes);if(!data.length||data.length>LIMITS.analysis)throw Error('Analysis archive exceeds 32 MiB');return {format:'ald.run-analysis-archive+gzip',bytes:data.length,sha256:await sha(data),data_base64:b64(data)};}
508
  async function pack(artifacts){
509
  if(!artifacts||!artifacts.selected_network)throw Error('A selected network is required');
510
+ const version=artifacts.analysis_archive?2:1;
511
+ if(version===2){const a=artifacts.analysis_archive;exact(a,['format','bytes','sha256','data_base64']);if(a.format!=='ald.run-analysis-archive+gzip'||!Number.isInteger(a.bytes)||a.bytes<1||a.bytes>LIMITS.analysis||!/^[a-f0-9]{64}$/.test(a.sha256))throw Error('Invalid analysis archive slot');const bytes=unb64(a.data_base64);if(bytes.length!==a.bytes||await sha(bytes)!==a.sha256)throw Error('Analysis archive bytes or SHA-256 do not match');}
512
+ const clean={schema:SCHEMA,version,artifacts};
513
+ const text=JSON.stringify(clean,null,2);if(utf8(text).byteLength>(version===1?LIMITS.bundleV1:LIMITS.bundle))throw Error('Portable bundle exceeds its version size limit');
514
  return text;
515
  }
516
  async function parse(text){
517
+ if(typeof text!=='string'||utf8(text).byteLength>LIMITS.bundle)throw Error('Portable bundle exceeds 48 MiB');
518
  let bundle;try{bundle=JSON.parse(text);}catch{throw Error('Portable bundle is not valid JSON');}
519
+ exact(bundle,['schema','version','artifacts']);if(bundle.schema!==SCHEMA||![1,2].includes(bundle.version))throw Error('Unsupported portable bundle version');
520
+ if(bundle.version===1&&utf8(text).byteLength>LIMITS.bundleV1)throw Error('Version 1 portable bundle exceeds 8 MiB');
521
+ const allowed=['selected_network','selected_track','selected_recipe','run_receipt',...(bundle.version===2?['analysis_archive']:[])];
522
  if(!bundle.artifacts||typeof bundle.artifacts!=='object'||Array.isArray(bundle.artifacts)||Object.keys(bundle.artifacts).some(k=>!allowed.includes(k))||!bundle.artifacts.selected_network)throw Error('Portable bundle needs a selected network and fixed artifact slots');
523
  const a=bundle.artifacts,networkMeta=a.selected_network,networkBytes=await checkArtifact(networkMeta,'evolution',['format','kind','bytes','sha256','data_base64'],LIMITS.network);
524
  if(!['input','result'].includes(networkMeta.kind))throw Error('Invalid selected network kind');
525
+ let track=null,recipe=null,receipt=null,analysisArchive=null;
526
  if(Object.hasOwn(a,'selected_track')){
527
  const t=a.selected_track;if(!t||typeof t!=='object')throw Error('Invalid selected track');
528
  const builtin=t.source==='builtin';
 
543
  const text=JSON.stringify(r);if(utf8(text).byteLength>LIMITS.receipt)throw Error('Run receipt exceeds 512 KiB bundle limit');
544
  receipt=r;
545
  }
546
+ if(Object.hasOwn(a,'analysis_archive')){
547
+ if(bundle.version!==2)throw Error('Analysis archive requires portable bundle version 2');
548
+ const x=a.analysis_archive;exact(x,['format','bytes','sha256','data_base64']);if(x.format!=='ald.run-analysis-archive+gzip'||!Number.isInteger(x.bytes)||x.bytes<1||x.bytes>LIMITS.analysis||!/^[a-f0-9]{64}$/.test(x.sha256))throw Error('Invalid analysis archive metadata');
549
+ const bytes=unb64(x.data_base64);if(bytes.length!==x.bytes||await sha(bytes)!==x.sha256)throw Error('Analysis archive bytes or SHA-256 do not match');analysisArchive={...x,bytes};
550
+ }
551
+ return {bundle,network:{...networkMeta,bytes:networkBytes},track,recipe,receipt,analysisArchive};
552
  }
553
  async function checkArtifact(value,format,keys,limit){
554
  exact(value,keys);if(value.format!==format||!Number.isInteger(value.bytes)||value.bytes<1||value.bytes>limit||! /^[a-f0-9]{64}$/.test(value.sha256))throw Error('Invalid '+format+' artifact metadata');
555
  const bytes=unb64(value.data_base64);if(bytes.byteLength!==value.bytes||await sha(bytes)!==value.sha256)throw Error(format+' bytes or SHA-256 do not match');
556
  return bytes;
557
  }
558
+ return {SCHEMA,LIMITS,sha,artifact,analysisSlot,pack,parse};
559
  });</script><script src="/client.js"></script><script src="/hf-auth.js"></script><script>'use strict';
560
  // Navigation only. Editor and training/storage adapters are separate layers.
561
  document.querySelectorAll('[data-tab]').forEach(button=>button.addEventListener('click',()=>{
562
  document.querySelectorAll('.pane').forEach(pane=>pane.hidden=pane.id!==button.dataset.tab);
563
  document.querySelectorAll('[data-tab]').forEach(tab=>tab.setAttribute('aria-selected',String(tab===button)));
564
  }));
565
+ document.getElementById('recipe_bucket_nav').addEventListener('click',()=>{
566
+ document.querySelector('[data-tab="drive"]').click();
567
+ document.getElementById('portableBundle').open=true;
568
+ const storage=document.getElementById('bucketStorage');storage.open=true;
569
+ const connected=document.getElementById('bucketConnected');
570
+ const recipeTitle=document.getElementById('bucketRecipeTitle');
571
+ const target=connected&&!connected.hidden&&recipeTitle?recipeTitle:storage.querySelector('summary');
572
+ target?.scrollIntoView({behavior:'smooth',block:'center'});target?.focus({preventScroll:true});
573
+ });
574
  </script><script>// Browser-only editor; upload is controlled by the separate training client.
575
  (() => {
576
  const q=id=>document.getElementById(id),api=window.NetworkEditor;
native/analysis.patch ADDED
@@ -0,0 +1,607 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ --- a/src/bin/altd-sim/args.rs
2
+ +++ b/src/bin/altd-sim/args.rs
3
+ @@ -290,6 +290,15 @@
4
+ /// Export the final complete batch fitness leader before reproduction.
5
+ #[arg(long)]
6
+ save_final_candidate: bool,
7
+ + /// Record every evaluated car and the three distinct winner roles.
8
+ + #[arg(long)]
9
+ + analysis: bool,
10
+ + /// Evaluate the exact init-network once without initializing an optimizer population.
11
+ + #[arg(long, requires = "init_network")]
12
+ + evaluate_frozen: bool,
13
+ + /// Forward laps required for the zero-physical-contact winner.
14
+ + #[arg(long, default_value_t = 2)]
15
+ + clean_min_laps: u64,
16
+ /// Stop after a generation whose best total_score reaches this value.
17
+ #[arg(long)]
18
+ stop_score_above: Option<f64>,
19
+ --- a/src/bin/altd-sim/main.rs
20
+ +++ b/src/bin/altd-sim/main.rs
21
+ @@ -161,6 +161,9 @@
22
+ batch_count,
23
+ checkpoint_every,
24
+ save_final_candidate,
25
+ + analysis,
26
+ + evaluate_frozen,
27
+ + clean_min_laps,
28
+ resume,
29
+ eliminate_on_wall,
30
+ no_eliminate_on_wall,
31
+ @@ -203,6 +206,9 @@
32
+ batch_count,
33
+ checkpoint_every,
34
+ save_final_candidate,
35
+ + analysis,
36
+ + evaluate_frozen,
37
+ + clean_min_laps,
38
+ resume,
39
+ stop,
40
+ eliminate_on_wall: flag(eliminate_on_wall, no_eliminate_on_wall),
41
+ --- a/src/bin/altd-sim/scratch/analysis.rs
42
+ +++ b/src/bin/altd-sim/scratch/analysis.rs
43
+ @@ -0,0 +1,253 @@
44
+ +//! Read-only generation-boundary observations and immutable winner snapshots.
45
+ +use altd_sim::training::{
46
+ + evolution::{reward_descriptors, METRIC_NAMES},
47
+ + TrainingRunner,
48
+ +};
49
+ +use serde_json::{json, Value};
50
+ +use std::{
51
+ + fs::File,
52
+ + io::{BufWriter, Write},
53
+ + path::{Path, PathBuf},
54
+ +};
55
+ +
56
+ +const MAX_DESCRIPTOR_BYTES: usize = 256 * 1024 * 1024;
57
+ +
58
+ +pub(super) struct Analysis {
59
+ + dir: PathBuf,
60
+ + output: BufWriter<File>,
61
+ + bytes: usize,
62
+ + rows: usize,
63
+ + clean_min_laps: u64,
64
+ + frozen: bool,
65
+ + fitness: Option<Value>,
66
+ + fastest: Option<Value>,
67
+ + clean: Option<Value>,
68
+ + evaluation: Option<Value>,
69
+ +}
70
+ +
71
+ +fn weight_hash(params: &[f64]) -> String {
72
+ + let bytes: Vec<u8> = params.iter().flat_map(|p| p.to_le_bytes()).collect();
73
+ + altd_sim::training::evaluation::sha256(&bytes)
74
+ +}
75
+ +
76
+ +fn eligible(row: &Value, clean_min_laps: Option<u64>) -> bool {
77
+ + row["objectives"]["best_lap_seconds"]
78
+ + .as_f64()
79
+ + .is_some_and(|v| v.is_finite() && v > 0.0)
80
+ + && row["objectives"]["forward_laps"]
81
+ + .as_u64()
82
+ + .is_some_and(|laps| laps >= clean_min_laps.unwrap_or(1))
83
+ + && (clean_min_laps.is_none() || row["objectives"]["physical_contacts"].as_u64() == Some(0))
84
+ +}
85
+ +
86
+ +fn better_lap(row: &Value, previous: Option<&Value>) -> bool {
87
+ + previous.is_none_or(|old| {
88
+ + row["objectives"]["best_lap_seconds"].as_f64().unwrap()
89
+ + < old["objectives"]["best_lap_seconds"].as_f64().unwrap()
90
+ + })
91
+ +}
92
+ +
93
+ +impl Analysis {
94
+ + pub(super) fn new(dir: &Path, clean_min_laps: u64, frozen: bool) -> Self {
95
+ + assert!((1..=100).contains(&clean_min_laps), "clean minimum laps must be 1..100");
96
+ + let dir = dir.join("analysis");
97
+ + std::fs::create_dir_all(dir.join("winners")).expect("analysis directory");
98
+ + let output = BufWriter::new(File::create(dir.join("descriptors.jsonl")).expect("analysis descriptors"));
99
+ + Self {
100
+ + dir,
101
+ + output,
102
+ + bytes: 0,
103
+ + rows: 0,
104
+ + clean_min_laps,
105
+ + frozen,
106
+ + fitness: None,
107
+ + fastest: None,
108
+ + clean: None,
109
+ + evaluation: None,
110
+ + }
111
+ + }
112
+ +
113
+ + fn snapshot(&self, role: &str, row: &Value, runner: &TrainingRunner, index: usize, network_data: &Value) {
114
+ + let mut network = runner.agents[index].network.to_json();
115
+ + network["inputs"] = network_data["inputs"].clone();
116
+ + network["outputs"] = network_data["outputs"].clone();
117
+ + network["observation"] = row.clone();
118
+ + std::fs::write(
119
+ + self.dir.join(format!("winners/{role}.json")),
120
+ + serde_json::to_vec(&network).unwrap(),
121
+ + )
122
+ + .expect("immutable selected weight snapshot");
123
+ + }
124
+ +
125
+ + pub(super) fn record(&mut self, runner: &TrainingRunner, generation: usize, fitness: &[f64], network_data: &Value) {
126
+ + assert_eq!(fitness.len(), runner.agents.len());
127
+ + let channels = reward_descriptors(&runner.results());
128
+ + assert_eq!(channels.len(), 18);
129
+ + let leader = if self.frozen {
130
+ + 0
131
+ + } else {
132
+ + super::runner::final_candidate_leader(fitness)
133
+ + };
134
+ + for (index, agent) in runner.agents.iter().enumerate() {
135
+ + let raw: serde_json::Map<String, Value> = METRIC_NAMES
136
+ + .iter()
137
+ + .zip(agent.stats.metrics())
138
+ + .map(|(name, value)| ((*name).into(), json!(value.filter(|v| v.is_finite()))))
139
+ + .collect();
140
+ + let channel_values: Vec<Value> = channels.iter().map(|c| c["values"][index].clone()).collect();
141
+ + let channel_ranks: Vec<Value> = channels
142
+ + .iter()
143
+ + .map(|c| c["population_relative"][index].clone())
144
+ + .collect();
145
+ + let reasons: Vec<Value> = channels
146
+ + .iter()
147
+ + .map(|column| {
148
+ + if !column["values"][index].is_null() {
149
+ + Value::Null
150
+ + } else if column["metric"] == "best_lap_performance" {
151
+ + json!("no_completed_lap")
152
+ + } else if column["type"] == "average" && agent.stats.update_count == 0 {
153
+ + json!("no_statistics_updates")
154
+ + } else {
155
+ + json!("not_observed")
156
+ + }
157
+ + })
158
+ + .collect();
159
+ + let row = json!({"schema":1, "generation":generation + 1, "car":index,
160
+ + "candidate_id":format!("g{:06}-c{:06}", generation + 1, index),
161
+ + "ticks_elapsed":runner.tick, "stats_update_count":agent.stats.update_count,
162
+ + "raw_metrics":raw, "channels":{"values":channel_values, "population_relative":channel_ranks, "unavailable_reasons":reasons},
163
+ + "selection_fitness":if self.frozen {Value::Null} else {json!(fitness[index])},
164
+ + "objectives":{"best_lap_seconds":agent.stats.best_lap_time.filter(|v| v.is_finite() && *v > 0.0),
165
+ + "forward_laps":agent.stats.score.lap_count, "physical_contacts":agent.car.collision_count,
166
+ + "collision_updates":agent.stats.collision_count, "total_actual_distance":agent.stats.total_actual_distance,
167
+ + "active":agent.car.active},
168
+ + "weights_sha256":weight_hash(&agent.network.params)});
169
+ + let text = serde_json::to_vec(&row).expect("finite descriptor");
170
+ + self.bytes += text.len() + 1;
171
+ + assert!(
172
+ + self.bytes <= MAX_DESCRIPTOR_BYTES,
173
+ + "complete analysis exceeds 256 MiB; no descriptors truncated"
174
+ + );
175
+ + self.output
176
+ + .write_all(&text)
177
+ + .and_then(|_| self.output.write_all(b"\n"))
178
+ + .expect("descriptor write");
179
+ + self.rows += 1;
180
+ + if self.frozen {
181
+ + self.evaluation = Some(row);
182
+ + continue;
183
+ + }
184
+ + // Replacing only on a strictly better lap retains the earliest generation/car tie.
185
+ + if eligible(&row, None) && better_lap(&row, self.fastest.as_ref()) {
186
+ + self.snapshot("fastest_lap", &row, runner, index, network_data);
187
+ + self.fastest = Some(row.clone());
188
+ + }
189
+ + if eligible(&row, Some(self.clean_min_laps)) && better_lap(&row, self.clean.as_ref()) {
190
+ + self.snapshot("clean_laps", &row, runner, index, network_data);
191
+ + self.clean = Some(row.clone());
192
+ + }
193
+ + // Fitness ranks belong to this population. Always overwrite with this generation's leader.
194
+ + if index == leader {
195
+ + self.snapshot("fitness", &row, runner, index, network_data);
196
+ + self.fitness = Some(row);
197
+ + }
198
+ + }
199
+ + self.output.flush().expect("flush complete descriptors before turnover");
200
+ + }
201
+ +
202
+ + pub(super) fn finish(mut self) {
203
+ + self.output.flush().expect("flush analysis");
204
+ + let data = json!({"schema":"ald.native-analysis", "version":1, "rows":self.rows,
205
+ + "mode":if self.frozen {"evaluate"} else {"train"}, "clean_min_laps":self.clean_min_laps,
206
+ + "winners":{"fitness":self.fitness, "fastest_lap":self.fastest, "clean_laps":self.clean},
207
+ + "evaluation":self.evaluation, "metric_names":METRIC_NAMES});
208
+ + std::fs::write(self.dir.join("analysis.json"), serde_json::to_vec(&data).unwrap()).expect("analysis summary");
209
+ + }
210
+ +}
211
+ +
212
+ +#[cfg(test)]
213
+ +#[path = "../../../../examples/support/generated.rs"]
214
+ +mod generated;
215
+ +
216
+ +#[cfg(test)]
217
+ +mod tests {
218
+ + use super::*;
219
+ + fn row(laps: u64, contacts: u64, lap: Option<f64>) -> Value {
220
+ + json!({"objectives":{"forward_laps":laps,"physical_contacts":contacts,"best_lap_seconds":lap}})
221
+ + }
222
+ + #[test]
223
+ + fn distinct_roles_keep_global_laps_but_final_population_fitness_and_old_weights() {
224
+ + use altd_sim::{
225
+ + math::profile::MathProfile,
226
+ + nn::network::Network,
227
+ + track::world::{vector, World},
228
+ + training::{evolution::EvolutionSettings, pyrandom::PyRandom, SensorLayout},
229
+ + };
230
+ + use std::sync::Arc;
231
+ + let scene = generated::scene("formula", 0);
232
+ + let network_data = json!({"inputs":["Speed"], "outputs":["Acceleration"]});
233
+ + let layout = SensorLayout::from_ordered(&json!({"names":["Speed"], "sensors":[{"$type":"speed"}]})).unwrap();
234
+ + let mut runner = TrainingRunner::new(
235
+ + Arc::new(World::from_scene_with(&scene, MathProfile::Proton)),
236
+ + vector(&scene["reset_position"]),
237
+ + scene["reset_rotation"].as_f64().unwrap(),
238
+ + layout,
239
+ + &["Acceleration".into()],
240
+ + EvolutionSettings::default(),
241
+ + PyRandom::new(42),
242
+ + 2,
243
+ + 0,
244
+ + false,
245
+ + false,
246
+ + );
247
+ + let networks: Vec<_> = (0..3)
248
+ + .map(|i| Network::from_vector(&[1, 1], vec![i as f64, 0.0]))
249
+ + .collect();
250
+ + runner.resume(&networks, 0);
251
+ + for (i, agent) in runner.agents.iter_mut().enumerate() {
252
+ + agent.stats.update_count = 10;
253
+ + agent.stats.best_lap_time = [None, Some(5.0), Some(8.0)][i];
254
+ + agent.stats.score.lap_count = [0, 1, 2][i];
255
+ + // Reward collision updates cannot disqualify a physically clean car.
256
+ + agent.stats.collision_count = if i == 2 { 9 } else { 0 };
257
+ + }
258
+ + let dir = std::env::temp_dir().join(format!("ald-distinct-winners-{}", std::process::id()));
259
+ + std::fs::create_dir(&dir).unwrap();
260
+ + let mut writer = Analysis::new(&dir, 2, false);
261
+ + writer.record(&runner, 0, &[0.9, 0.7, 0.1], &network_data);
262
+ + assert_eq!(writer.clean.as_ref().unwrap()["car"], 2);
263
+ + for (i, agent) in runner.agents.iter_mut().enumerate() {
264
+ + agent.network.params = vec![10.0 + i as f64, 0.0];
265
+ + agent.stats.best_lap_time = [Some(6.0), Some(5.0), None][i];
266
+ + agent.stats.score.lap_count = [2, 2, 0][i];
267
+ + agent.car.collision_count = if i == 0 { 1 } else { 0 };
268
+ + }
269
+ + writer.record(&runner, 1, &[0.0, 0.5, 0.2], &network_data);
270
+ + assert_eq!(writer.fastest.as_ref().unwrap()["generation"], 1);
271
+ + assert_eq!(writer.fastest.as_ref().unwrap()["car"], 1);
272
+ + assert_eq!(writer.clean.as_ref().unwrap()["generation"], 2);
273
+ + assert_eq!(writer.fitness.as_ref().unwrap()["generation"], 2);
274
+ + assert_eq!(writer.fitness.as_ref().unwrap()["selection_fitness"], 0.5);
275
+ + let fastest: Value =
276
+ + serde_json::from_slice(&std::fs::read(dir.join("analysis/winners/fastest_lap.json")).unwrap()).unwrap();
277
+ + assert_eq!(fastest["weights"][0][0][0], 1.0);
278
+ + writer.finish();
279
+ + std::fs::remove_dir_all(dir).unwrap();
280
+ + }
281
+ +
282
+ + #[test]
283
+ + fn clean_requires_forward_laps_and_physical_contacts_not_reward_updates() {
284
+ + assert!(!eligible(&row(0, 0, None), None));
285
+ + assert!(eligible(&row(1, 3, Some(10.0)), None));
286
+ + assert!(!eligible(&row(1, 0, Some(10.0)), Some(2)));
287
+ + assert!(!eligible(&row(2, 1, Some(10.0)), Some(2)));
288
+ + assert!(eligible(&row(2, 0, Some(10.0)), Some(2)));
289
+ + }
290
+ + #[test]
291
+ + fn lap_ties_keep_earlier_snapshot_and_rank_fitness_is_not_a_tiebreaker() {
292
+ + let old = row(2, 0, Some(10.0));
293
+ + assert!(!better_lap(&old, Some(&old)));
294
+ + assert!(better_lap(&row(2, 0, Some(9.0)), Some(&old)));
295
+ + }
296
+ +}
297
+ --- a/src/bin/altd-sim/scratch/mod.rs
298
+ +++ b/src/bin/altd-sim/scratch/mod.rs
299
+ @@ -1,5 +1,6 @@
300
+ //! Scratch training configuration, checkpoints, and stopping rules.
301
+
302
+ +mod analysis;
303
+ mod checkpoint;
304
+ mod runner;
305
+ mod stop;
306
+ @@ -37,6 +38,9 @@
307
+ pub(super) batch_count: usize,
308
+ pub(super) checkpoint_every: usize,
309
+ pub(super) save_final_candidate: bool,
310
+ + pub(super) analysis: bool,
311
+ + pub(super) evaluate_frozen: bool,
312
+ + pub(super) clean_min_laps: u64,
313
+ pub(super) stop: StopRules,
314
+ pub(super) eliminate_on_wall: Option<bool>,
315
+ pub(super) idle_eliminate: Option<bool>,
316
+ --- a/src/bin/altd-sim/scratch/runner.rs
317
+ +++ b/src/bin/altd-sim/scratch/runner.rs
318
+ @@ -74,7 +74,7 @@
319
+ "population, generations, and ticks must be positive"
320
+ );
321
+ assert!(
322
+ - config.mutation_start > 0.0 && config.mutation_end > 0.0,
323
+ + config.evaluate_frozen || (config.mutation_start > 0.0 && config.mutation_end > 0.0),
324
+ "mutation rates must be positive"
325
+ );
326
+
327
+ @@ -110,6 +110,15 @@
328
+ .validate_algorithm()
329
+ .unwrap_or_else(|e| panic!("settings: {e}"));
330
+
331
+ + assert!(
332
+ + !config.analysis || (track_count_guard(config) && !config.resume && config.init_population.is_none()),
333
+ + "analysis requires a fresh fixed-track run"
334
+ + );
335
+ + assert!(
336
+ + !config.evaluate_frozen
337
+ + || (config.analysis && config.population == 1 && config.generations == 1 && config.checkpoint_every == 0),
338
+ + "frozen evaluation requires analysis, one exact policy, one generation, and no checkpoint turnover"
339
+ + );
340
+ let dir = &config.out_dir;
341
+ let best_dir = dir.join("best_laps");
342
+ std::fs::create_dir_all(&best_dir).unwrap_or_else(|e| panic!("{}: {e}", best_dir.display()));
343
+ @@ -338,7 +347,11 @@
344
+ );
345
+ generation as usize
346
+ } else {
347
+ - runner.start(seed_network.as_ref().unwrap());
348
+ + if config.evaluate_frozen {
349
+ + runner.resume(&[seed_network.as_ref().unwrap().clone()], 0);
350
+ + } else {
351
+ + runner.start(seed_network.as_ref().unwrap());
352
+ + }
353
+ std::fs::write(&log_path, "").expect("log file");
354
+ 0
355
+ };
356
+ @@ -370,6 +383,10 @@
357
+ started.elapsed().as_secs_f64()
358
+ );
359
+
360
+ + let frozen_params = config.evaluate_frozen.then(|| runner.agents[0].network.params.clone());
361
+ + let mut analysis = config
362
+ + .analysis
363
+ + .then(|| super::analysis::Analysis::new(dir, config.clean_min_laps, config.evaluate_frozen));
364
+ let fmt_lap = |lap: Option<f64>| lap.map_or(" - ".to_string(), |t| format!("{t:7.2}"));
365
+ let mut stop_state = StopState {
366
+ best_lap: None,
367
+ @@ -493,6 +510,18 @@
368
+ }
369
+ let simulate_seconds = generation_started.elapsed().as_secs_f64();
370
+ let summary = batch.finish();
371
+ + if let Some(params) = &frozen_params {
372
+ + assert!(
373
+ + params
374
+ + .iter()
375
+ + .zip(&runner.agents[0].network.params)
376
+ + .all(|(a, b)| a.to_bits() == b.to_bits()),
377
+ + "frozen weights changed during evaluation"
378
+ + );
379
+ + }
380
+ + if let Some(writer) = &mut analysis {
381
+ + writer.record(&runner, generation, &summary.fitness, &network_data);
382
+ + }
383
+ let best_score = summary.mean_scores.iter().copied().fold(f64::NEG_INFINITY, f64::max);
384
+ let mean_score = py_sum(summary.mean_scores.iter().copied()) / config.population as f64;
385
+ let mean_lap = (lap_count > 0).then(|| lap_time_sum / lap_count as f64);
386
+ @@ -521,7 +550,7 @@
387
+ }
388
+ let last = stop_reason.is_some();
389
+ let final_checkpoint = last && config.checkpoint_every > 0;
390
+ - let candidate = if last && config.save_final_candidate {
391
+ + let candidate = if last && config.save_final_candidate && !config.evaluate_frozen {
392
+ let leader = final_candidate_leader(&summary.fitness);
393
+ let mut export = runner.agents[leader].network.to_json();
394
+ export["schema_version"] = json!(1);
395
+ @@ -606,6 +635,9 @@
396
+ break;
397
+ }
398
+ }
399
+ + if let Some(writer) = analysis {
400
+ + writer.finish();
401
+ + }
402
+ let _ = std::fs::remove_file(dir.join("progress.json"));
403
+ println!(
404
+ "done: best lap {} (generation {}), {:.1} h",
405
+ @@ -614,3 +646,7 @@
406
+ started.elapsed().as_secs_f64() / 3600.0
407
+ );
408
+ }
409
+ +
410
+ +fn track_count_guard(config: &ScratchConfig) -> bool {
411
+ + config.tracks.settings().is_none()
412
+ +}
413
+ --- a/src/training/evolution.rs
414
+ +++ b/src/training/evolution.rs
415
+ @@ -397,6 +397,42 @@
416
+ }
417
+ }
418
+ result
419
+ +}
420
+ +
421
+ +/// Observe every game reward channel without altering selection or drawing RNG.
422
+ +/// The unsigned ranks use the native selection normalization, but missing
423
+ +/// measurements remain null instead of exposing selection's zero fallback.
424
+ +pub fn reward_descriptors(agents: &[AgentResult]) -> Vec<Value> {
425
+ + let mut channels = Vec::new();
426
+ + for metric in METRIC_NAMES {
427
+ + for kind in ["default", "average"] {
428
+ + if (kind == "default" && AVERAGE_ONLY_METRICS.contains(&metric))
429
+ + || (kind == "average" && !AVERAGE_METRICS.contains(&metric))
430
+ + {
431
+ + continue;
432
+ + }
433
+ + let spec = RewardSpec {
434
+ + metric: metric.into(),
435
+ + weight: 100,
436
+ + kind: kind.into(),
437
+ + };
438
+ + let values: Vec<Option<f64>> = agents
439
+ + .iter()
440
+ + .map(|agent| {
441
+ + if kind == "average" && agent.update_count == 0 {
442
+ + None
443
+ + } else {
444
+ + agent.value(&spec).filter(|v| v.is_finite())
445
+ + }
446
+ + })
447
+ + .collect();
448
+ + let ranks = rank_normalize(&values);
449
+ + channels.push(json!({"metric": metric, "type": kind, "values": values,
450
+ + "population_relative": ranks.iter().zip(&values)
451
+ + .map(|(rank, value)| value.map(|_| *rank)).collect::<Vec<_>>() }));
452
+ + }
453
+ + }
454
+ + channels
455
+ }
456
+
457
+ /// The adaptive mutation scale; its log is the C runtime's (.NET Math.Log).
458
+ --- a/tests/winner_analysis.rs
459
+ +++ b/tests/winner_analysis.rs
460
+ @@ -0,0 +1,147 @@
461
+ +//! Native boundary checks: observations preserve evolution and frozen policies.
462
+ +use altd_sim::nn::network::Network;
463
+ +use serde_json::{json, Value};
464
+ +use std::{fs, path::Path, process::Command};
465
+ +#[path = "../examples/support/generated.rs"]
466
+ +mod generated;
467
+ +fn load(path: impl AsRef<Path>) -> Value {
468
+ + serde_json::from_slice(&fs::read(path).unwrap()).unwrap()
469
+ +}
470
+ +fn run(root: &Path, name: &str, extra: &[&str]) {
471
+ + let mut command = Command::new(env!("CARGO_BIN_EXE_altd-sim"));
472
+ + command
473
+ + .args([
474
+ + "--threads",
475
+ + "1",
476
+ + "--math-profile",
477
+ + "proton",
478
+ + "train-scratch",
479
+ + "--shape",
480
+ + "20,5",
481
+ + "--population",
482
+ + if extra.contains(&"--evaluate-frozen") { "1" } else { "4" },
483
+ + "--generations",
484
+ + if extra.contains(&"--evaluate-frozen") { "1" } else { "2" },
485
+ + "--ticks",
486
+ + "60",
487
+ + "--mutation-start",
488
+ + "0.2",
489
+ + "--mutation-end",
490
+ + "0.2",
491
+ + "--seed",
492
+ + "42",
493
+ + "--checkpoint-every",
494
+ + "0",
495
+ + "--save-final-candidate",
496
+ + "--no-idle-eliminate",
497
+ + "--no-eliminate-on-wall",
498
+ + ])
499
+ + .arg("--scene")
500
+ + .arg(root.join("scene.json"))
501
+ + .arg("--spawn-trace")
502
+ + .arg(root.join("spawn.json"))
503
+ + .arg("--network")
504
+ + .arg(root.join("seed.json"))
505
+ + .arg("--init-network")
506
+ + .arg(root.join("seed.json"))
507
+ + .arg("--model")
508
+ + .arg(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/models/rally.json"))
509
+ + .arg("--settings")
510
+ + .arg(root.join("settings.json"))
511
+ + .arg("--out-dir")
512
+ + .arg(root.join(name))
513
+ + .args(extra);
514
+ + let output = command.output().unwrap();
515
+ + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
516
+ +}
517
+ +fn workspace(tag: &str) -> std::path::PathBuf {
518
+ + let root = std::env::temp_dir().join(format!("ald-winner-analysis-{}-{tag}", std::process::id()));
519
+ + fs::create_dir(&root).unwrap();
520
+ + let scene = generated::scene("rally", 0);
521
+ + let metadata = generated::network("rally");
522
+ + let mut seed = Network::from_vector(&[20, 5], vec![0.1; 105]).to_json();
523
+ + seed["inputs"] = metadata["inputs"].clone();
524
+ + seed["outputs"] = metadata["outputs"].clone();
525
+ + for (name, value) in [
526
+ + ("scene.json", scene.clone()),
527
+ + ("seed.json", seed),
528
+ + ("spawn.json", json!({"frames":[generated::spawn(&scene)]})),
529
+ + (
530
+ + "settings.json",
531
+ + json!({"selection_algorithm":"best","selection_size":1,"preserve_parents":"on_custom",
532
+ + "preserve_parents_size":1,"weight_decay":0.8,"rewards":[{"metric":"total_actual_distance","weight":100,"type":"default"}]}),
533
+ + ),
534
+ + ] {
535
+ + fs::write(root.join(name), value.to_string()).unwrap();
536
+ + }
537
+ + root
538
+ +}
539
+ +#[test]
540
+ +fn observation_is_rng_neutral_and_final_winner_matches_exact_pre_turnover_parameters() {
541
+ + let root = workspace("neutral");
542
+ + run(&root, "plain", &[]);
543
+ + run(&root, "observed", &["--analysis"]);
544
+ + assert_eq!(
545
+ + load(root.join("plain/candidate.json")),
546
+ + load(root.join("observed/candidate.json"))
547
+ + );
548
+ + let plain: Vec<Value> = fs::read_to_string(root.join("plain/log.jsonl"))
549
+ + .unwrap()
550
+ + .lines()
551
+ + .map(|l| serde_json::from_str(l).unwrap())
552
+ + .collect();
553
+ + let observed: Vec<Value> = fs::read_to_string(root.join("observed/log.jsonl"))
554
+ + .unwrap()
555
+ + .lines()
556
+ + .map(|l| serde_json::from_str(l).unwrap())
557
+ + .collect();
558
+ + for (a, b) in plain.iter().zip(&observed) {
559
+ + for key in [
560
+ + "best_score",
561
+ + "mean_score",
562
+ + "best_fitness",
563
+ + "mean_fitness",
564
+ + "total_metric_values",
565
+ + "simulated_ticks",
566
+ + ] {
567
+ + assert_eq!(a[key], b[key], "{key}");
568
+ + }
569
+ + }
570
+ + let summary = load(root.join("observed/analysis/analysis.json"));
571
+ + assert_eq!(summary["rows"], 8);
572
+ + assert_eq!(summary["winners"]["fitness"]["generation"], 2);
573
+ + let selected = load(root.join("observed/analysis/winners/fitness.json"));
574
+ + let candidate = load(root.join("observed/candidate.json"));
575
+ + assert_eq!(selected["weights"], candidate["weights"]);
576
+ + assert_eq!(selected["biases"], candidate["biases"]);
577
+ + assert!(summary["winners"]["fastest_lap"].is_null());
578
+ + assert!(summary["winners"]["clean_laps"].is_null());
579
+ + let rows: Vec<Value> = fs::read_to_string(root.join("observed/analysis/descriptors.jsonl"))
580
+ + .unwrap()
581
+ + .lines()
582
+ + .map(|l| serde_json::from_str(l).unwrap())
583
+ + .collect();
584
+ + assert_eq!(rows.len(), 8);
585
+ + assert_eq!(rows[0]["raw_metrics"].as_object().unwrap().len(), 14);
586
+ + assert_eq!(rows[0]["channels"]["values"].as_array().unwrap().len(), 18);
587
+ + assert!(rows.iter().any(|row| row == &selected["observation"]));
588
+ + fs::remove_dir_all(root).unwrap();
589
+ +}
590
+ +#[test]
591
+ +fn frozen_evaluation_never_breeds_or_decays_and_has_no_parent_candidate() {
592
+ + let root = workspace("frozen");
593
+ + run(&root, "evaluated", &["--analysis", "--evaluate-frozen"]);
594
+ + let summary = load(root.join("evaluated/analysis/analysis.json"));
595
+ + assert_eq!(summary["rows"], 1);
596
+ + assert!(summary["winners"]["fitness"].is_null());
597
+ + assert!(summary["evaluation"]["selection_fitness"].is_null());
598
+ + let seed = Network::from_game_export(&load(root.join("seed.json")));
599
+ + let bytes: Vec<u8> = seed.params.iter().flat_map(|p| p.to_le_bytes()).collect();
600
+ + assert_eq!(
601
+ + summary["evaluation"]["weights_sha256"],
602
+ + altd_sim::training::evaluation::sha256(&bytes)
603
+ + );
604
+ + assert!(!root.join("evaluated/candidate.json").exists());
605
+ + assert!(!root.join("evaluated/checkpoint.json").exists());
606
+ + fs::remove_dir_all(root).unwrap();
607
+ +}
native/build-receipt.json ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "schema": "ald-winners-linux-build-v1",
3
+ "status": "passed",
4
+ "created_utc": "2026-10-14T04:35:53.223545+00:00",
5
+ "source": {
6
+ "upstream_revision": "b90b8af06ffc4ea4e4fd703becd1107a48b8e5e0",
7
+ "source_receipt_sha256": "33d09c326e4d6c3ac1d1839e4528c70c06b4ff48138b90f174f434ab685b4b34",
8
+ "patch_sha256": "56859bc55a946ea129d502868387f8441cbd37c30962fe3e7e484d313c4ccf63",
9
+ "source_inventory_sha256": "3311905f217e08a927ca73d71db6687aaaf68f01cc2758e4028b92fa703bcebe"
10
+ },
11
+ "artifacts": {
12
+ "cpu/bin/altd-sim": {
13
+ "path": "cpu/bin/altd-sim",
14
+ "bytes": 3580296,
15
+ "sha256": "6488c7ec651701841999687708ff128a8ffb0b88feb53e23168a3ce678b5393d",
16
+ "origin_type": "local_linux_build"
17
+ },
18
+ "gpu/bin/altd-sim": {
19
+ "path": "gpu/bin/altd-sim",
20
+ "bytes": 3580296,
21
+ "sha256": "6488c7ec651701841999687708ff128a8ffb0b88feb53e23168a3ce678b5393d",
22
+ "origin_type": "local_linux_build"
23
+ },
24
+ "gpu/bin/libaltd_gpu.so": {
25
+ "path": "gpu/bin/libaltd_gpu.so",
26
+ "bytes": 4711704,
27
+ "sha256": "3b1b88bccc06a21acc33ac4400af016d55a7e3d8727136a0c43b541f3c7887ca",
28
+ "origin_type": "retained_cuda_b90"
29
+ }
30
+ },
31
+ "checks": {
32
+ "native_tests": {
33
+ "status": "passed",
34
+ "receipt_path": "build/receipts/linux-native-tests.json",
35
+ "receipt_sha256": "ddb35821b16094bc0da624748e1f07e0b7a06cea4d4bc9baa28a7b6356bfacdb"
36
+ },
37
+ "cpu_smoke": {
38
+ "status": "passed",
39
+ "receipt_path": "build/receipts/linux-cpu-smoke.json",
40
+ "receipt_sha256": "afb4107c84bb38f21b9fabd6bbd9401b236ebff16ae2ce23230640994b8fd85c"
41
+ },
42
+ "gpu_layout": {
43
+ "status": "passed",
44
+ "receipt_path": "build/receipts/linux-gpu-layout.json",
45
+ "receipt_sha256": "45462cb6f4047e7715d6258fea60b9b39b1f8ea2e88ebbe3479454d53801cc00"
46
+ }
47
+ },
48
+ "gpu_device_execution": "not_run",
49
+ "compiler": {
50
+ "version": "rustc 1.95.0 (59807616e 2026-04-14)",
51
+ "host": "aarch64-apple-darwin",
52
+ "target": "x86_64-unknown-linux-gnu",
53
+ "cross_compiled": true,
54
+ "standard_library": {
55
+ "origin": "official_rust_distribution",
56
+ "url": "https://static.rust-lang.org/dist/2026-04-16/rust-std-1.95.0-x86_64-unknown-linux-gnu.tar.xz",
57
+ "sha256": "047ea7098803d3500fa1072e9cee5392697e21525559e4458128a2bf874aa382",
58
+ "installed_manifest_sha256": "46c1f8d1bcef24174217545ece8c22eb395a42e3534f618736c17a759a31e255"
59
+ },
60
+ "linker": {
61
+ "clang_version": "Apple clang version 21.0.0 (clang-2100.3.34.2)",
62
+ "lld_origin": "installed Rust1.95 bundled rust-lld",
63
+ "wrapper_path": "build/cross-tools/linux-linker",
64
+ "wrapper_sha256": "53fcd6801c00d79b669c231841079988a285e583142e59b1cdf29f8c19b1fc5f"
65
+ },
66
+ "sysroot": {
67
+ "origin": "read-only Ubuntu22.04 libraries copied from our existing nvidia/cuda:13.0.2-devel-ubuntu22.04 container",
68
+ "inventory_path": "build/receipts/linux-sysroot-inventory.json",
69
+ "inventory_sha256": "a4a9d33b5ec227a18b7f76f2df085fba372c5b6acd827cf28de84867df53ba6f"
70
+ }
71
+ },
72
+ "build_command": "/Users/tiga/.cargo/bin/cargo +1.95.0 build --target x86_64-unknown-linux-gnu --manifest-path build/source/Cargo.toml --release --locked --offline --no-default-features --features cli --bin altd-sim",
73
+ "build_environment": {
74
+ "CARGO_TARGET_DIR": "/Volumes/PATRIOT/ALD-TAO-lab/hf-winners-evaluation-v1.2-20261013/build/cross-target",
75
+ "CARGO_PROFILE_RELEASE_LTO": "false",
76
+ "CARGO_PROFILE_RELEASE_CODEGEN_UNITS": "16",
77
+ "CARGO_PROFILE_RELEASE_DEBUG": "0",
78
+ "CARGO_BUILD_JOBS": "4",
79
+ "CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER": "/Volumes/PATRIOT/ALD-TAO-lab/hf-winners-evaluation-v1.2-20261013/build/cross-tools/linux-linker",
80
+ "RUSTFLAGS": "--sysroot /Volumes/PATRIOT/ALD-TAO-lab/hf-winners-evaluation-v1.2-20261013/build/cross-sysroot -C target-cpu=x86-64"
81
+ },
82
+ "build_recipe_path": "build/recipes/native-cross-build.py",
83
+ "build_recipe_sha256": "17c731594d158bbc6627340954c1b2c53e90232fe2f89b48c2c33d73ae56fcc4",
84
+ "build_log_path": "build/receipts/native-cross-build.log",
85
+ "build_log_sha256": "d201fa42f1e379210dd385d394207e4afccb2d837e281ee86f6b4d992aa087f8",
86
+ "source_preserved": true,
87
+ "installed_toolchain_unchanged": true,
88
+ "original_baseline_binaries_unchanged": true
89
+ }
native/source-receipt.json ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "schema": "ald-native-analysis-source-v1",
3
+ "upstream_revision": "b90b8af06ffc4ea4e4fd703becd1107a48b8e5e0",
4
+ "source_inventory_sha256": "3311905f217e08a927ca73d71db6687aaaf68f01cc2758e4028b92fa703bcebe",
5
+ "patch_sha256": "56859bc55a946ea129d502868387f8441cbd37c30962fe3e7e484d313c4ccf63",
6
+ "changed_source_files": [
7
+ {
8
+ "path": "src/bin/altd-sim/args.rs",
9
+ "baseline_sha256": "9f833f026d1f71ef76fe05d948464c619c76c15e43cedc11b5f510bfc337bda2",
10
+ "sha256": "7e94398fdae67500735a7be1ba6de6c576ed021d9c96011fd20ef0a3f3d3f3d7"
11
+ },
12
+ {
13
+ "path": "src/bin/altd-sim/main.rs",
14
+ "baseline_sha256": "841956a5a1a927b1a31369ccbd9aa18161b6328745a5a3ca4f02bdcfedd7230c",
15
+ "sha256": "3e679871a0978b2d68ce7cbb520584f1951adc8a0b584145966e58b6ca20e933"
16
+ },
17
+ {
18
+ "path": "src/bin/altd-sim/scratch/analysis.rs",
19
+ "baseline_sha256": null,
20
+ "sha256": "294c5c738de7fd573a3f3b89e4bdc8b35ba1c992b00959caa0bc75f19e41c3ba"
21
+ },
22
+ {
23
+ "path": "src/bin/altd-sim/scratch/mod.rs",
24
+ "baseline_sha256": "524e9033a1fb29ac7b4d689a21fede12b7bb77a772c078fce4c0bf4763568bbe",
25
+ "sha256": "1edaa2580112e4d0645743ab14bba16cf780d75800cd1036bd19f940bfc36680"
26
+ },
27
+ {
28
+ "path": "src/bin/altd-sim/scratch/runner.rs",
29
+ "baseline_sha256": "20a78bd99dc6bd69cc9314ba2d531dac13b5d9f21fb6ca676d34c0fa472a46c0",
30
+ "sha256": "0cab956fb0d63a20d6b62e01aadeb3d6592f4f12d601df095f41840a89329c55"
31
+ },
32
+ {
33
+ "path": "src/training/evolution.rs",
34
+ "baseline_sha256": "d050036c9346561411feefc6c94d598cb0f3ee69e85a8960cf911d60b2129a61",
35
+ "sha256": "488729530a2562295b44835716c7a4e055543614d0f262da682712cdac084460"
36
+ },
37
+ {
38
+ "path": "tests/winner_analysis.rs",
39
+ "baseline_sha256": null,
40
+ "sha256": "df3afd6faca830679afbefa4520b6947126bc972895df76504df6d42362ede28"
41
+ }
42
+ ],
43
+ "gpu_abi_source_unchanged": true,
44
+ "physics_source_unchanged": true,
45
+ "retained_cuda_library_sha256": "3b1b88bccc06a21acc33ac4400af016d55a7e3d8727136a0c43b541f3c7887ca",
46
+ "local_patch_semantics": "Host-only observations; unchanged simulation/evolution kernels. Descriptor helper reads native rewards without RNG; scratch frozen path resumes exact single policy and disables checkpoint turnover. Training state may be reused; evaluation uses fresh physical state.",
47
+ "mac_compiler": "rustc 1.95.0 (59807616e 2026-04-14)",
48
+ "mac_build_command": "cargo +1.95.0 --locked --offline --no-default-features --features cli; separate build/mac-target",
49
+ "linux_status": "passed"
50
+ }
portable_bundle.py CHANGED
@@ -1,6 +1,6 @@
1
  """Strict versioned portable bundle validator shared by CLI and hosted API."""
2
  from __future__ import annotations
3
- import base64, binascii, gzip, hashlib, io, json, math, os, re, sys
4
  from pathlib import Path
5
  from typing import Any
6
  PACKAGE_ROOT = Path(__file__).resolve().parent
@@ -13,8 +13,20 @@ MAX_NETWORK = 4 * 1024 * 1024
13
  MAX_TRACK = 1 * 1024 * 1024
14
  MAX_RECIPE = 32 * 1024
15
  MAX_RECEIPT = 512 * 1024
16
- MAX_BUNDLE = 8 * 1024 * 1024
 
17
  MAX_EXPANDED_JSON = 4 * 1024 * 1024
 
 
 
 
 
 
 
 
 
 
 
18
  HEX64 = re.compile(r"^[0-9a-f]{64}$")
19
  BUCKET_PATH = re.compile(r"^hf://buckets/([A-Za-z0-9][A-Za-z0-9_.-]{0,63})/([A-Za-z0-9][A-Za-z0-9_.-]{0,127})(?:/([A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)*))?$")
20
  SAFE_SEGMENT = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
@@ -23,19 +35,33 @@ RECIPE_KEYS = {
23
  "population", "generations", "ticks", "mutation", "seed", "mutation_end",
24
  "schedule", "selection", "parents", "elite", "crossover", "decay",
25
  "adaptive_mutation", "control_hz", "workers", "collision", "idle", "rewards",
 
26
  }
 
27
  METRICS = {
28
  "total_score", "total_speed", "total_abs_steering", "total_actual_distance",
29
  "total_drifting_amount", "total_momentum_change_frequency", "total_throttle_change_frequency",
30
  "total_braking_frequency", "total_distance_from_wall", "total_distance_from_center",
31
  "network_novelty", "collision_count", "best_lap_performance", "total_racing_line_efficiency",
32
  }
 
 
 
 
 
 
 
 
 
33
  RECEIPT_KEYS = {"version", "kind", "state", "build_id", "backend", "output_sha256", "report"}
34
  REPORT_KEYS = {
35
  "vehicle_type", "vehicle_name", "generations_completed", "generations_requested", "population",
36
  "elapsed_seconds", "best_score", "mean_score", "best_lap_seconds", "lapped_cars",
37
  "simulated_ticks", "requested_ticks", "candidate_validated", "execution_backend", "inputs", "settings",
38
  }
 
 
 
39
  HISTORY_ROW_KEYS = {
40
  "generation", "mutation", "best_score", "mean_score",
41
  "best_lap_seconds", "lapped_cars", "simulated_ticks",
@@ -47,6 +73,374 @@ class BundleError(ValueError):
47
  pass
48
 
49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  def _no_constant(value: str) -> None:
51
  raise BundleError(f"Non-finite JSON number is not allowed: {value}")
52
 
@@ -72,7 +466,7 @@ def _parse_json(raw: bytes, *, limit: int, label: str) -> Any:
72
  raise BundleError(f"{label} exceeds its byte limit")
73
  try:
74
  return json.loads(raw.decode("utf-8"), object_pairs_hook=_unique_object, parse_constant=_no_constant)
75
- except (UnicodeDecodeError, json.JSONDecodeError) as exc:
76
  raise BundleError(f"{label} must be valid UTF-8 JSON") from exc
77
 
78
 
@@ -94,18 +488,27 @@ def _is_number(value: Any, low: float, high: float) -> bool:
94
 
95
 
96
  def validate_settings(value: Any) -> dict[str, Any]:
97
- settings = _strict_keys(value, RECIPE_KEYS, RECIPE_KEYS, "recipe settings")
 
 
98
  bounds = {
99
  "population": (1, 128), "generations": (1, 1000), "ticks": (1, 18000),
100
  "seed": (0, 2147483647), "parents": (1, 128), "elite": (0, 128), "workers": (1, 4),
 
101
  }
102
  if any(not _is_int(settings[k], *bounds[k]) for k in bounds):
103
  raise BundleError("recipe settings contain an out-of-range integer")
104
  for key in ("mutation", "mutation_end"):
105
  if not _is_number(settings[key], 0, 4.2):
106
  raise BundleError("recipe mutation rates must be finite and between 0 and 4.2")
107
- if settings["mutation"] <= 0 or (settings["schedule"] != "fixed" and settings["mutation_end"] <= 0):
 
 
108
  raise BundleError("recipe mutation rates must be positive for the selected schedule")
 
 
 
 
109
  if not _is_number(settings["decay"], 0, 1):
110
  raise BundleError("recipe decay must be between 0 and 1")
111
  choices = {
@@ -152,7 +555,7 @@ def validate_recipe(value: Any) -> dict[str, Any]:
152
  return recipe
153
 
154
 
155
- def validate_receipt(value: Any) -> dict[str, Any]:
156
  receipt = _strict_keys(value, RECEIPT_KEYS, RECEIPT_KEYS, "run receipt")
157
  if type(receipt["version"]) is not int or receipt["version"] != 1 or receipt["kind"] != "run":
158
  raise BundleError("run receipt version or kind is unsupported")
@@ -163,7 +566,24 @@ def validate_receipt(value: Any) -> dict[str, Any]:
163
  raise BundleError("run receipt build id is invalid")
164
  if not isinstance(receipt["output_sha256"], str) or not HEX64.fullmatch(receipt["output_sha256"]):
165
  raise BundleError("run receipt output hash is invalid")
166
- report = _strict_keys(receipt["report"], REPORT_KEYS | {"history"}, REPORT_KEYS, "run receipt report")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167
  if not _is_int(report["vehicle_type"], 0, 3) or report["vehicle_name"] != ("Formula", "Rally", "Snowmobile", "Truck")[report["vehicle_type"]]:
168
  raise BundleError("run receipt vehicle is invalid")
169
  for key in ("generations_completed", "generations_requested", "population", "simulated_ticks", "requested_ticks"):
@@ -302,13 +722,28 @@ def _decode_slot(value: Any, *, role: str, limit: int, fmt: str, allowed_extra:
302
  return raw
303
 
304
 
 
 
 
 
 
 
 
305
  def load_bundle(raw: bytes, *, catalog_path: Path | None = None) -> tuple[dict[str, Any], dict[str, bytes]]:
306
  value = _parse_json(raw, limit=MAX_BUNDLE, label="bundle")
307
  bundle = _strict_keys(value, {"schema", "version", "artifacts"}, {"schema", "version", "artifacts"}, "bundle")
308
- if bundle["schema"] != SCHEMA or type(bundle["version"]) is not int or bundle["version"] != VERSION:
 
309
  raise BundleError("bundle schema or version is unsupported")
 
 
310
  artifacts = bundle["artifacts"]
311
- if not isinstance(artifacts, dict) or not {"selected_network"} <= set(artifacts) or set(artifacts) - {"selected_network", "selected_track", "selected_recipe", "run_receipt"}:
 
 
 
 
 
312
  raise BundleError("bundle must contain a selected network and only supported optional artifacts")
313
  files: dict[str, bytes] = {}
314
  net = _strict_keys(artifacts["selected_network"], {"format", "kind", "bytes", "sha256", "data_base64"}, {"format", "kind", "bytes", "sha256", "data_base64"}, "selected network")
@@ -354,15 +789,63 @@ def load_bundle(raw: bytes, *, catalog_path: Path | None = None) -> tuple[dict[s
354
  raise BundleError("selected recipe exceeds 32 KiB")
355
  files["selected_recipe.json"] = recipe_bytes
356
 
 
 
 
 
 
 
 
 
 
 
357
  if "run_receipt" in artifacts:
358
- receipt = validate_receipt(artifacts["run_receipt"])
359
  report = receipt["report"]
360
  network_sha = net["sha256"]
361
- if net["kind"] == "input":
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
362
  network_matches = report["inputs"]["network_sha256"] == network_sha
363
  else:
364
  network_matches = receipt["output_sha256"] == network_sha
365
- if not network_matches:
366
  raise BundleError("run receipt does not describe the bundled network")
367
  if "selected_track" in artifacts and report["inputs"]["track_sha256"] != artifacts["selected_track"]["sha256"]:
368
  raise BundleError("run receipt does not describe the bundled track")
 
1
  """Strict versioned portable bundle validator shared by CLI and hosted API."""
2
  from __future__ import annotations
3
+ import base64, binascii, gzip, hashlib, io, json, math, os, re, struct, sys, zlib
4
  from pathlib import Path
5
  from typing import Any
6
  PACKAGE_ROOT = Path(__file__).resolve().parent
 
13
  MAX_TRACK = 1 * 1024 * 1024
14
  MAX_RECIPE = 32 * 1024
15
  MAX_RECEIPT = 512 * 1024
16
+ MAX_BUNDLE_V1 = 8 * 1024 * 1024
17
+ MAX_BUNDLE = 48 * 1024 * 1024
18
  MAX_EXPANDED_JSON = 4 * 1024 * 1024
19
+ ANALYSIS_ARCHIVE_FORMAT = "ald.run-analysis-archive+gzip"
20
+ MAX_ANALYSIS_ARCHIVE = 32 * 1024 * 1024
21
+ MAX_ANALYSIS_EXPANDED = 256 * 1024 * 1024
22
+ MAX_ANALYSIS_FILE_BYTES = 192 * 1024 * 1024
23
+ MAX_DESCRIPTOR_ROWS = 128000
24
+ ANALYSIS_JSON_LIMIT = 512 * 1024
25
+ ANALYSIS_WINNER_LIMIT = 1024 * 1024
26
+ ANALYSIS_FILES = {
27
+ "analysis.json", "descriptors.jsonl", "evaluation.json",
28
+ "winners/fitness.evolution", "winners/fastest_lap.evolution", "winners/clean_laps.evolution",
29
+ }
30
  HEX64 = re.compile(r"^[0-9a-f]{64}$")
31
  BUCKET_PATH = re.compile(r"^hf://buckets/([A-Za-z0-9][A-Za-z0-9_.-]{0,63})/([A-Za-z0-9][A-Za-z0-9_.-]{0,127})(?:/([A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)*))?$")
32
  SAFE_SEGMENT = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
 
35
  "population", "generations", "ticks", "mutation", "seed", "mutation_end",
36
  "schedule", "selection", "parents", "elite", "crossover", "decay",
37
  "adaptive_mutation", "control_hz", "workers", "collision", "idle", "rewards",
38
+ "mode", "clean_min_laps",
39
  }
40
+ LEGACY_RECIPE_KEYS = RECIPE_KEYS - {"mode", "clean_min_laps"}
41
  METRICS = {
42
  "total_score", "total_speed", "total_abs_steering", "total_actual_distance",
43
  "total_drifting_amount", "total_momentum_change_frequency", "total_throttle_change_frequency",
44
  "total_braking_frequency", "total_distance_from_wall", "total_distance_from_center",
45
  "network_novelty", "collision_count", "best_lap_performance", "total_racing_line_efficiency",
46
  }
47
+ METRIC_ORDER = (
48
+ "total_score", "total_speed", "total_abs_steering", "total_actual_distance",
49
+ "total_drifting_amount", "total_momentum_change_frequency", "total_throttle_change_frequency",
50
+ "total_braking_frequency", "total_distance_from_wall", "total_distance_from_center",
51
+ "network_novelty", "collision_count", "best_lap_performance", "total_racing_line_efficiency",
52
+ )
53
+ AVERAGE_METRICS = set(METRIC_ORDER[:10])
54
+ AVERAGE_ONLY_METRICS = {"total_abs_steering", "total_momentum_change_frequency", "total_throttle_change_frequency",
55
+ "total_braking_frequency", "total_distance_from_wall", "total_distance_from_center"}
56
  RECEIPT_KEYS = {"version", "kind", "state", "build_id", "backend", "output_sha256", "report"}
57
  REPORT_KEYS = {
58
  "vehicle_type", "vehicle_name", "generations_completed", "generations_requested", "population",
59
  "elapsed_seconds", "best_score", "mean_score", "best_lap_seconds", "lapped_cars",
60
  "simulated_ticks", "requested_ticks", "candidate_validated", "execution_backend", "inputs", "settings",
61
  }
62
+ ANALYSIS_SUMMARY_KEYS = {
63
+ "schema", "version", "mode", "execution_backend", "conditions", "observations", "winners", "semantics",
64
+ }
65
  HISTORY_ROW_KEYS = {
66
  "generation", "mutation", "best_score", "mean_score",
67
  "best_lap_seconds", "lapped_cars", "simulated_ticks",
 
73
  pass
74
 
75
 
76
+ def _analysis_member_limit(name: str) -> int:
77
+ if name in {"analysis.json", "evaluation.json"}:
78
+ return ANALYSIS_JSON_LIMIT
79
+ if name.startswith("winners/") and name.endswith(".evolution"):
80
+ return ANALYSIS_WINNER_LIMIT
81
+ if name == "descriptors.jsonl":
82
+ return MAX_ANALYSIS_FILE_BYTES
83
+ raise BundleError("analysis archive contains an unsupported filename")
84
+
85
+
86
+ def _analysis_jsonl_rows(raw: bytes) -> int:
87
+ if not raw or not raw.endswith(b"\n"):
88
+ raise BundleError("analysis descriptors must be newline-terminated JSONL")
89
+ count = 0
90
+ for line in io.BytesIO(raw):
91
+ line = line[:-1] if line.endswith(b"\n") else line
92
+ if not line:
93
+ raise BundleError("analysis descriptors contain a blank row")
94
+ row = _parse_json(line, limit=64 * 1024, label="analysis descriptor row")
95
+ if not isinstance(row, dict):
96
+ raise BundleError("analysis descriptor row must be an object")
97
+ required = {"schema", "generation", "car", "candidate_id", "ticks_elapsed", "stats_update_count",
98
+ "raw_metrics", "channels", "selection_fitness", "objectives", "weights_sha256"}
99
+ if set(row) != required or row.get("schema") != 1:
100
+ raise BundleError("analysis descriptor row fields are unsupported")
101
+ if (type(row["generation"]) is not int or not 1 <= row["generation"] <= 1000
102
+ or type(row["car"]) is not int or not 0 <= row["car"] < 128
103
+ or row["candidate_id"] != f'g{row["generation"]:06}-c{row["car"]:06}'
104
+ or type(row["ticks_elapsed"]) is not int or not 0 <= row["ticks_elapsed"] <= 18_012
105
+ or type(row["stats_update_count"]) is not int or not 0 <= row["stats_update_count"] <= 18_012
106
+ or not isinstance(row["weights_sha256"], str) or not HEX64.fullmatch(row["weights_sha256"])):
107
+ raise BundleError("analysis descriptor identity or counts are invalid")
108
+ metrics = row["raw_metrics"]
109
+ if (not isinstance(metrics, dict) or set(metrics) != METRICS
110
+ or any(v is not None and not _is_number(v, -1e12, 1e12)
111
+ for k, v in metrics.items())):
112
+ raise BundleError("analysis descriptor raw metrics are invalid")
113
+ channels = row["channels"]
114
+ if (not isinstance(channels, dict)
115
+ or set(channels) != {"values", "population_relative", "unavailable_reasons"}
116
+ or any(not isinstance(channels[key], list) or len(channels[key]) != 18
117
+ for key in ("values", "population_relative", "unavailable_reasons"))):
118
+ raise BundleError("analysis descriptor reward channels are invalid")
119
+ for value, rank, reason in zip(channels["values"], channels["population_relative"], channels["unavailable_reasons"]):
120
+ if value is None:
121
+ if rank is not None or not isinstance(reason, str) or not reason:
122
+ raise BundleError("unavailable analysis channel is not represented explicitly")
123
+ elif (not _is_number(value, -1e12, 1e12) or not _is_number(rank, 0, 1) or reason is not None):
124
+ raise BundleError("available analysis channel is invalid")
125
+ if row["selection_fitness"] is not None and not _is_number(row["selection_fitness"], -1e12, 1e12):
126
+ raise BundleError("analysis descriptor selection fitness is invalid")
127
+ objectives = row["objectives"]
128
+ objective_keys = {"best_lap_seconds", "forward_laps", "physical_contacts", "collision_updates", "total_actual_distance", "active"}
129
+ if (not isinstance(objectives, dict) or set(objectives) != objective_keys
130
+ or any(objectives[k] is not None and not _is_number(objectives[k], 0, 1e12)
131
+ for k in ("best_lap_seconds", "total_actual_distance"))
132
+ or any(type(objectives[k]) is not int or not 0 <= objectives[k] <= 4_000_000
133
+ for k in ("forward_laps", "physical_contacts", "collision_updates"))
134
+ or type(objectives["active"]) is not bool):
135
+ raise BundleError("analysis descriptor objectives are invalid")
136
+ count += 1
137
+ if count > MAX_DESCRIPTOR_ROWS:
138
+ raise BundleError("analysis descriptor row limit exceeded")
139
+ return count
140
+
141
+
142
+ def _network_weights_sha256(raw: bytes) -> str:
143
+ try:
144
+ network = _game_network_input(raw)["NeuralNetwork"]
145
+ digest = hashlib.sha256()
146
+ for matrix, bias in zip(network["Weights"], network["Biases"]):
147
+ for row in matrix:
148
+ for value in row:
149
+ digest.update(struct.pack("<d", value))
150
+ for value in bias:
151
+ digest.update(struct.pack("<d", value))
152
+ return digest.hexdigest()
153
+ except (KeyError, TypeError, ValueError, OverflowError, struct.error) as exc:
154
+ raise BundleError("analysis winner network is invalid") from exc
155
+
156
+
157
+ def load_analysis_archive(raw: bytes) -> tuple[dict[str, Any], dict[str, bytes]]:
158
+ """Validate the deterministic gzip envelope and return fixed inner members."""
159
+ if not isinstance(raw, bytes) or not raw or len(raw) > MAX_ANALYSIS_ARCHIVE:
160
+ raise BundleError("analysis archive is empty or exceeds 32 MiB")
161
+ if not raw.startswith(b"\x1f\x8b"):
162
+ raise BundleError("analysis archive is not gzip data")
163
+ inflater = zlib.decompressobj(16 + zlib.MAX_WBITS)
164
+ try:
165
+ expanded = inflater.decompress(raw, MAX_ANALYSIS_EXPANDED + 1)
166
+ if len(expanded) > MAX_ANALYSIS_EXPANDED or inflater.unconsumed_tail:
167
+ raise BundleError("analysis archive expands beyond 256 MiB")
168
+ expanded += inflater.flush(MAX_ANALYSIS_EXPANDED - len(expanded) + 1)
169
+ except zlib.error as exc:
170
+ raise BundleError("analysis archive gzip data is invalid") from exc
171
+ if len(expanded) > MAX_ANALYSIS_EXPANDED:
172
+ raise BundleError("analysis archive expands beyond 256 MiB")
173
+ if not inflater.eof or inflater.unused_data:
174
+ raise BundleError("analysis archive must contain one complete gzip member")
175
+ value = _parse_json(expanded, limit=MAX_ANALYSIS_EXPANDED, label="analysis archive envelope")
176
+ envelope = _strict_keys(value, {"schema", "version", "files"}, {"schema", "version", "files"}, "analysis archive")
177
+ if envelope["schema"] != "ald.run-analysis-archive" or type(envelope["version"]) is not int or envelope["version"] != 1:
178
+ raise BundleError("analysis archive schema or version is unsupported")
179
+ entries = envelope["files"]
180
+ if not isinstance(entries, list) or not 2 <= len(entries) <= len(ANALYSIS_FILES):
181
+ raise BundleError("analysis archive file list is invalid")
182
+ inner: dict[str, bytes] = {}
183
+ total = 0
184
+ for entry in entries:
185
+ entry = _strict_keys(entry, {"name", "bytes", "sha256", "data_base64"},
186
+ {"name", "bytes", "sha256", "data_base64"}, "analysis archive member")
187
+ name = entry["name"]
188
+ if not isinstance(name, str) or name not in ANALYSIS_FILES or name in inner:
189
+ raise BundleError("analysis archive filename is unsupported or duplicated")
190
+ size = entry["bytes"]
191
+ limit = _analysis_member_limit(name)
192
+ if type(size) is not int or size < 1 or size > limit:
193
+ raise BundleError("analysis archive member size is invalid")
194
+ if not isinstance(entry["sha256"], str) or not HEX64.fullmatch(entry["sha256"]):
195
+ raise BundleError("analysis archive member SHA-256 is invalid")
196
+ encoded = entry["data_base64"]
197
+ if not isinstance(encoded, str) or len(encoded) > 4 * ((limit + 2) // 3):
198
+ raise BundleError("analysis archive member base64 exceeds its limit")
199
+ try:
200
+ content = base64.b64decode(encoded, validate=True)
201
+ except (ValueError, binascii.Error) as exc:
202
+ raise BundleError("analysis archive member base64 is invalid") from exc
203
+ if len(content) != size or hashlib.sha256(content).hexdigest() != entry["sha256"]:
204
+ raise BundleError("analysis archive member bytes or hash do not match")
205
+ total += len(content)
206
+ if total > MAX_ANALYSIS_FILE_BYTES:
207
+ raise BundleError("analysis archive members exceed 192 MiB")
208
+ inner[name] = content
209
+ if "analysis.json" not in inner or "descriptors.jsonl" not in inner:
210
+ raise BundleError("analysis archive is missing required files")
211
+ rows = _analysis_jsonl_rows(inner["descriptors.jsonl"])
212
+ analysis = _parse_json(inner["analysis.json"], limit=ANALYSIS_JSON_LIMIT, label="analysis metadata")
213
+ if analysis.get("schema") != "ald.run-analysis" or type(analysis.get("version")) is not int or analysis["version"] != 1:
214
+ raise BundleError("analysis metadata schema or version is unsupported")
215
+ mode = analysis.get("mode")
216
+ if not isinstance(mode, str):
217
+ raise BundleError("analysis mode is unsupported")
218
+ if mode == "train":
219
+ if "evaluation.json" in inner or "winners/fitness.evolution" not in inner:
220
+ raise BundleError("training analysis is missing its fitness winner or includes evaluation data")
221
+ elif mode == "evaluate":
222
+ if "evaluation.json" not in inner or any(name.startswith("winners/") for name in inner):
223
+ raise BundleError("evaluation analysis must include evaluation data and no selected winners")
224
+ else:
225
+ raise BundleError("analysis mode is unsupported")
226
+ observations = analysis.get("observations")
227
+ descriptors = analysis.get("descriptors")
228
+ if not isinstance(observations, dict) or not isinstance(descriptors, dict):
229
+ raise BundleError("analysis observation or descriptor metadata is missing")
230
+ if (type(observations.get("cars")) is not int or observations["cars"] != rows
231
+ or type(observations.get("generations")) is not int or not 1 <= observations["generations"] <= 1000
232
+ or type(observations.get("raw_metric_count")) is not int or observations["raw_metric_count"] != 14
233
+ or type(observations.get("reward_channel_count")) is not int or observations["reward_channel_count"] != 18):
234
+ raise BundleError("analysis observation counts do not match the descriptor rows")
235
+ if rows % observations["generations"]:
236
+ raise BundleError("analysis descriptor rows are not a whole number of generations")
237
+ expected_generation_cars = rows // observations["generations"]
238
+ if not 1 <= expected_generation_cars <= 128:
239
+ raise BundleError("analysis car population is outside its supported bounds")
240
+ if (descriptors.get("name") != "descriptors.jsonl"
241
+ or descriptors.get("bytes") != len(inner["descriptors.jsonl"])
242
+ or descriptors.get("sha256") != hashlib.sha256(inner["descriptors.jsonl"]).hexdigest()
243
+ or descriptors.get("rows") != rows):
244
+ raise BundleError("descriptor metadata does not match the complete descriptor file")
245
+ winners = analysis.get("winners")
246
+ if not isinstance(winners, dict) or set(winners) != {"fitness", "fastest_lap", "clean_laps"}:
247
+ raise BundleError("analysis winner roles are invalid")
248
+ expected_semantics = {"fitness", "fastest_lap", "clean_laps", "channels", "collision_count", "evaluation", "training"}
249
+ semantics = analysis.get("semantics")
250
+ if (not isinstance(semantics, dict) or set(semantics) != expected_semantics
251
+ or any(not isinstance(value, str) or not value or len(value) > 512 for value in semantics.values())):
252
+ raise BundleError("analysis semantics are invalid")
253
+ conditions = analysis.get("conditions")
254
+ if (not isinstance(conditions, dict)
255
+ or set(conditions) != {"track_sha256", "network_sha256", "vehicle_type", "requested_ticks", "control_hz", "collision", "idle", "math_profile", "clean_min_laps"}
256
+ or any(not isinstance(conditions[k], str) or not HEX64.fullmatch(conditions[k]) for k in ("track_sha256", "network_sha256"))
257
+ or type(conditions["vehicle_type"]) is not int or not 0 <= conditions["vehicle_type"] <= 3
258
+ or type(conditions["requested_ticks"]) is not int or not 1 <= conditions["requested_ticks"] <= 18_000
259
+ or type(conditions["control_hz"]) is not int or conditions["control_hz"] not in {15, 30, 60}
260
+ or type(conditions["collision"]) is not bool or type(conditions["idle"]) is not bool
261
+ or conditions["math_profile"] != "proton"
262
+ or type(conditions["clean_min_laps"]) is not int or not 1 <= conditions["clean_min_laps"] <= 100):
263
+ raise BundleError("analysis conditions are invalid")
264
+ metric_names = analysis.get("metric_names")
265
+ reward_channels = analysis.get("reward_channels")
266
+ if (not isinstance(metric_names, list) or tuple(metric_names) != METRIC_ORDER
267
+ or not isinstance(reward_channels, list) or len(reward_channels) != 18
268
+ or any(not isinstance(item, dict) or set(item) != {"metric", "type"}
269
+ or not isinstance(item["metric"], str) or item["metric"] not in METRICS
270
+ or not isinstance(item["type"], str) or item["type"] not in {"default", "average"}
271
+ for item in reward_channels)
272
+ or reward_channels != [{"metric": metric, "type": kind} for metric in metric_names for kind in ("default", "average")
273
+ if not (kind == "default" and metric in AVERAGE_ONLY_METRICS)
274
+ and not (kind == "average" and metric not in AVERAGE_METRICS)]):
275
+ raise BundleError("analysis metric or reward channel definitions are invalid")
276
+ settings = analysis.get("settings")
277
+ if not isinstance(settings, dict) or set(settings) != RECIPE_KEYS:
278
+ raise BundleError("analysis validated settings are missing")
279
+ validate_settings(settings)
280
+ if (settings.get("mode") != mode or settings.get("ticks") != conditions["requested_ticks"]
281
+ or settings.get("control_hz") != conditions["control_hz"]
282
+ or settings.get("collision") != conditions["collision"]
283
+ or settings.get("idle") != conditions["idle"]
284
+ or settings.get("clean_min_laps") != conditions["clean_min_laps"]):
285
+ raise BundleError("analysis settings do not match its conditions")
286
+ if (type(settings.get("population")) is not int or not 1 <= settings["population"] <= 128
287
+ or type(settings.get("generations")) is not int or not 1 <= settings["generations"] <= 1000
288
+ or observations["generations"] != (1 if mode == "evaluate" else settings["generations"])
289
+ or expected_generation_cars != (1 if mode == "evaluate" else settings["population"])):
290
+ raise BundleError("analysis observation dimensions do not match its validated settings")
291
+ for role in ("fitness", "fastest_lap", "clean_laps"):
292
+ winner = winners[role]
293
+ winner_keys = {"status", "reason", "candidate_id", "generation", "car", "selection_fitness",
294
+ "best_lap_seconds", "forward_laps", "physical_contacts", "collision_updates",
295
+ "total_actual_distance", "artifact"}
296
+ if (not isinstance(winner, dict) or set(winner) != winner_keys
297
+ or not isinstance(winner.get("status"), str) or winner["status"] not in {"available", "unavailable"}):
298
+ raise BundleError("analysis winner status is invalid")
299
+ artifact = winner.get("artifact")
300
+ expected_name = f"winners/{role}.evolution"
301
+ if winner["status"] == "available":
302
+ if (not isinstance(winner["candidate_id"], str)
303
+ or not re.fullmatch(r"g[0-9]{6}-c[0-9]{6}", winner["candidate_id"])
304
+ or type(winner["generation"]) is not int or not 1 <= winner["generation"] <= observations["generations"]
305
+ or type(winner["car"]) is not int or not 0 <= winner["car"] < expected_generation_cars
306
+ or not _is_number(winner["selection_fitness"], -1e12, 1e12)
307
+ or (winner["best_lap_seconds"] is not None and not _is_number(winner["best_lap_seconds"], 0, 1e12))
308
+ or any(type(winner[k]) is not int or not 0 <= winner[k] <= 4_000_000
309
+ for k in ("forward_laps", "physical_contacts", "collision_updates"))
310
+ or (winner["total_actual_distance"] is not None and not _is_number(winner["total_actual_distance"], 0, 1e12))
311
+ or winner["reason"] is not None):
312
+ raise BundleError("available winner observations are invalid")
313
+ if not isinstance(artifact, dict) or set(artifact) != {"name", "bytes", "sha256"}:
314
+ raise BundleError("available winner artifact metadata is invalid")
315
+ content = inner.get(expected_name)
316
+ if (artifact["name"] != expected_name or content is None
317
+ or artifact["bytes"] != len(content)
318
+ or artifact["sha256"] != hashlib.sha256(content).hexdigest()):
319
+ raise BundleError("winner file does not match its analysis manifest")
320
+ if _game_network_input(content)["Type"] != conditions["vehicle_type"]:
321
+ raise BundleError("winner network vehicle does not match the analysis conditions")
322
+ else:
323
+ if (artifact is not None or expected_name in inner or not isinstance(winner["reason"], str)
324
+ or any(winner[k] is not None for k in ("candidate_id", "generation", "car", "selection_fitness",
325
+ "best_lap_seconds", "forward_laps", "physical_contacts", "collision_updates", "total_actual_distance"))):
326
+ raise BundleError("unavailable winner metadata is invalid")
327
+ if mode == "train" and winners["fitness"]["status"] != "available":
328
+ raise BundleError("training analysis requires its fitness winner")
329
+ if mode == "evaluate" and any(winners[role]["status"] != "unavailable" for role in winners):
330
+ raise BundleError("evaluation analysis cannot select winners")
331
+ expected_files = {"analysis.json", "descriptors.jsonl"}
332
+ if mode == "train":
333
+ expected_files |= {f"winners/{role}.evolution" for role in winners if winners[role]["status"] == "available"}
334
+ else:
335
+ expected_files.add("evaluation.json")
336
+ if set(inner) != expected_files:
337
+ raise BundleError("analysis archive file set does not match its mode and winner manifest")
338
+ if mode == "evaluate":
339
+ evaluation = _parse_json(inner["evaluation.json"], limit=ANALYSIS_JSON_LIMIT, label="evaluation metadata")
340
+ if (not isinstance(evaluation, dict)
341
+ or set(evaluation) != {"schema", "version", "conditions", "input_sha256", "weights_unchanged", "measurements"}
342
+ or evaluation.get("schema") != "ald.frozen-evaluation"
343
+ or type(evaluation.get("version")) is not int or evaluation["version"] != 1
344
+ or evaluation.get("conditions") != conditions
345
+ or evaluation.get("weights_unchanged") is not True
346
+ or evaluation.get("input_sha256") != conditions["network_sha256"]):
347
+ raise BundleError("frozen evaluation metadata is invalid")
348
+ rows = list(io.BytesIO(inner["descriptors.jsonl"]))
349
+ if len(rows) != 1 or _parse_json(rows[0].rstrip(b"\n"), limit=64 * 1024, label="evaluation descriptor") != evaluation.get("measurements"):
350
+ raise BundleError("frozen evaluation measurement does not match its descriptor")
351
+ selected_ids = {winner["candidate_id"]: (role, winner) for role, winner in winners.items()
352
+ if winner["status"] == "available"}
353
+ selected_rows = {}
354
+ population = expected_generation_cars
355
+ expected_role_rows: dict[str, dict[str, Any] | None] = {"fitness": None, "fastest_lap": None, "clean_laps": None}
356
+ for index, line in enumerate(io.BytesIO(inner["descriptors.jsonl"])):
357
+ line = line[:-1] if line.endswith(b"\n") else line
358
+ row = _parse_json(line, limit=64 * 1024, label="analysis descriptor row")
359
+ expected_generation = index // population + 1
360
+ expected_car = index % population
361
+ if row["generation"] != expected_generation or row["car"] != expected_car:
362
+ raise BundleError("analysis descriptor ordering is invalid")
363
+ if mode == "train":
364
+ if row["generation"] == observations["generations"]:
365
+ current = expected_role_rows["fitness"]
366
+ if current is None or row["selection_fitness"] > current["selection_fitness"]:
367
+ expected_role_rows["fitness"] = row
368
+ lap = row["objectives"]["best_lap_seconds"]
369
+ forward_laps = row["objectives"]["forward_laps"]
370
+ if lap is not None and lap > 0 and forward_laps >= 1:
371
+ current = expected_role_rows["fastest_lap"]
372
+ if current is None or lap < current["objectives"]["best_lap_seconds"]:
373
+ expected_role_rows["fastest_lap"] = row
374
+ if (forward_laps >= conditions["clean_min_laps"]
375
+ and row["objectives"]["physical_contacts"] == 0):
376
+ current = expected_role_rows["clean_laps"]
377
+ if current is None or lap < current["objectives"]["best_lap_seconds"]:
378
+ expected_role_rows["clean_laps"] = row
379
+ if row["candidate_id"] in selected_ids:
380
+ selected_rows[row["candidate_id"]] = row
381
+ if set(selected_rows) != set(selected_ids):
382
+ raise BundleError("winner metadata does not identify observed descriptor rows")
383
+ for role, expected in expected_role_rows.items():
384
+ winner = winners[role]
385
+ if expected is None:
386
+ expected_reason = "no_completed_lap" if role == "fastest_lap" else "no_eligible_clean_laps"
387
+ if mode == "evaluate":
388
+ expected_reason = "frozen_evaluation_does_not_select_winners"
389
+ if winner["status"] != "unavailable" or winner["reason"] != expected_reason:
390
+ raise BundleError("winner availability does not match the complete descriptor stream")
391
+ elif winner["status"] != "available" or winner["candidate_id"] != expected["candidate_id"]:
392
+ raise BundleError("winner selection does not match the complete descriptor stream")
393
+ for candidate_id, (role, winner) in selected_ids.items():
394
+ row = selected_rows[candidate_id]
395
+ objective = row["objectives"]
396
+ if (winner["generation"] != row["generation"] or winner["car"] != row["car"]
397
+ or winner["selection_fitness"] != row["selection_fitness"]
398
+ or _network_weights_sha256(inner[f"winners/{role}.evolution"]) != row["weights_sha256"]
399
+ or any(winner[key] != objective[key] for key in (
400
+ "best_lap_seconds", "forward_laps", "physical_contacts", "collision_updates", "total_actual_distance"))):
401
+ raise BundleError("winner metadata does not match its observed descriptor")
402
+ return analysis, inner
403
+
404
+
405
+ def make_analysis_archive(files: Any, *, primary: bytes, analysis_summary: Any = None) -> bytes:
406
+ if (not isinstance(files, dict) or not files
407
+ or any(not isinstance(name, str) for name in files)):
408
+ raise BundleError("analysis files must be a nonempty mapping")
409
+ if not isinstance(analysis_summary, dict):
410
+ raise BundleError("public analysis summary is required")
411
+ entries = []
412
+ total = 0
413
+ for name in sorted(files):
414
+ limit = _analysis_member_limit(name) if isinstance(name, str) else 0
415
+ content = files[name]
416
+ if not isinstance(content, bytes) or not content or len(content) > limit:
417
+ raise BundleError("analysis member is not bounded bytes")
418
+ total += len(content)
419
+ if total > MAX_ANALYSIS_FILE_BYTES:
420
+ raise BundleError("analysis files exceed 192 MiB")
421
+ entries.append({"name": name, "bytes": len(content),
422
+ "sha256": hashlib.sha256(content).hexdigest(),
423
+ "data_base64": base64.b64encode(content).decode("ascii")})
424
+ envelope = {"schema": "ald.run-analysis-archive", "version": 1, "files": entries}
425
+ expanded = _json_bytes(envelope)
426
+ if len(expanded) > MAX_ANALYSIS_EXPANDED:
427
+ raise BundleError("expanded analysis archive exceeds 256 MiB")
428
+ raw = gzip.compress(expanded, mtime=0)
429
+ if len(raw) > MAX_ANALYSIS_ARCHIVE:
430
+ raise BundleError("compressed analysis archive exceeds 32 MiB")
431
+ analysis, members = load_analysis_archive(raw)
432
+ fitness = members.get("winners/fitness.evolution")
433
+ if analysis.get("mode") == "train" and fitness != primary:
434
+ raise BundleError("primary result is not the manifested final fitness winner")
435
+ if (analysis.get("mode") == "evaluate"
436
+ and hashlib.sha256(primary).hexdigest() != analysis.get("conditions", {}).get("network_sha256")):
437
+ raise BundleError("frozen evaluation primary is not the original input network")
438
+ projection = {key: analysis.get(key) for key in ANALYSIS_SUMMARY_KEYS}
439
+ if analysis_summary != projection:
440
+ raise BundleError("public analysis summary does not match the private analysis archive")
441
+ return raw
442
+
443
+
444
  def _no_constant(value: str) -> None:
445
  raise BundleError(f"Non-finite JSON number is not allowed: {value}")
446
 
 
466
  raise BundleError(f"{label} exceeds its byte limit")
467
  try:
468
  return json.loads(raw.decode("utf-8"), object_pairs_hook=_unique_object, parse_constant=_no_constant)
469
+ except (UnicodeDecodeError, json.JSONDecodeError, RecursionError) as exc:
470
  raise BundleError(f"{label} must be valid UTF-8 JSON") from exc
471
 
472
 
 
488
 
489
 
490
  def validate_settings(value: Any) -> dict[str, Any]:
491
+ settings = _strict_keys(value, RECIPE_KEYS, LEGACY_RECIPE_KEYS, "recipe settings")
492
+ settings.setdefault("mode", "train")
493
+ settings.setdefault("clean_min_laps", 2)
494
  bounds = {
495
  "population": (1, 128), "generations": (1, 1000), "ticks": (1, 18000),
496
  "seed": (0, 2147483647), "parents": (1, 128), "elite": (0, 128), "workers": (1, 4),
497
+ "clean_min_laps": (1, 100),
498
  }
499
  if any(not _is_int(settings[k], *bounds[k]) for k in bounds):
500
  raise BundleError("recipe settings contain an out-of-range integer")
501
  for key in ("mutation", "mutation_end"):
502
  if not _is_number(settings[key], 0, 4.2):
503
  raise BundleError("recipe mutation rates must be finite and between 0 and 4.2")
504
+ if not isinstance(settings["mode"], str) or settings["mode"] not in {"train", "evaluate"}:
505
+ raise BundleError("recipe mode is unsupported")
506
+ if settings["mode"] == "train" and (settings["mutation"] <= 0 or (settings["schedule"] != "fixed" and settings["mutation_end"] <= 0)):
507
  raise BundleError("recipe mutation rates must be positive for the selected schedule")
508
+ if settings["mode"] == "evaluate" and (settings["population"] != 1 or settings["generations"] != 1
509
+ or settings["mutation"] != 0 or settings["mutation_end"] != 0 or settings["decay"] != 0
510
+ or settings["adaptive_mutation"] is not False or settings["parents"] != 1 or settings["elite"] != 0):
511
+ raise BundleError("frozen evaluation settings are not canonical")
512
  if not _is_number(settings["decay"], 0, 1):
513
  raise BundleError("recipe decay must be between 0 and 1")
514
  choices = {
 
555
  return recipe
556
 
557
 
558
+ def validate_receipt(value: Any, *, allow_analysis_summary: bool = False) -> dict[str, Any]:
559
  receipt = _strict_keys(value, RECEIPT_KEYS, RECEIPT_KEYS, "run receipt")
560
  if type(receipt["version"]) is not int or receipt["version"] != 1 or receipt["kind"] != "run":
561
  raise BundleError("run receipt version or kind is unsupported")
 
566
  raise BundleError("run receipt build id is invalid")
567
  if not isinstance(receipt["output_sha256"], str) or not HEX64.fullmatch(receipt["output_sha256"]):
568
  raise BundleError("run receipt output hash is invalid")
569
+ optional_report = {"history"}
570
+ if allow_analysis_summary:
571
+ optional_report.add("analysis_summary")
572
+ report = _strict_keys(receipt["report"], REPORT_KEYS | optional_report, REPORT_KEYS, "run receipt report")
573
+ if "analysis_summary" in report:
574
+ if not allow_analysis_summary:
575
+ raise BundleError("legacy run receipt contains unsupported analysis summary")
576
+ summary = _strict_keys(report["analysis_summary"], ANALYSIS_SUMMARY_KEYS,
577
+ ANALYSIS_SUMMARY_KEYS, "analysis summary")
578
+ if (summary["schema"] != "ald.run-analysis" or type(summary["version"]) is not int
579
+ or summary["version"] != 1 or not isinstance(summary["mode"], str)
580
+ or summary["mode"] not in {"train", "evaluate"}
581
+ or not isinstance(summary["execution_backend"], str)
582
+ or summary["execution_backend"] not in {"cpu", "cuda"}
583
+ or not isinstance(summary["conditions"], dict)
584
+ or not isinstance(summary["observations"], dict)
585
+ or not isinstance(summary["winners"], dict)):
586
+ raise BundleError("analysis summary is invalid")
587
  if not _is_int(report["vehicle_type"], 0, 3) or report["vehicle_name"] != ("Formula", "Rally", "Snowmobile", "Truck")[report["vehicle_type"]]:
588
  raise BundleError("run receipt vehicle is invalid")
589
  for key in ("generations_completed", "generations_requested", "population", "simulated_ticks", "requested_ticks"):
 
722
  return raw
723
 
724
 
725
+ def _decode_analysis_slot(value: Any) -> bytes:
726
+ slot = _strict_keys(value, {"format", "bytes", "sha256", "data_base64"},
727
+ {"format", "bytes", "sha256", "data_base64"}, "analysis archive slot")
728
+ return _decode_slot(slot, role="analysis archive", limit=MAX_ANALYSIS_ARCHIVE,
729
+ fmt=ANALYSIS_ARCHIVE_FORMAT, allowed_extra=set(), required_extra=set())
730
+
731
+
732
  def load_bundle(raw: bytes, *, catalog_path: Path | None = None) -> tuple[dict[str, Any], dict[str, bytes]]:
733
  value = _parse_json(raw, limit=MAX_BUNDLE, label="bundle")
734
  bundle = _strict_keys(value, {"schema", "version", "artifacts"}, {"schema", "version", "artifacts"}, "bundle")
735
+ version = bundle["version"]
736
+ if bundle["schema"] != SCHEMA or type(version) is not int or version not in {1, 2}:
737
  raise BundleError("bundle schema or version is unsupported")
738
+ if version == 1 and len(raw) > MAX_BUNDLE_V1:
739
+ raise BundleError("legacy bundle exceeds its 8 MiB limit")
740
  artifacts = bundle["artifacts"]
741
+ allowed_artifacts = {"selected_network", "selected_track", "selected_recipe", "run_receipt"}
742
+ if version == 2:
743
+ allowed_artifacts.add("analysis_archive")
744
+ if (not isinstance(artifacts, dict) or not {"selected_network"} <= set(artifacts)
745
+ or set(artifacts) - allowed_artifacts
746
+ or (version == 2 and "analysis_archive" not in artifacts)):
747
  raise BundleError("bundle must contain a selected network and only supported optional artifacts")
748
  files: dict[str, bytes] = {}
749
  net = _strict_keys(artifacts["selected_network"], {"format", "kind", "bytes", "sha256", "data_base64"}, {"format", "kind", "bytes", "sha256", "data_base64"}, "selected network")
 
789
  raise BundleError("selected recipe exceeds 32 KiB")
790
  files["selected_recipe.json"] = recipe_bytes
791
 
792
+ analysis = None
793
+ analysis_members: dict[str, bytes] = {}
794
+ if version == 2:
795
+ archive_bytes = _decode_analysis_slot(artifacts["analysis_archive"])
796
+ analysis, analysis_members = load_analysis_archive(archive_bytes)
797
+ files["analysis_archive.gz"] = archive_bytes
798
+ files.update({f"analysis/{name}": content for name, content in analysis_members.items()})
799
+ if net["kind"] != "result" or "run_receipt" not in artifacts or "selected_track" not in artifacts:
800
+ raise BundleError("analysis bundles must contain a result network and run receipt")
801
+
802
  if "run_receipt" in artifacts:
803
+ receipt = validate_receipt(artifacts["run_receipt"], allow_analysis_summary=version == 2)
804
  report = receipt["report"]
805
  network_sha = net["sha256"]
806
+ if version == 2:
807
+ if not isinstance(report.get("analysis_summary"), dict):
808
+ raise BundleError("v2 run receipt is missing its analysis summary")
809
+ projection = {key: analysis.get(key) for key in ANALYSIS_SUMMARY_KEYS}
810
+ if report["analysis_summary"] != projection:
811
+ raise BundleError("run receipt analysis summary does not match its archive")
812
+ conditions = analysis.get("conditions")
813
+ if (not isinstance(conditions, dict)
814
+ or conditions.get("track_sha256") != report["inputs"]["track_sha256"]
815
+ or conditions.get("network_sha256") != report["inputs"]["network_sha256"]
816
+ or conditions.get("vehicle_type") != report["vehicle_type"]
817
+ or conditions.get("requested_ticks") != report["requested_ticks"]
818
+ or analysis.get("execution_backend") != report["execution_backend"]):
819
+ raise BundleError("analysis archive conditions do not match the run receipt")
820
+ if analysis.get("mode") == "evaluate":
821
+ evaluation = _parse_json(analysis_members["evaluation.json"], limit=ANALYSIS_JSON_LIMIT,
822
+ label="evaluation metadata")
823
+ if (evaluation.get("schema") != "ald.frozen-evaluation"
824
+ or evaluation.get("version") != 1
825
+ or evaluation.get("weights_unchanged") is not True
826
+ or evaluation.get("input_sha256") != report["inputs"]["network_sha256"]
827
+ or network_sha != report["inputs"]["network_sha256"]):
828
+ raise BundleError("frozen evaluation does not preserve the input network")
829
+ if _network_weights_sha256(files["selected_network.evolution"]) != evaluation["measurements"]["weights_sha256"]:
830
+ raise BundleError("frozen evaluation descriptor does not match the input weights")
831
+ else:
832
+ fitness = analysis["winners"]["fitness"]
833
+ fitness_bytes = analysis_members["winners/fitness.evolution"]
834
+ if (fitness.get("artifact", {}).get("sha256") != receipt["output_sha256"]
835
+ or hashlib.sha256(fitness_bytes).hexdigest() != receipt["output_sha256"]):
836
+ raise BundleError("run output hash does not match its final fitness winner")
837
+ selected_is_winner = any(
838
+ analysis["winners"][role]["status"] == "available"
839
+ and analysis_members.get(f"winners/{role}.evolution") == files["selected_network.evolution"]
840
+ for role in ("fitness", "fastest_lap", "clean_laps")
841
+ )
842
+ if not selected_is_winner:
843
+ raise BundleError("selected network is not byte-identical to an archived winner")
844
+ elif net["kind"] == "input":
845
  network_matches = report["inputs"]["network_sha256"] == network_sha
846
  else:
847
  network_matches = receipt["output_sha256"] == network_sha
848
+ if version == 1 and not network_matches:
849
  raise BundleError("run receipt does not describe the bundled network")
850
  if "selected_track" in artifacts and report["inputs"]["track_sha256"] != artifacts["selected_track"]["sha256"]:
851
  raise BundleError("run receipt does not describe the bundled track")
server.py CHANGED
@@ -8,20 +8,23 @@ import threading
8
  from fastapi import HTTPException, Request
9
  from fastapi.responses import FileResponse, Response, HTMLResponse, JSONResponse
10
  from starlette.concurrency import run_in_threadpool
11
- from delivery import create_api, CapacityExceeded
12
  from worker import run_reported
13
  from build_info import build_identity
14
  from training_config import validate_config
15
  from hf_identity import oauth_config
16
  from builtin_tracks import TrackLibrary
17
  from steam_leaderboard import compare_public_best_lap, milliseconds, SteamUnavailable
18
- from bucket_storage import BucketStorageError, MAX_REQUEST as BUCKET_MAX_REQUEST, run_bucket_operation, valid_bucket_id, valid_bundle_key
19
- from portable_bundle import BundleError, load_bundle, MAX_BUNDLE, MAX_NETWORK
 
 
 
20
 
21
  ROOT=Path(__file__).resolve().parent
22
- EVOLUTION_SIDECAR=re.compile(r'^[a-z0-9][a-z0-9-]{0,63}-(formula|rally|snowmobile|truck)-[0-9]{8}T[0-9]{6}Z-[0-9a-f]{8}\.evolution$')
23
 
24
- def create_app(worker=run_reported,store=None,gpu_worker=None,track_library=None):
25
  app=create_api(store)
26
  admission=threading.BoundedSemaphore(1)
27
  leaderboard_admission=threading.BoundedSemaphore(1)
@@ -96,10 +99,24 @@ def create_app(worker=run_reported,store=None,gpu_worker=None,track_library=None
96
  except BundleError: raise HTTPException(400,'Invalid portable bundle') from None
97
  bundle_raw=bundle.encode('utf-8')
98
  call_args={'bundle_base64':base64.b64encode(bundle_raw).decode('ascii')}
 
 
 
 
 
 
 
 
 
 
99
  elif operation=='restore':
100
  if not valid_bundle_key(body.get('key')):
101
  raise HTTPException(400,'Invalid storage request')
102
  call_args={'key':body['key']}
 
 
 
 
103
  else:
104
  call_args={}
105
  try:
@@ -110,6 +127,9 @@ def create_app(worker=run_reported,store=None,gpu_worker=None,track_library=None
110
  result['_validated_bundle_bytes']=bundle_raw
111
  result['_validated_bundle_files']=bundle_files
112
  result['_validated_bundle_has_receipt']='run_receipt' in bundle_obj['artifacts']
 
 
 
113
  return result
114
  finally:
115
  bucket_admission.release()
@@ -134,7 +154,19 @@ def create_app(worker=run_reported,store=None,gpu_worker=None,track_library=None
134
  required={'bundle.json'}
135
  if has_receipt:
136
  required|={'report.md','generation-history.csv'}
137
- evolution=[name for name in verified if isinstance(name,str) and EVOLUTION_SIDECAR.fullmatch(name)] if isinstance(verified,dict) else []
 
 
 
 
 
 
 
 
 
 
 
 
138
  if len(evolution)!=1 or not isinstance(verified,dict) or set(verified)!=(required|{evolution[0]}):
139
  raise HTTPException(502,'Storage operation failed')
140
  if not isinstance(raw,bytes) or not isinstance(bundle_files,dict):
@@ -142,23 +174,81 @@ def create_app(worker=run_reported,store=None,gpu_worker=None,track_library=None
142
  network=bundle_files['selected_network.evolution']
143
  safe_verified={}
144
  total=0
 
145
  for name,proof in verified.items():
146
  if (not isinstance(proof,dict) or set(proof)!={'bytes','sha256'}
147
- or type(proof['bytes']) is not int or not 0<=proof['bytes']<=MAX_BUNDLE
148
  or not isinstance(proof['sha256'],str) or not re.fullmatch(r'[0-9a-f]{64}',proof['sha256'])):
149
  raise HTTPException(502,'Storage operation failed')
150
  total+=proof['bytes']
151
  safe_verified[name]={'bytes':proof['bytes'],'sha256':proof['sha256']}
152
- if (total>MAX_BUNDLE+MAX_NETWORK+1024*1024
153
  or safe_verified['bundle.json']!={'bytes':len(raw),'sha256':hashlib.sha256(raw).hexdigest()}
154
  or safe_verified[evolution[0]]!={'bytes':len(network),'sha256':hashlib.sha256(network).hexdigest()}):
155
  raise HTTPException(502,'Storage operation failed')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
  return JSONResponse({key:result[key] for key in ('status','bucket_id','key','bytes','sha256')}|{'verified_files':safe_verified},headers={'Cache-Control':'no-store'})
157
 
158
  @app.post('/api/bucket/restore')
159
  async def bucket_restore(request:Request):
160
  result=await bucket_operation(request,'restore',fields={'bucket_id','key'})
161
  return Response(result['bundle_bytes'],media_type='application/json',headers={'Cache-Control':'no-store','X-Content-Type-Options':'nosniff'})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
  @app.get('/api/tracks')
163
  def track_catalog():
164
  return JSONResponse(library.catalog(),headers={'Cache-Control':'no-store'})
@@ -260,8 +350,11 @@ def create_app(worker=run_reported,store=None,gpu_worker=None,track_library=None
260
  try:
261
  result=await run_in_threadpool(selected,track,network,config) if config is not None else await run_in_threadpool(selected,track,network)
262
  payload,report=result if isinstance(result,tuple) else (result,None)
263
- return {**app.state.results.publish(payload,automatic),'backend':backend,'report':report}
 
 
264
  except CapacityExceeded: raise HTTPException(503,'Result capacity reached') from None
 
265
  except Exception: raise HTTPException(422,'Processing failed; no result published. Cleanup status requires verification.') from None
266
  finally: admission.release()
267
  return app
 
8
  from fastapi import HTTPException, Request
9
  from fastapi.responses import FileResponse, Response, HTMLResponse, JSONResponse
10
  from starlette.concurrency import run_in_threadpool
11
+ from delivery import create_api, CapacityExceeded, InvalidAnalysisOutput, publish_worker_result
12
  from worker import run_reported
13
  from build_info import build_identity
14
  from training_config import validate_config
15
  from hf_identity import oauth_config
16
  from builtin_tracks import TrackLibrary
17
  from steam_leaderboard import compare_public_best_lap, milliseconds, SteamUnavailable
18
+ from bucket_storage import (BucketStorageError, MAX_REQUEST as BUCKET_MAX_REQUEST,
19
+ MAX_RECIPE_LIBRARY, run_bucket_operation, valid_bucket_id, valid_bundle_key,
20
+ valid_recipe_key, validate_recipe_library, AUTOSAVE_APP_KEY, winner_sidecar_filename)
21
+ from portable_bundle import (BundleError, load_bundle, MAX_BUNDLE, MAX_NETWORK,
22
+ MAX_ANALYSIS_ARCHIVE, MAX_ANALYSIS_FILE_BYTES)
23
 
24
  ROOT=Path(__file__).resolve().parent
25
+ EVOLUTION_SIDECAR=re.compile(r'^[a-z0-9][a-z0-9-]{0,63}-(formula|rally|snowmobile|truck)-[0-9]{8}T[0-9]{6}Z-[0-9a-f]{8}(?:-(fitness-winner|fastest-lap-winner|clean-laps-winner))?\.evolution$')
26
 
27
+ def create_app(worker=run_reported,store=None,gpu_worker=None,track_library=None,require_analysis=False):
28
  app=create_api(store)
29
  admission=threading.BoundedSemaphore(1)
30
  leaderboard_admission=threading.BoundedSemaphore(1)
 
99
  except BundleError: raise HTTPException(400,'Invalid portable bundle') from None
100
  bundle_raw=bundle.encode('utf-8')
101
  call_args={'bundle_base64':base64.b64encode(bundle_raw).decode('ascii')}
102
+ elif operation=='recipes_save':
103
+ library=body.get('library')
104
+ if not isinstance(library,str): raise HTTPException(400,'Invalid recipe library')
105
+ try: library_raw=library.encode('utf-8')
106
+ except UnicodeEncodeError: raise HTTPException(400,'Invalid recipe library') from None
107
+ if len(library_raw)>MAX_RECIPE_LIBRARY:
108
+ raise HTTPException(413,'Recipe library too large')
109
+ try: validate_recipe_library(library_raw)
110
+ except Exception: raise HTTPException(400,'Invalid recipe library') from None
111
+ call_args={'library_base64':base64.b64encode(library_raw).decode('ascii')}
112
  elif operation=='restore':
113
  if not valid_bundle_key(body.get('key')):
114
  raise HTTPException(400,'Invalid storage request')
115
  call_args={'key':body['key']}
116
+ elif operation=='recipes_restore':
117
+ if not valid_recipe_key(body.get('key')):
118
+ raise HTTPException(400,'Invalid storage request')
119
+ call_args={'key':body['key']}
120
  else:
121
  call_args={}
122
  try:
 
127
  result['_validated_bundle_bytes']=bundle_raw
128
  result['_validated_bundle_files']=bundle_files
129
  result['_validated_bundle_has_receipt']='run_receipt' in bundle_obj['artifacts']
130
+ if operation=='recipes_save':
131
+ result['_validated_library_bytes']=library_raw
132
+ result['_validated_bucket_id']=body['bucket_id']
133
  return result
134
  finally:
135
  bucket_admission.release()
 
154
  required={'bundle.json'}
155
  if has_receipt:
156
  required|={'report.md','generation-history.csv'}
157
+ if isinstance(bundle_files,dict) and 'analysis_archive.gz' in bundle_files:
158
+ required.add('analysis-archive.gz')
159
+ required.update(name for name in bundle_files if name.startswith('analysis/'))
160
+ key_match=AUTOSAVE_APP_KEY.fullmatch(result.get('key',''))
161
+ if key_match is None:
162
+ raise HTTPException(502,'Storage operation failed')
163
+ for role in ('fitness','fastest_lap','clean_laps'):
164
+ inner=f'analysis/winners/{role}.evolution'
165
+ if inner in bundle_files:
166
+ required.add(winner_sidecar_filename(key_match.group('track'),key_match.group('vehicle'),
167
+ key_match.group('timestamp'),key_match.group('uuid')[:8],role))
168
+ evolution=[name for name in verified if isinstance(name,str)
169
+ and (match:=EVOLUTION_SIDECAR.fullmatch(name)) and match.group(2) is None] if isinstance(verified,dict) else []
170
  if len(evolution)!=1 or not isinstance(verified,dict) or set(verified)!=(required|{evolution[0]}):
171
  raise HTTPException(502,'Storage operation failed')
172
  if not isinstance(raw,bytes) or not isinstance(bundle_files,dict):
 
174
  network=bundle_files['selected_network.evolution']
175
  safe_verified={}
176
  total=0
177
+ per_file_limit=MAX_ANALYSIS_FILE_BYTES
178
  for name,proof in verified.items():
179
  if (not isinstance(proof,dict) or set(proof)!={'bytes','sha256'}
180
+ or type(proof['bytes']) is not int or not 0<=proof['bytes']<=per_file_limit
181
  or not isinstance(proof['sha256'],str) or not re.fullmatch(r'[0-9a-f]{64}',proof['sha256'])):
182
  raise HTTPException(502,'Storage operation failed')
183
  total+=proof['bytes']
184
  safe_verified[name]={'bytes':proof['bytes'],'sha256':proof['sha256']}
185
+ if (total>MAX_BUNDLE+MAX_NETWORK+320*1024*1024
186
  or safe_verified['bundle.json']!={'bytes':len(raw),'sha256':hashlib.sha256(raw).hexdigest()}
187
  or safe_verified[evolution[0]]!={'bytes':len(network),'sha256':hashlib.sha256(network).hexdigest()}):
188
  raise HTTPException(502,'Storage operation failed')
189
+ analysis_archive=bundle_files.get('analysis_archive.gz')
190
+ if analysis_archive is not None and safe_verified.get('analysis-archive.gz')!={
191
+ 'bytes':len(analysis_archive),'sha256':hashlib.sha256(analysis_archive).hexdigest()}:
192
+ raise HTTPException(502,'Storage operation failed')
193
+ for filename,content in bundle_files.items():
194
+ if filename.startswith('analysis/') and safe_verified.get(filename)!={
195
+ 'bytes':len(content),'sha256':hashlib.sha256(content).hexdigest()}:
196
+ raise HTTPException(502,'Storage operation failed')
197
+ for role in ('fitness','fastest_lap','clean_laps'):
198
+ inner=f'analysis/winners/{role}.evolution'
199
+ key_match=AUTOSAVE_APP_KEY.fullmatch(result.get('key',''))
200
+ sidecar=(winner_sidecar_filename(key_match.group('track'),key_match.group('vehicle'),
201
+ key_match.group('timestamp'),key_match.group('uuid')[:8],role) if key_match else '')
202
+ if inner in bundle_files and safe_verified.get(sidecar)!={
203
+ 'bytes':len(bundle_files[inner]),'sha256':hashlib.sha256(bundle_files[inner]).hexdigest()}:
204
+ raise HTTPException(502,'Storage operation failed')
205
  return JSONResponse({key:result[key] for key in ('status','bucket_id','key','bytes','sha256')}|{'verified_files':safe_verified},headers={'Cache-Control':'no-store'})
206
 
207
  @app.post('/api/bucket/restore')
208
  async def bucket_restore(request:Request):
209
  result=await bucket_operation(request,'restore',fields={'bucket_id','key'})
210
  return Response(result['bundle_bytes'],media_type='application/json',headers={'Cache-Control':'no-store','X-Content-Type-Options':'nosniff'})
211
+
212
+ @app.post('/api/bucket/recipes/list')
213
+ async def bucket_recipes_list(request:Request):
214
+ result=await bucket_operation(request,'recipes_list',fields={'bucket_id'})
215
+ items=result.get('items')
216
+ if (not isinstance(items,list) or type(result.get('truncated')) is not bool
217
+ or type(result.get('scan_incomplete')) is not bool or len(items)>50):
218
+ raise HTTPException(502,'Storage operation failed')
219
+ clean=[]
220
+ for item in items:
221
+ if (not isinstance(item,dict) or set(item)!={'key','bytes','label','created_at'}
222
+ or not valid_recipe_key(item.get('key')) or type(item.get('bytes')) is not int
223
+ or not 0<=item['bytes']<=MAX_RECIPE_LIBRARY
224
+ or not isinstance(item.get('label'),str) or len(item['label'])>192
225
+ or (item.get('created_at') is not None and not isinstance(item.get('created_at'),str))):
226
+ raise HTTPException(502,'Storage operation failed')
227
+ clean.append(item)
228
+ return JSONResponse({'items':clean,'truncated':result['truncated'],
229
+ 'scan_incomplete':result['scan_incomplete']},headers={'Cache-Control':'no-store'})
230
+
231
+ @app.post('/api/bucket/recipes/save')
232
+ async def bucket_recipes_save(request:Request):
233
+ result=await bucket_operation(request,'recipes_save',fields={'bucket_id','library'},
234
+ limit=MAX_RECIPE_LIBRARY*6+4096)
235
+ raw=result.pop('_validated_library_bytes',None)
236
+ expected_bucket=result.pop('_validated_bucket_id',None)
237
+ verified=result.pop('verified_bytes',None)
238
+ if (not isinstance(raw,bytes) or verified!=raw or result.get('status')!='saved_verified'
239
+ or result.get('bucket_id')!=expected_bucket or not valid_recipe_key(result.get('key'))
240
+ or result.get('bytes')!=len(raw) or result.get('sha256')!=hashlib.sha256(raw).hexdigest()):
241
+ raise HTTPException(502,'Storage operation failed')
242
+ return JSONResponse({key:result[key] for key in ('status','bucket_id','key','bytes','sha256')},
243
+ headers={'Cache-Control':'no-store'})
244
+
245
+ @app.post('/api/bucket/recipes/restore')
246
+ async def bucket_recipes_restore(request:Request):
247
+ result=await bucket_operation(request,'recipes_restore',fields={'bucket_id','key'})
248
+ raw=result.get('recipe_bytes')
249
+ if not isinstance(raw,bytes) or not valid_recipe_key(result.get('key')):
250
+ raise HTTPException(502,'Storage operation failed')
251
+ return Response(raw,media_type='application/json',headers={'Cache-Control':'no-store','X-Content-Type-Options':'nosniff'})
252
  @app.get('/api/tracks')
253
  def track_catalog():
254
  return JSONResponse(library.catalog(),headers={'Cache-Control':'no-store'})
 
350
  try:
351
  result=await run_in_threadpool(selected,track,network,config) if config is not None else await run_in_threadpool(selected,track,network)
352
  payload,report=result if isinstance(result,tuple) else (result,None)
353
+ published,public_report=publish_worker_result(
354
+ app.state.results,payload,report,automatic,require_analysis=require_analysis)
355
+ return {**published,'backend':backend,'report':public_report}
356
  except CapacityExceeded: raise HTTPException(503,'Result capacity reached') from None
357
+ except InvalidAnalysisOutput: raise HTTPException(422,'Processing failed; complete analysis could not be verified.') from None
358
  except Exception: raise HTTPException(422,'Processing failed; no result published. Cleanup status requires verification.') from None
359
  finally: admission.release()
360
  return app
training_config.py CHANGED
@@ -2,7 +2,7 @@
2
  import copy
3
  import math
4
 
5
- DEFAULTS={'population':4,'generations':1,'ticks':60,'mutation':.01,'seed':42,
6
  'mutation_end':.01,'schedule':'fixed','selection':'best','parents':1,'elite':1,
7
  'crossover':'none','decay':0,'adaptive_mutation':False,'control_hz':30,'workers':1,
8
  'collision':False,'idle':False,'rewards':[{'metric':'total_score','type':'default','weight':100}]}
@@ -17,17 +17,17 @@ def validate_config(value=None):
17
  c={**copy.deepcopy(DEFAULTS),**value}
18
  bounds={'population':(1,128,True),'generations':(1,1000,True),'ticks':(1,18000,True),
19
  'mutation':(0,4.2,False),'mutation_end':(0,4.2,False),'seed':(0,2147483647,True),
20
- 'parents':(1,128,True),'elite':(0,128,True),'decay':(0,1,False),'workers':(1,4,True)}
21
  for key,(low,high,integer) in bounds.items():
22
  v=c[key]
23
  if type(v) not in (int,float) or not math.isfinite(v) or not low<=v<=high or (integer and type(v) is not int):raise ValueError('Invalid '+key)
24
- for key,choices in {'schedule':('fixed','linear','geometric'),'selection':('best','tournament','roulette'),
25
  'crossover':('none','single_point','uniform'),'control_hz':(60,30,15)}.items():
26
  if c[key] not in choices or (key=='control_hz' and type(c[key]) is not int):raise ValueError('Invalid '+key)
27
  for key in ('adaptive_mutation','collision','idle'):
28
  if type(c[key]) is not bool:raise ValueError('Invalid '+key)
29
- if c['parents']>c['population'] or c['elite']>c['population']:raise ValueError('Parents/elites exceed population')
30
- if c['mutation']<=0 or (c['schedule']!='fixed' and c['mutation_end']<=0):raise ValueError('This training runner requires positive mutation rates, including when continuing an uploaded network')
31
  # Echo the rate actually passed to the native CLI, not an unused form value.
32
  if c['schedule']=='fixed':c['mutation_end']=c['mutation']
33
  rewards=c['rewards']
@@ -39,5 +39,7 @@ def validate_config(value=None):
39
  seen.add(r['metric']);clean.append(dict(r))
40
  if not any(r['weight'] for r in clean):raise ValueError('A reward weight must be nonzero')
41
  c['rewards']=clean
 
 
42
  if c['population']*c['generations']*c['ticks']>4_000_000:raise ValueError('Run exceeds preview work limit (4,000,000 vehicle-ticks)')
43
  return c
 
2
  import copy
3
  import math
4
 
5
+ DEFAULTS={'mode':'train','clean_min_laps':2,'population':4,'generations':1,'ticks':60,'mutation':.01,'seed':42,
6
  'mutation_end':.01,'schedule':'fixed','selection':'best','parents':1,'elite':1,
7
  'crossover':'none','decay':0,'adaptive_mutation':False,'control_hz':30,'workers':1,
8
  'collision':False,'idle':False,'rewards':[{'metric':'total_score','type':'default','weight':100}]}
 
17
  c={**copy.deepcopy(DEFAULTS),**value}
18
  bounds={'population':(1,128,True),'generations':(1,1000,True),'ticks':(1,18000,True),
19
  'mutation':(0,4.2,False),'mutation_end':(0,4.2,False),'seed':(0,2147483647,True),
20
+ 'clean_min_laps':(1,100,True),'parents':(1,128,True),'elite':(0,128,True),'decay':(0,1,False),'workers':(1,4,True)}
21
  for key,(low,high,integer) in bounds.items():
22
  v=c[key]
23
  if type(v) not in (int,float) or not math.isfinite(v) or not low<=v<=high or (integer and type(v) is not int):raise ValueError('Invalid '+key)
24
+ for key,choices in {'mode':('train','evaluate'),'schedule':('fixed','linear','geometric'),'selection':('best','tournament','roulette'),
25
  'crossover':('none','single_point','uniform'),'control_hz':(60,30,15)}.items():
26
  if c[key] not in choices or (key=='control_hz' and type(c[key]) is not int):raise ValueError('Invalid '+key)
27
  for key in ('adaptive_mutation','collision','idle'):
28
  if type(c[key]) is not bool:raise ValueError('Invalid '+key)
29
+ if c['mode']=='train' and (c['parents']>c['population'] or c['elite']>c['population']):raise ValueError('Parents/elites exceed population')
30
+ if c['mode']=='train' and (c['mutation']<=0 or (c['schedule']!='fixed' and c['mutation_end']<=0)):raise ValueError('This training runner requires positive mutation rates, including when continuing an uploaded network')
31
  # Echo the rate actually passed to the native CLI, not an unused form value.
32
  if c['schedule']=='fixed':c['mutation_end']=c['mutation']
33
  rewards=c['rewards']
 
39
  seen.add(r['metric']);clean.append(dict(r))
40
  if not any(r['weight'] for r in clean):raise ValueError('A reward weight must be nonzero')
41
  c['rewards']=clean
42
+ if c['mode']=='evaluate':
43
+ c.update(population=1,generations=1,mutation=0,mutation_end=0,decay=0,adaptive_mutation=False,parents=1,elite=0,crossover='none',selection='best',schedule='fixed')
44
  if c['population']*c['generations']*c['ticks']>4_000_000:raise ValueError('Run exceeds preview work limit (4,000,000 vehicle-ticks)')
45
  return c
worker.py CHANGED
@@ -16,6 +16,7 @@ import time
16
  import uuid
17
  from training_config import validate_config
18
  from build_info import build_identity
 
19
 
20
  MODULE_DIR = Path(__file__).resolve().parent
21
  # Source checkout is nested; a hosted release is flat under /app. Deployment
@@ -188,19 +189,23 @@ def run_native(track_bytes,network_bytes,*,binary=None,importer=None,scene_templ
188
  '--out-dir',str(out),'--mutation-start',str(config['mutation']),
189
  '--mutation-end',str(config['mutation'] if config['schedule']=='fixed' else config['mutation_end']),
190
  '--schedule','linear' if config['schedule']=='fixed' else config['schedule'],'--save-final-candidate',
 
191
  '--eliminate-on-wall' if config['collision'] else '--no-eliminate-on-wall',
192
  '--idle-eliminate' if config['idle'] else '--no-idle-eliminate']
 
193
  if gpu_library is not None: command.append('--gpu')
194
  subprocess.run(command,cwd=work,env=env,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,timeout=25,check=True)
195
- candidate=out/'candidate.json'
196
- if candidate.stat().st_size>1024*1024: raise ValueError('Output too large')
197
- trained=json.loads(candidate.read_text())
198
- n['Weights']=trained['weights']; n['Biases']=trained['biases']
199
- result=gzip.compress(json.dumps(template,allow_nan=False).encode(),mtime=0)
 
200
  network_input(result)
201
  history=[json.loads(line) for line in (out/'log.jsonl').read_text().splitlines() if line.strip()]
202
  last=history[-1] if history else {}
203
- report={'build':build_identity(),'state':'completed','elapsed_seconds':time.perf_counter()-started,
 
204
  'generations_completed':len(history),'generations_requested':config['generations'],'population':config['population'],
205
  'best_score':last.get('best_score'),'mean_score':last.get('mean_score'),
206
  'best_lap_seconds':best_run_lap(history),'best_lap_scope':'Best completed lap observed during this run; may belong to a different car than the exported candidate','lapped_cars':last.get('lapped_cars'),
 
16
  import uuid
17
  from training_config import validate_config
18
  from build_info import build_identity
19
+ from analysis_result import assemble_analysis
20
 
21
  MODULE_DIR = Path(__file__).resolve().parent
22
  # Source checkout is nested; a hosted release is flat under /app. Deployment
 
189
  '--out-dir',str(out),'--mutation-start',str(config['mutation']),
190
  '--mutation-end',str(config['mutation'] if config['schedule']=='fixed' else config['mutation_end']),
191
  '--schedule','linear' if config['schedule']=='fixed' else config['schedule'],'--save-final-candidate',
192
+ '--checkpoint-every','0','--analysis','--clean-min-laps',str(config['clean_min_laps']),
193
  '--eliminate-on-wall' if config['collision'] else '--no-eliminate-on-wall',
194
  '--idle-eliminate' if config['idle'] else '--no-idle-eliminate']
195
+ if config['mode']=='evaluate':command.append('--evaluate-frozen')
196
  if gpu_library is not None: command.append('--gpu')
197
  subprocess.run(command,cwd=work,env=env,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,timeout=25,check=True)
198
+ if config['mode']=='train':
199
+ candidate=out/'candidate.json'
200
+ if candidate.stat().st_size>1024*1024: raise ValueError('Output too large')
201
+ trained=json.loads(candidate.read_text())
202
+ n['Weights']=trained['weights']; n['Biases']=trained['biases']
203
+ result=(network_bytes if config['mode']=='evaluate' else gzip.compress(json.dumps(template,allow_nan=False).encode(),mtime=0))
204
  network_input(result)
205
  history=[json.loads(line) for line in (out/'log.jsonl').read_text().splitlines() if line.strip()]
206
  last=history[-1] if history else {}
207
+ files,summary,evaluation=assemble_analysis(out,template,result,network_bytes,track_bytes,config,'cuda' if gpu_library is not None else 'cpu',network_input)
208
+ report={'mode':config['mode'],'analysis_summary':summary,'_analysis_files':files,'build':build_identity(),'state':'completed','elapsed_seconds':time.perf_counter()-started,
209
  'generations_completed':len(history),'generations_requested':config['generations'],'population':config['population'],
210
  'best_score':last.get('best_score'),'mean_score':last.get('mean_score'),
211
  'best_lap_seconds':best_run_lap(history),'best_lap_scope':'Best completed lap observed during this run; may belong to a different car than the exported candidate','lapped_cars':last.get('lapped_cars'),