Spaces:
Running on Zero
Running on Zero
Add distinct winners, frozen evaluation and private-bucket recipes
Browse files- BINARY_PROVENANCE.json +1 -72
- BUCKET_BUNDLE.md +14 -4
- DATA_POLICY.md +13 -12
- FRONTEND_SOURCE.json +1 -10
- README.md +4 -0
- RELEASE_MANIFEST.json +1 -84
- STORAGE_BOUNDARY.md +4 -2
- UPSTREAM.json +1 -12
- WINNERS_AND_EVALUATION.md +37 -0
- analysis_result.py +153 -0
- app.py +7 -5
- bin/altd-sim +2 -2
- bucket_storage.py +287 -22
- build_info.py +15 -11
- client.js +537 -47
- delivery.py +82 -14
- index.html +56 -20
- native/analysis.patch +607 -0
- native/build-receipt.json +89 -0
- native/source-receipt.json +50 -0
- portable_bundle.py +495 -12
- server.py +102 -9
- training_config.py +7 -5
- worker.py +11 -6
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
|
| 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`
|
| 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.
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
|
|
|
| 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
|
| 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
|
|
|
|
| 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
|
| 15 |
| Batch intermediates | Same job contract, always automatic | Verified size/hash and deletion ACK before advancement |
|
| 16 |
-
| Received candidate
|
| 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 |
-
|
|
|
|
|
|
|
| 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:
|
| 3 |
-
size
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 275 |
raise BucketStorageError("invalid_request")
|
| 276 |
content = file_path.read_bytes()
|
| 277 |
-
if
|
| 278 |
-
if hashlib.sha256(content).hexdigest() != result.get("sha256")
|
|
|
|
| 279 |
raise BucketStorageError("storage_unavailable")
|
| 280 |
result["verified_bytes"] = content
|
| 281 |
else:
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 >
|
| 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 |
-
(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 {
|
|
|
|
| 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
|
| 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 /
|
| 10 |
-
upstream = json.loads((ROOT /
|
| 11 |
-
members = json.dumps(manifest[
|
|
|
|
| 12 |
return {
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
| 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($('
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 376 |
}
|
| 377 |
-
|
| 378 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 408 |
-
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 478 |
// The browser has the complete validated result. Disk-save success cannot be observed.
|
| 479 |
-
localResult=new Blob([bytes]);
|
| 480 |
-
const acknowledgment=await acknowledge(job,
|
| 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) {
|
|
|
|
|
|
|
|
|
|
| 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,{
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
|
|
|
| 521 |
runReceipt=stepRunReceipt;runResultContext=stepContext;runReceipt.delivery={received_and_hash_verified:true,server_copy_deleted:false};
|
| 522 |
-
const ack=await acknowledge(job,
|
| 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
|
|
|
|
| 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 |
-
|
| 665 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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?'
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
| 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)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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:
|
|
|
|
|
|
|
| 30 |
|
| 31 |
def _delete(self, ident):
|
| 32 |
record = self.jobs.pop(ident)
|
| 33 |
-
|
| 34 |
-
|
|
|
|
|
|
|
|
|
|
| 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)>
|
|
|
|
|
|
|
| 47 |
raise CapacityExceeded()
|
|
|
|
| 48 |
with self.lock:
|
| 49 |
self.expire()
|
| 50 |
-
if len(self.jobs)>=16 or self.retained_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),
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 74 |
r['delivered'] = True
|
| 75 |
-
r['receipt']
|
|
|
|
| 76 |
return bytes(r['data']), r['receipt']
|
| 77 |
|
| 78 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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,
|
| 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.
|
| 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
|
| 492 |
-
const
|
|
|
|
|
|
|
| 493 |
return text;
|
| 494 |
}
|
| 495 |
async function parse(text){
|
| 496 |
-
if(typeof text!=='string'||utf8(text).byteLength>LIMITS.bundle)throw Error('Portable bundle exceeds
|
| 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
|
| 499 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 & 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 |
-
|
|
|
|
| 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,
|
|
|
|
|
|
|
| 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["
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 309 |
raise BundleError("bundle schema or version is unsupported")
|
|
|
|
|
|
|
| 310 |
artifacts = bundle["artifacts"]
|
| 311 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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']<=
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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'),
|