diff --git a/config/perception/m49-tgs-fail-closed-evidence-v1.json b/config/perception/m49-tgs-fail-closed-evidence-v1.json new file mode 100644 index 0000000..d593e69 --- /dev/null +++ b/config/perception/m49-tgs-fail-closed-evidence-v1.json @@ -0,0 +1,69 @@ +{ + "schema_version": "missioncore.m49-tgs-fail-closed-evidence-profile/v1", + "profile_id": "m49-ravnoves00-tgs-fail-closed-evidence/v1", + "source": { + "source_id": "RAVNOVES00", + "session_id": "20260720T065719Z_viewer_live", + "source_pack_id": "e10-lidar-pack-576c994a6c814e2592dd6240ace3902a5db94843312c759a73ba0c9166157d2b", + "source_pack_sha256": "0685d24219d8236caf8b7f1685e93f6d6b59e7fd015a768d88a92bbe8b154944", + "travel_revision": "95dc2fbd66a343efd9060c45a5711b6307a950a4", + "compatibility_decision_sha256": "ff0b27f469cccad258c53ae07cb04ad00e696d82b32021c4f1f38ba7fa39d66a", + "input_coordinate_frame": "map-gravity-local-translation-only" + }, + "anchors": [171, 306, 368, 402, 450, 509, 525, 744, 1122, 1856], + "tgs": { + "max_range_m": 80.0, + "min_range_m": 1.0, + "resolution_m": 8.0, + "num_iterations": 3, + "num_lowest_representative_points": 5, + "minimum_points": 10, + "seed_threshold_m": 0.5, + "distance_threshold_m": 0.125, + "outlier_threshold_m": 0.3, + "normal_threshold": 0.94, + "weight_threshold": 200.0, + "lcc_normal_similarity": 0.03, + "lcc_planar_distance_m": 0.1, + "obstacle_height_m": 1.0, + "refine_mode": true + }, + "profiles": { + "current_increment": { + "role": "diagnostic-current-evidence" + }, + "causal_rolling_1s": { + "role": "primary-local-evidence", + "history_seconds": 1.0, + "local_radius_m": 12.0 + } + }, + "costmap": { + "coordinate_frame": "map-gravity-local", + "cell_size_m": 0.45, + "radius_m": 12.0, + "state_priority": [ + "NONGROUND_OCCUPIED", + "UNKNOWN_REJECTED", + "GROUND_SUPPORT", + "UNOBSERVED" + ] + }, + "state_codes": { + "UNOBSERVED": 0, + "GROUND_SUPPORT": 1, + "NONGROUND_OCCUPIED": 2, + "UNKNOWN_REJECTED": 3 + }, + "invariants": { + "all_eligible_input_points_accounted": true, + "aos_allowed": false, + "lidar_orientation_applied_to_tgs_input": false, + "map_gravity_axis_preserved": true, + "missing_support_means_free": false, + "unobserved_cells_are_emitted": true, + "camera_projection_is_authoritative": false, + "gpu_allowed": false, + "navigation_or_actuation_allowed": false + } +} diff --git a/experiments/perception/worker/Invoke-M49TgsFailClosedEvidence.ps1 b/experiments/perception/worker/Invoke-M49TgsFailClosedEvidence.ps1 new file mode 100644 index 0000000..bf20c25 --- /dev/null +++ b/experiments/perception/worker/Invoke-M49TgsFailClosedEvidence.ps1 @@ -0,0 +1,205 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$ReleaseRoot, + [Parameter(Mandatory = $true)] + [ValidatePattern("^[A-Za-z0-9._-]{1,96}$")] + [string]$RunId, + [string]$SourcePackPath = "D:\NDC_MISSIONCORE\runtime\derived\e10-lidar-pack-576c994a6c814e2592dd6240ace3902a5db94843312c759a73ba0c9166157d2b\lidar-pack.npz", + [string]$OutputRoot = "D:\NDC_MISSIONCORE\runtime\results\m49-tgs-fail-closed" +) + +$ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" +$TravelImageTag = "ndc/mission-core-m49-t3-travel:20260826" +$TravelImageId = "sha256:7b412020f4d8392d1d1ed1b33beadc44140f0ea8f781e62dd69796042334300f" +$ParityImageTag = "ndc-mission-core-m48t-upstream-parity:1.9.4-cu130" +$ParityImageId = "sha256:ceb13548617e4bd3f619766bfdff00af3fa5160946b367828da6d2233dcdcba0" + +function Assert-LastExitCode([string]$Operation) { + if ($LASTEXITCODE -ne 0) { throw "$Operation failed with exit code $LASTEXITCODE" } +} + +function Resolve-DDirectory([string]$Path, [string]$Label, [bool]$Create) { + if ($Create -and -not (Test-Path -LiteralPath $Path)) { + $null = New-Item -ItemType Directory -Path $Path + } + $item = Get-Item -LiteralPath (Resolve-Path -LiteralPath $Path).Path -Force + if ( + -not $item.PSIsContainer -or + ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -or + [IO.Path]::GetPathRoot($item.FullName).TrimEnd("\") -ine "D:" + ) { + throw "$Label must be a real D: directory" + } + return $item.FullName +} + +function Resolve-DFile([string]$Path, [string]$Label) { + $item = Get-Item -LiteralPath (Resolve-Path -LiteralPath $Path).Path -Force + if ( + $item.PSIsContainer -or + ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -or + [IO.Path]::GetPathRoot($item.FullName).TrimEnd("\") -ine "D:" + ) { + throw "$Label must be a real D: file" + } + return $item.FullName +} + +function Convert-ToDockerPath([string]$Path) { + return ($Path -replace "\\", "/") +} + +function Get-Container([string]$Name) { + $rows = @(((& docker inspect $Name) | ConvertFrom-Json)) + Assert-LastExitCode "Docker inspection for $Name" + if ($rows.Count -ne 1) { throw "Container identity for $Name is not unique" } + return $rows[0] +} + +function Assert-Image([string]$Tag, [string]$ExpectedId) { + $rows = @(((& docker image inspect $Tag) | ConvertFrom-Json)) + Assert-LastExitCode "Docker image inspection for $Tag" + if ($rows.Count -ne 1 -or [string]$rows[0].Id -cne $ExpectedId) { + throw "Pinned image identity changed for $Tag" + } +} + +function Remove-ExactContainer([string]$Name) { + if (& docker ps -a --format "{{.Names}}" --filter "name=^/$Name$") { + & docker rm --force $Name *> $null + } +} + +if ($env:COMPUTERNAME -cne "DESKTOP-OPJ8J04") { + throw "M49 TGS fail-closed evidence is pinned to Worker 006" +} +$release = Resolve-DDirectory $ReleaseRoot "M49 TGS release" $false +$payload = Resolve-DDirectory (Join-Path $release "payload") "M49 TGS payload" $false +$sourcePack = Resolve-DFile $SourcePackPath "RAVNOVES00 source pack" +$output = Resolve-DDirectory $OutputRoot "M49 TGS output root" $true +$runCandidate = Join-Path $output $RunId +if (Test-Path -LiteralPath $runCandidate) { throw "M49 TGS output already exists" } +$null = New-Item -ItemType Directory -Path $runCandidate +$runOutput = Resolve-DDirectory $runCandidate "M49 TGS run output" $false + +$releaseDocument = Get-Content -LiteralPath (Join-Path $payload "release.json") -Raw | ConvertFrom-Json +if ( + $releaseDocument.schema_version -cne "missioncore.m49-tgs-worker-release/v1" -or + $releaseDocument.worker_id -cne "worker-006" -or + $releaseDocument.candidate_id -cne "travel-tgs-only" +) { + throw "M49 TGS release contract changed" +} +foreach ($property in $releaseDocument.files.PSObject.Properties) { + $path = Join-Path $payload $property.Name + $actual = (Get-FileHash -Algorithm SHA256 -LiteralPath $path).Hash.ToLowerInvariant() + if ($actual -cne [string]$property.Value.sha256) { + throw "M49 TGS payload digest changed: $($property.Name)" + } +} +$sourcePackSha = (Get-FileHash -Algorithm SHA256 -LiteralPath $sourcePack).Hash.ToLowerInvariant() +if ($sourcePackSha -cne "0685d24219d8236caf8b7f1685e93f6d6b59e7fd015a768d88a92bbe8b154944") { + throw "RAVNOVES00 source pack digest changed" +} +$os = Get-CimInstance Win32_OperatingSystem +$freeMemoryGiB = [double]$os.FreePhysicalMemory / 1MB +if ($freeMemoryGiB -lt 16.0) { + throw ("M49 TGS requires 16 GiB free memory; observed {0:N2} GiB" -f $freeMemoryGiB) +} +$tritonBefore = Get-Container "ndc-mission-core-triton" +if (-not $tritonBefore.State.Running -or $tritonBefore.State.Health.Status -cne "healthy") { + throw "Canonical Mission Core Triton must remain healthy during M49 TGS" +} +Assert-Image $TravelImageTag $TravelImageId +Assert-Image $ParityImageTag $ParityImageId + +$prepareName = "ndc-mission-core-m49-tgs-prepare-$RunId" +$runName = "ndc-mission-core-m49-tgs-run-$RunId" +$analyzeName = "ndc-mission-core-m49-tgs-analyze-$RunId" +foreach ($name in @($prepareName, $runName, $analyzeName)) { + if (& docker ps -a --format "{{.Names}}" --filter "name=^/$name$") { + throw "M49 TGS container name already exists: $name" + } +} + +$started = [DateTimeOffset]::UtcNow +try { + & docker run --rm --name $prepareName --network none --cpus 8 --memory 16g ` + --entrypoint python3 ` + --volume ((Convert-ToDockerPath $sourcePack) + ":/source/lidar-pack.npz:ro") ` + --volume ((Convert-ToDockerPath $payload) + ":/release:ro") ` + --volume ((Convert-ToDockerPath $runOutput) + ":/tgs") ` + $ParityImageTag /release/prepare_tgs_fail_closed_inputs.py ` + --source-pack /source/lidar-pack.npz ` + --config /release/m49-tgs-fail-closed-evidence-v1.json ` + --output-root /tgs/inputs + Assert-LastExitCode "M49 TGS input preparation" + + & docker run --rm --name $runName --network none --cpus 16 --memory 24g ` + --entrypoint /bin/bash ` + --volume ((Convert-ToDockerPath $payload) + ":/release:ro") ` + --volume ((Convert-ToDockerPath $runOutput) + ":/tgs") ` + $TravelImageTag /release/run_tgs_fail_closed.sh + Assert-LastExitCode "M49 TGS-only run" + + & docker run --rm --name $analyzeName --network none --cpus 8 --memory 16g ` + --entrypoint python3 ` + --volume ((Convert-ToDockerPath $payload) + ":/release:ro") ` + --volume ((Convert-ToDockerPath $runOutput) + ":/tgs") ` + $ParityImageTag /release/build_tgs_fail_closed_evidence.py ` + --run-root /tgs ` + --config /release/m49-tgs-fail-closed-evidence-v1.json ` + --output-root /tgs/evidence + Assert-LastExitCode "M49 TGS fail-closed evidence analysis" +} finally { + foreach ($name in @($prepareName, $runName, $analyzeName)) { + Remove-ExactContainer $name + } +} +$completed = [DateTimeOffset]::UtcNow + +$resultPath = Join-Path $runOutput "evidence\result.json" +if (-not (Test-Path -LiteralPath $resultPath -PathType Leaf)) { + throw "M49 TGS evidence result is missing" +} +$result = Get-Content -LiteralPath $resultPath -Raw | ConvertFrom-Json +if ( + $result.status -cne "passed" -or + -not [bool]$result.summary.all_eligible_points_accounted -or + [bool]$result.summary.aos_used +) { + throw "M49 TGS fail-closed acceptance failed" +} +$tritonAfter = Get-Container "ndc-mission-core-triton" +if ( + -not $tritonAfter.State.Running -or + $tritonAfter.State.Health.Status -cne "healthy" -or + [string]$tritonAfter.Id -cne [string]$tritonBefore.Id +) { + throw "Canonical Mission Core Triton changed during M49 TGS" +} +$summary = [ordered]@{ + schema_version = "missioncore.m49-tgs-worker-summary/v1" + worker_id = "worker-006" + run_id = $RunId + code_revision = [string]$releaseDocument.code_revision + source_pack_sha256 = $sourcePackSha + travel_image_id = $TravelImageId + parity_image_id = $ParityImageId + started_utc = $started.ToString("o") + wall_seconds = [math]::Round(($completed - $started).TotalSeconds, 6) + free_memory_gib_before = [math]::Round($freeMemoryGiB, 6) + canonical_triton_id = [string]$tritonAfter.Id + canonical_triton_health = [string]$tritonAfter.State.Health.Status + all_eligible_points_accounted = [bool]$result.summary.all_eligible_points_accounted + aos_used = [bool]$result.summary.aos_used + visual_quality_accepted = $false + realtime_accepted = $false + navigation_or_actuation_allowed = $false +} +$summary | ConvertTo-Json -Depth 3 | Set-Content -LiteralPath ( + Join-Path $runOutput "worker-summary.json" +) -Encoding utf8 +$summary | ConvertTo-Json -Depth 3 diff --git a/experiments/perception/worker/Invoke-M49TgsFailClosedEvidenceAsInteractiveUser.ps1 b/experiments/perception/worker/Invoke-M49TgsFailClosedEvidenceAsInteractiveUser.ps1 new file mode 100644 index 0000000..d8f4e28 --- /dev/null +++ b/experiments/perception/worker/Invoke-M49TgsFailClosedEvidenceAsInteractiveUser.ps1 @@ -0,0 +1,51 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$ReleaseRoot, + [Parameter(Mandatory = $true)] + [ValidatePattern("^[A-Za-z0-9._-]{1,96}$")] + [string]$RunId +) + +$ErrorActionPreference = "Stop" +$taskName = "MissionCore-M49TgsFailClosed" +$release = (Resolve-Path -LiteralPath $ReleaseRoot).Path +$runner = Join-Path $release "payload\Invoke-M49TgsFailClosedEvidence.ps1" +if (-not (Test-Path -LiteralPath $runner -PathType Leaf)) { + throw "M49 TGS runner is missing" +} +$existing = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue +if ($existing -and $existing.State -eq "Running") { + throw "$taskName is already running" +} +$powerShell = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" +$arguments = @( + "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", + "-File", "`"$runner`"", + "-ReleaseRoot", "`"$release`"", + "-RunId", "`"$RunId`"" +) -join " " +$userId = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name +$action = New-ScheduledTaskAction -Execute $powerShell -Argument $arguments -WorkingDirectory $release +$principal = New-ScheduledTaskPrincipal -UserId $userId -LogonType Interactive -RunLevel Limited +$trigger = New-ScheduledTaskTrigger -Once -At ((Get-Date).AddMinutes(30)) +$settings = New-ScheduledTaskSettingsSet ` + -AllowStartIfOnBatteries ` + -DontStopIfGoingOnBatteries ` + -StartWhenAvailable ` + -ExecutionTimeLimit ([TimeSpan]::FromHours(2)) +Register-ScheduledTask ` + -TaskName $taskName ` + -Action $action ` + -Principal $principal ` + -Trigger $trigger ` + -Settings $settings ` + -Description "One-shot CPU-only gravity-aligned TGS fail-closed evidence run." ` + -Force | Out-Null +Start-ScheduledTask -TaskName $taskName +[pscustomobject]@{ + task_name = $taskName + run_id = $RunId + release_root = $release + state = (Get-ScheduledTask -TaskName $taskName).State.ToString() +} | ConvertTo-Json -Compress diff --git a/experiments/perception/worker/m49_t3_travel/build_tgs_fail_closed_evidence.py b/experiments/perception/worker/m49_t3_travel/build_tgs_fail_closed_evidence.py new file mode 100644 index 0000000..7dd0010 --- /dev/null +++ b/experiments/perception/worker/m49_t3_travel/build_tgs_fail_closed_evidence.py @@ -0,0 +1,297 @@ +#!/usr/bin/env python3 +"""Build a fail-closed TGS evidence pack from the sealed compatibility run.""" + +from __future__ import annotations + +import argparse +import hashlib +import io +import json +import math +import zipfile +from collections import Counter +from pathlib import Path + +import numpy as np + + +class TgsEvidenceError(RuntimeError): + """The sealed compatibility evidence or fail-closed contract is invalid.""" + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _load_float32(path: Path, columns: int) -> np.ndarray: + if path.is_symlink() or not path.is_file(): + raise TgsEvidenceError(f"sealed input is unavailable: {path.name}") + values = np.fromfile(path, dtype=np.float32) + if values.size % columns: + raise TgsEvidenceError(f"sealed input shape changed: {path.name}") + result = values.reshape(-1, columns) + if not np.isfinite(result).all(): + raise TgsEvidenceError(f"sealed input is non-finite: {path.name}") + return result + + +def classify_exact_input( + native: np.ndarray, + ground: np.ndarray, + nonground: np.ndarray, + *, + min_range_m: float = 1.0, + max_range_m: float = 80.0, +) -> tuple[np.ndarray, np.ndarray]: + points = np.asarray(native, dtype=np.float32) + if points.ndim != 2 or points.shape[1:] != (4,): + raise TgsEvidenceError("native XYZI input shape changed") + ranges = np.linalg.norm(points[:, :2].astype(np.float64), axis=1) + eligible = points[(ranges > min_range_m) & (ranges < max_range_m)] + ground_counts = Counter(row[:3].tobytes() for row in ground) + nonground_counts = Counter(row[:3].tobytes() for row in nonground) + states = np.empty(eligible.shape[0], dtype=np.uint8) + for index, row in enumerate(eligible): + key = row[:3].tobytes() + if ground_counts[key] > 0: + states[index] = 1 + ground_counts[key] -= 1 + elif nonground_counts[key] > 0: + states[index] = 2 + nonground_counts[key] -= 1 + else: + states[index] = 3 + if any(value for value in ground_counts.values()) or any( + value for value in nonground_counts.values() + ): + raise TgsEvidenceError("TGS output is not a multiset subset of its exact input") + if not np.isin(states, np.asarray([1, 2, 3], dtype=np.uint8)).all(): + raise TgsEvidenceError("point state reconstruction failed") + return eligible[:, :3].copy(), states + + +def costmap_grid(radius_m: float, cell_size_m: float) -> np.ndarray: + if not math.isfinite(radius_m) or not math.isfinite(cell_size_m): + raise TgsEvidenceError("costmap bounds are non-finite") + if radius_m <= 0 or cell_size_m <= 0 or cell_size_m > radius_m: + raise TgsEvidenceError("costmap bounds are invalid") + minimum = math.floor(-radius_m / cell_size_m) + maximum = math.ceil(radius_m / cell_size_m) + cells = [] + for ix in range(minimum, maximum): + for iy in range(minimum, maximum): + center_x = (ix + 0.5) * cell_size_m + center_y = (iy + 0.5) * cell_size_m + if math.hypot(center_x, center_y) <= radius_m: + cells.append((ix, iy, center_x, center_y)) + if not cells: + raise TgsEvidenceError("costmap grid is empty") + return np.asarray(cells, dtype=np.float64) + + +def rasterize_costmap( + points_xyz: np.ndarray, + point_states: np.ndarray, + grid: np.ndarray, + *, + cell_size_m: float, +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + lookup = {(int(row[0]), int(row[1])): index for index, row in enumerate(grid)} + counts = np.zeros((grid.shape[0], 3), dtype=np.int32) + minimum_z = np.full(grid.shape[0], np.nan, dtype=np.float32) + maximum_z = np.full(grid.shape[0], np.nan, dtype=np.float32) + for point, state in zip(points_xyz, point_states, strict=True): + cell = ( + math.floor(float(point[0]) / cell_size_m), + math.floor(float(point[1]) / cell_size_m), + ) + cell_index = lookup.get(cell) + if cell_index is None: + continue + counts[cell_index, int(state) - 1] += 1 + z = np.float32(point[2]) + if np.isnan(minimum_z[cell_index]) or z < minimum_z[cell_index]: + minimum_z[cell_index] = z + if np.isnan(maximum_z[cell_index]) or z > maximum_z[cell_index]: + maximum_z[cell_index] = z + states = np.zeros(grid.shape[0], dtype=np.uint8) + states[counts[:, 0] > 0] = 1 + states[counts[:, 2] > 0] = 3 + states[counts[:, 1] > 0] = 2 + return states, counts[:, 0], counts[:, 1], counts[:, 2], np.column_stack((minimum_z, maximum_z)) + + +def _array_bytes(array: np.ndarray) -> bytes: + stream = io.BytesIO() + np.lib.format.write_array(stream, np.ascontiguousarray(array), allow_pickle=False) + return stream.getvalue() + + +def write_deterministic_npz(path: Path, arrays: dict[str, np.ndarray]) -> None: + with zipfile.ZipFile( + path, mode="w", compression=zipfile.ZIP_DEFLATED, compresslevel=6 + ) as archive: + for name in sorted(arrays): + info = zipfile.ZipInfo(f"{name}.npy", date_time=(1980, 1, 1, 0, 0, 0)) + info.compress_type = zipfile.ZIP_DEFLATED + info.external_attr = 0o100644 << 16 + archive.writestr(info, _array_bytes(arrays[name])) + + +def build(run_root: Path, config_path: Path, output_root: Path) -> dict[str, object]: + if output_root.exists(): + raise TgsEvidenceError("TGS evidence output already exists") + config = json.loads(config_path.read_text(encoding="utf-8")) + if ( + config.get("schema_version") != "missioncore.m49-tgs-fail-closed-evidence-profile/v1" + or config.get("invariants", {}).get("aos_allowed") is not False + or config.get("invariants", {}).get("missing_support_means_free") is not False + or config.get("state_codes") + != { + "UNOBSERVED": 0, + "GROUND_SUPPORT": 1, + "NONGROUND_OCCUPIED": 2, + "UNKNOWN_REJECTED": 3, + } + ): + raise TgsEvidenceError("TGS fail-closed profile changed") + input_manifest_path = run_root / "inputs" / "input-manifest.json" + input_manifest = json.loads(input_manifest_path.read_text(encoding="utf-8")) + if ( + input_manifest.get("schema_version") != "missioncore.m49-tgs-fail-closed-input/v1" + or input_manifest.get("source_pack_sha256") != config["source"]["source_pack_sha256"] + or input_manifest.get("config_sha256") != sha256_file(config_path) + or input_manifest.get("coordinate_frame") != "map-gravity-local" + or input_manifest.get("future_frames_used") is not False + or len(input_manifest.get("records", [])) != 20 + ): + raise TgsEvidenceError("sealed TGS input manifest changed") + records = {(str(row["profile_id"]), int(row["slot"])): row for row in input_manifest["records"]} + cell_size = float(config["costmap"]["cell_size_m"]) + radius = float(config["costmap"]["radius_m"]) + grid = costmap_grid(radius, cell_size) + arrays: dict[str, np.ndarray] = { + "costmap_cell_indices_xy": grid[:, :2].astype(np.int32), + "costmap_cell_centers_xy_m": grid[:, 2:].astype(np.float32), + } + summaries: list[dict[str, object]] = [] + for profile in ("current_increment", "causal_rolling_1s"): + all_points: list[np.ndarray] = [] + all_states: list[np.ndarray] = [] + offsets = [0] + profile_grid_states: list[np.ndarray] = [] + profile_ground_counts: list[np.ndarray] = [] + profile_nonground_counts: list[np.ndarray] = [] + profile_rejected_counts: list[np.ndarray] = [] + profile_z_bounds: list[np.ndarray] = [] + for slot in range(10): + record = records[(profile, slot)] + native_path = run_root / "inputs" / record["relative_path"] + if sha256_file(native_path) != record["sha256"]: + raise TgsEvidenceError("sealed gravity-aligned input changed") + output = run_root / "outputs" / profile + points, states = classify_exact_input( + _load_float32(native_path, 4), + _load_float32(output / f"{slot}_ground.bin", 4), + _load_float32(output / f"{slot}_nonground.bin", 4), + ) + grid_state, ground_count, nonground_count, rejected_count, z_bounds = rasterize_costmap( + points, states, grid, cell_size_m=cell_size + ) + all_points.append(points.astype(np.float32, copy=False)) + all_states.append(states) + offsets.append(offsets[-1] + points.shape[0]) + profile_grid_states.append(grid_state) + profile_ground_counts.append(ground_count) + profile_nonground_counts.append(nonground_count) + profile_rejected_counts.append(rejected_count) + profile_z_bounds.append(z_bounds) + summaries.append( + { + "profile_id": profile, + "slot": slot, + "anchor_frame_index": int(record["anchor_frame_index"]), + "point_count": int(points.shape[0]), + "ground_point_count": int(np.count_nonzero(states == 1)), + "nonground_point_count": int(np.count_nonzero(states == 2)), + "rejected_point_count": int(np.count_nonzero(states == 3)), + "ground_cell_count": int(np.count_nonzero(grid_state == 1)), + "nonground_cell_count": int(np.count_nonzero(grid_state == 2)), + "rejected_cell_count": int(np.count_nonzero(grid_state == 3)), + "unobserved_cell_count": int(np.count_nonzero(grid_state == 0)), + "all_points_accounted": bool( + np.count_nonzero(states == 1) + + np.count_nonzero(states == 2) + + np.count_nonzero(states == 3) + == points.shape[0] + ), + } + ) + arrays[f"{profile}_points_xyz_m"] = np.concatenate(all_points, axis=0) + arrays[f"{profile}_point_states"] = np.concatenate(all_states, axis=0) + arrays[f"{profile}_point_offsets"] = np.asarray(offsets, dtype=np.int64) + arrays[f"{profile}_costmap_states"] = np.stack(profile_grid_states) + arrays[f"{profile}_costmap_ground_point_counts"] = np.stack(profile_ground_counts) + arrays[f"{profile}_costmap_nonground_point_counts"] = np.stack(profile_nonground_counts) + arrays[f"{profile}_costmap_rejected_point_counts"] = np.stack(profile_rejected_counts) + arrays[f"{profile}_costmap_z_bounds_m"] = np.stack(profile_z_bounds) + if not all(bool(row["all_points_accounted"]) for row in summaries): + raise TgsEvidenceError("fail-closed evidence lost an eligible input point") + output_root.mkdir(parents=True) + evidence_path = output_root / "evidence.npz" + write_deterministic_npz(evidence_path, arrays) + result = { + "schema_version": "missioncore.m49-tgs-fail-closed-evidence-result/v1", + "status": "passed", + "config_sha256": sha256_file(config_path), + "source_pack_sha256": input_manifest["source_pack_sha256"], + "input_manifest_sha256": sha256_file(input_manifest_path), + "evidence": { + "path": "evidence.npz", + "bytes": evidence_path.stat().st_size, + "sha256": sha256_file(evidence_path), + }, + "costmap": { + "coordinate_frame": "map-gravity-local", + "cell_size_m": cell_size, + "radius_m": radius, + "cell_count": int(grid.shape[0]), + }, + "anchors": summaries, + "summary": { + "anchor_profile_count": len(summaries), + "all_eligible_points_accounted": True, + "aos_used": False, + "primary_profile": "causal_rolling_1s", + }, + "authority": { + "visual_quality_accepted": False, + "traversability_accepted": False, + "realtime_accepted": False, + "navigation_or_actuation_allowed": False, + }, + } + result_path_out = output_root / "result.json" + result_path_out.write_text( + json.dumps(result, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + return result + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--run-root", type=Path, required=True) + parser.add_argument("--config", type=Path, required=True) + parser.add_argument("--output-root", type=Path, required=True) + arguments = parser.parse_args() + result = build(arguments.run_root, arguments.config, arguments.output_root) + print(json.dumps(result["summary"], indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/experiments/perception/worker/m49_t3_travel/prepare_tgs_fail_closed_inputs.py b/experiments/perception/worker/m49_t3_travel/prepare_tgs_fail_closed_inputs.py new file mode 100644 index 0000000..21bab76 --- /dev/null +++ b/experiments/perception/worker/m49_t3_travel/prepare_tgs_fail_closed_inputs.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +"""Prepare gravity-aligned local clouds for the TGS-only RAVNOVES00 gate.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +from pathlib import Path + +import numpy as np + +SOURCE_PACK_SHA256 = "0685d24219d8236caf8b7f1685e93f6d6b59e7fd015a768d88a92bbe8b154944" +EXPECTED_ARRAYS = { + "cloud_offsets", + "cloud_points_map", + "frame_indices", + "pose_positions_map", + "sample_available", + "session_seconds", +} + + +class TgsInputError(RuntimeError): + """The immutable source cannot satisfy the TGS input contract.""" + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _validate_source(arrays: dict[str, np.ndarray]) -> None: + frames = 4489 + if ( + arrays["cloud_offsets"].shape != (frames + 1,) + or arrays["cloud_offsets"].dtype != np.int64 + or arrays["cloud_points_map"].shape != (9_207_270, 3) + or arrays["cloud_points_map"].dtype != np.float32 + or arrays["frame_indices"].shape != (frames,) + or arrays["frame_indices"].dtype != np.int64 + or arrays["pose_positions_map"].shape != (frames, 3) + or arrays["pose_positions_map"].dtype != np.float64 + or arrays["sample_available"].shape != (frames,) + or arrays["sample_available"].dtype != np.bool_ + or arrays["session_seconds"].shape != (frames,) + or arrays["session_seconds"].dtype != np.float64 + or int(arrays["cloud_offsets"][0]) != 0 + or int(arrays["cloud_offsets"][-1]) != 9_207_270 + or np.any(np.diff(arrays["cloud_offsets"]) < 0) + or np.any(np.diff(arrays["frame_indices"]) <= 0) + or np.any(np.diff(arrays["session_seconds"]) < 0) + ): + raise TgsInputError("RAVNOVES00 lidar-pack array contract changed") + + +def _frame_points(arrays: dict[str, np.ndarray], frame_index: int) -> np.ndarray: + start = int(arrays["cloud_offsets"][frame_index]) + stop = int(arrays["cloud_offsets"][frame_index + 1]) + return arrays["cloud_points_map"][start:stop] + + +def gravity_local_xyzi(points_map: np.ndarray, position_map: np.ndarray) -> np.ndarray: + points = np.asarray(points_map, dtype=np.float64) + position = np.asarray(position_map, dtype=np.float64) + if ( + points.ndim != 2 + or points.shape[1:] != (3,) + or position.shape != (3,) + or not np.isfinite(points).all() + or not np.isfinite(position).all() + ): + raise TgsInputError("point or position arrays are invalid") + result = np.zeros((points.shape[0], 4), dtype=np.float32) + result[:, :3] = (points - position).astype(np.float32) + return result + + +def prepare(source_pack: Path, config_path: Path, output_root: Path) -> dict[str, object]: + if output_root.exists(): + raise TgsInputError("TGS input output root already exists") + if sha256_file(source_pack) != SOURCE_PACK_SHA256: + raise TgsInputError("RAVNOVES00 lidar-pack digest changed") + config = json.loads(config_path.read_text(encoding="utf-8")) + if ( + config.get("schema_version") != "missioncore.m49-tgs-fail-closed-evidence-profile/v1" + or config.get("source", {}).get("source_pack_sha256") != SOURCE_PACK_SHA256 + or config.get("source", {}).get("input_coordinate_frame") + != "map-gravity-local-translation-only" + or config.get("invariants", {}).get("lidar_orientation_applied_to_tgs_input") is not False + ): + raise TgsInputError("TGS fail-closed profile changed") + with np.load(source_pack, allow_pickle=False) as archive: + if not EXPECTED_ARRAYS.issubset(archive.files): + raise TgsInputError("RAVNOVES00 lidar-pack members changed") + arrays = {name: archive[name] for name in EXPECTED_ARRAYS} + _validate_source(arrays) + anchors = tuple(int(value) for value in config["anchors"]) + if len(anchors) != 10 or len(set(anchors)) != 10: + raise TgsInputError("TGS anchors changed") + seconds = arrays["session_seconds"] + records: list[dict[str, object]] = [] + for profile in ("current_increment", "causal_rolling_1s"): + for slot, anchor in enumerate(anchors): + if not bool(arrays["sample_available"][anchor]): + raise TgsInputError(f"anchor {anchor} has no lidar sample") + if profile == "current_increment": + contributors = (anchor,) + else: + history = float(config["profiles"][profile]["history_seconds"]) + start = int(np.searchsorted(seconds, seconds[anchor] - history, side="left")) + contributors = tuple( + index + for index in range(start, anchor + 1) + if bool(arrays["sample_available"][index]) + ) + if not contributors or contributors[-1] != anchor: + raise TgsInputError("causal profile does not contain its anchor") + points_map = np.concatenate( + [_frame_points(arrays, index) for index in contributors], axis=0 + ) + if profile == "causal_rolling_1s": + radius = float(config["profiles"][profile]["local_radius_m"]) + relative_xy = ( + points_map[:, :2].astype(np.float64) - arrays["pose_positions_map"][anchor, :2] + ) + points_map = points_map[np.linalg.norm(relative_xy, axis=1) <= radius] + native = gravity_local_xyzi(points_map, arrays["pose_positions_map"][anchor]) + if native.shape[0] == 0: + raise TgsInputError("TGS profile produced an empty cloud") + target = output_root / "profiles" / profile / "velodyne" / f"{slot:06d}.bin" + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(np.ascontiguousarray(native).tobytes()) + records.append( + { + "profile_id": profile, + "slot": slot, + "anchor_frame_index": anchor, + "anchor_source_frame_index": int(arrays["frame_indices"][anchor]), + "anchor_session_seconds": float(seconds[anchor]), + "point_count": int(native.shape[0]), + "contributing_frame_indices": list(contributors), + "contributing_source_frame_indices": [ + int(arrays["frame_indices"][index]) for index in contributors + ], + "relative_path": target.relative_to(output_root).as_posix(), + "bytes": target.stat().st_size, + "sha256": sha256_file(target), + } + ) + manifest = { + "schema_version": "missioncore.m49-tgs-fail-closed-input/v1", + "source_pack_sha256": SOURCE_PACK_SHA256, + "config_sha256": sha256_file(config_path), + "coordinate_frame": "map-gravity-local", + "transform": "translation-only-preserve-map-gravity-axis", + "intensity_policy": "zero-filled-algorithm-compatibility-only", + "future_frames_used": False, + "records": records, + } + manifest_path = output_root / "input-manifest.json" + manifest_path.write_text( + json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + return manifest + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--source-pack", type=Path, required=True) + parser.add_argument("--config", type=Path, required=True) + parser.add_argument("--output-root", type=Path, required=True) + arguments = parser.parse_args() + manifest = prepare(arguments.source_pack, arguments.config, arguments.output_root) + print(json.dumps({"ok": True, "records": len(manifest["records"])}, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/experiments/perception/worker/m49_t3_travel/run_tgs_fail_closed.cpp b/experiments/perception/worker/m49_t3_travel/run_tgs_fail_closed.cpp new file mode 100644 index 0000000..c5ffe1e --- /dev/null +++ b/experiments/perception/worker/m49_t3_travel/run_tgs_fail_closed.cpp @@ -0,0 +1,95 @@ +#include +#include +#include +#include +#include + +#include "travel/kitti_loader.hpp" +#include "travel/point_types.hpp" +#include "travel/tgs.hpp" + +namespace { + +void writeXYZI( + const std::string& path, + const travel::PointCloud& cloud +) { + std::ofstream output(path, std::ios::binary); + if (!output) { + throw std::runtime_error("cannot open TGS output"); + } + for (const auto& point : cloud.points) { + const float row[4] = {point.x, point.y, point.z, point.intensity}; + output.write(reinterpret_cast(row), sizeof(row)); + } + if (!output) { + throw std::runtime_error("cannot write TGS output"); + } +} + +} // namespace + +int main(int argc, char** argv) { + if (argc != 4) { + std::cerr << "Usage: run_tgs_fail_closed \n"; + return 1; + } + const std::string sequence_dir = argv[1]; + const std::size_t frame_index = std::stoul(argv[2]); + const std::string output_dir = argv[3]; + KittiLoader loader(sequence_dir); + if (loader.size() == 0 || frame_index >= loader.size()) { + std::cerr << "TGS input frame is unavailable\n"; + return 2; + } + auto input_xyzi = loader.cloud(frame_index); + if (!input_xyzi) { + std::cerr << "TGS input frame cannot be loaded\n"; + return 3; + } + auto input = std::make_shared>(); + input->reserve(input_xyzi->size()); + for (const auto& point : input_xyzi->points) { + PointXYZILID value{}; + value.x = point.x; + value.y = point.y; + value.z = point.z; + value.intensity = point.intensity; + value.label = 0; + value.id = 0; + input->emplace_back(value); + } + + travel::TravelGroundSeg tgs; + tgs.setParams( + 80.0, + 1.0, + 8.0, + 3, + 5, + 10, + 0.5, + 0.125, + 0.3, + 0.940, + 200.0, + 0.03, + 0.1, + 1.0, + true, + false + ); + travel::PointCloud ground; + travel::PointCloud nonground; + double tgs_seconds = 0.0; + tgs.estimateGround(*input, ground, nonground, tgs_seconds); + std::cout << "[TGS-ONLY] frame=" << frame_index + << " input=" << input->size() + << " ground=" << ground.size() + << " nonground=" << nonground.size() + << " tgs_seconds=" << tgs_seconds << '\n'; + const std::string base = output_dir + "/" + std::to_string(frame_index); + writeXYZI(base + "_ground.bin", ground); + writeXYZI(base + "_nonground.bin", nonground); + return 0; +} diff --git a/experiments/perception/worker/m49_t3_travel/run_tgs_fail_closed.sh b/experiments/perception/worker/m49_t3_travel/run_tgs_fail_closed.sh new file mode 100644 index 0000000..ba1e6b5 --- /dev/null +++ b/experiments/perception/worker/m49_t3_travel/run_tgs_fail_closed.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +readonly INPUT_ROOT=/tgs/inputs +readonly OUTPUT_ROOT=/tgs/outputs +readonly LOG_ROOT=/tgs/logs +readonly TIMING_PATH=/tgs/tgs-timing.tsv +readonly BINARY=/tmp/run_tgs_fail_closed + +test -f "${INPUT_ROOT}/input-manifest.json" +test ! -e "${OUTPUT_ROOT}" +test ! -e "${LOG_ROOT}" +g++ -std=c++17 -O3 -DNDEBUG \ + -I/opt/travel/src/TRAVEL/cpp/travel/core \ + -I/usr/include/eigen3 \ + /release/run_tgs_fail_closed.cpp \ + -o "${BINARY}" +mkdir -p "${OUTPUT_ROOT}" "${LOG_ROOT}" +printf 'profile\tslot\twall_seconds\tmax_rss_kib\n' > "${TIMING_PATH}" + +for profile in current_increment causal_rolling_1s; do + sequence="${INPUT_ROOT}/profiles/${profile}" + output="${OUTPUT_ROOT}/${profile}" + mkdir -p "${output}" + for slot in $(seq 0 9); do + log="${LOG_ROOT}/${profile}-${slot}.log" + timing="${LOG_ROOT}/${profile}-${slot}.time" + /usr/bin/time -f '%e\t%M' -o "${timing}" \ + "${BINARY}" "${sequence}" "${slot}" "${output}" > "${log}" 2>&1 + read -r wall rss < "${timing}" + printf '%s\t%s\t%s\t%s\n' \ + "${profile}" "${slot}" "${wall}" "${rss}" >> "${TIMING_PATH}" + test -f "${output}/${slot}_ground.bin" + test -f "${output}/${slot}_nonground.bin" + done +done diff --git a/scripts/build_m49_tgs_fail_closed_worker_artifact.py b/scripts/build_m49_tgs_fail_closed_worker_artifact.py new file mode 100644 index 0000000..25b8791 --- /dev/null +++ b/scripts/build_m49_tgs_fail_closed_worker_artifact.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +"""Build the deterministic M49 gravity-aligned TGS evidence release.""" + +from __future__ import annotations + +import argparse +import gzip +import hashlib +import io +import json +import re +import subprocess +import tarfile +import tempfile +from pathlib import Path + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +SOURCES = ( + Path("experiments/perception/worker/m49_t3_travel/prepare_tgs_fail_closed_inputs.py"), + Path("experiments/perception/worker/m49_t3_travel/run_tgs_fail_closed.cpp"), + Path("experiments/perception/worker/m49_t3_travel/run_tgs_fail_closed.sh"), + Path("experiments/perception/worker/m49_t3_travel/build_tgs_fail_closed_evidence.py"), + Path("experiments/perception/worker/Invoke-M49TgsFailClosedEvidence.ps1"), + Path("experiments/perception/worker/Invoke-M49TgsFailClosedEvidenceAsInteractiveUser.ps1"), + Path("config/perception/m49-tgs-fail-closed-evidence-v1.json"), +) +PATCH_ID = re.compile(r"^[A-Za-z0-9._-]{1,96}$") + + +class ArtifactBuildError(RuntimeError): + """The TGS evidence artifact cannot be built from the declared source.""" + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def git_revision() -> str: + result = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=REPOSITORY_ROOT, + check=True, + capture_output=True, + text=True, + ) + revision = result.stdout.strip() + if re.fullmatch(r"[a-f0-9]{40}", revision) is None: + raise ArtifactBuildError("Git revision is not a full SHA-1") + return revision + + +def tar_info(path: Path, arcname: str) -> tarfile.TarInfo: + info = tarfile.TarInfo(arcname) + info.uid = info.gid = 0 + info.uname = info.gname = "root" + info.mtime = 0 + if path.is_dir(): + info.type = tarfile.DIRTYPE + info.mode = 0o755 + else: + info.type = tarfile.REGTYPE + info.mode = 0o755 if path.suffix in {".sh", ".ps1", ".py"} else 0o644 + info.size = path.stat().st_size + return info + + +def write_archive(stage: Path, target: Path) -> None: + members = [stage / "manifest.env", stage / "files.txt", stage / "payload"] + members.extend(sorted((stage / "payload").rglob("*"))) + target.parent.mkdir(parents=True, exist_ok=True) + with ( + target.open("wb") as raw, + gzip.GzipFile(filename="", mode="wb", fileobj=raw, mtime=0) as compressed, + tarfile.open(fileobj=compressed, mode="w", format=tarfile.PAX_FORMAT) as archive, + ): + for path in members: + info = tar_info(path, path.relative_to(stage).as_posix()) + if path.is_file(): + with path.open("rb") as stream: + archive.addfile(info, stream) + else: + archive.addfile(info, io.BytesIO()) + + +def build( + patch_id: str, + output_directory: Path, + *, + revision: str | None = None, +) -> dict[str, object]: + if PATCH_ID.fullmatch(patch_id) is None: + raise ArtifactBuildError("patch id is invalid") + sources = tuple(REPOSITORY_ROOT / source for source in SOURCES) + if any(path.is_symlink() or not path.is_file() for path in sources): + raise ArtifactBuildError("release input is not a regular file") + selected_revision = revision or git_revision() + if re.fullmatch(r"[a-f0-9]{40}", selected_revision) is None: + raise ArtifactBuildError("artifact revision is invalid") + with tempfile.TemporaryDirectory(prefix="mission-core-m49-tgs-") as directory: + stage = Path(directory) + payload = stage / "payload" + payload.mkdir() + files: dict[str, dict[str, object]] = {} + for source in sources: + destination = payload / source.name + destination.write_bytes(source.read_bytes()) + files[destination.name] = { + "bytes": destination.stat().st_size, + "sha256": sha256_file(destination), + } + release = { + "schema_version": "missioncore.m49-tgs-worker-release/v1", + "patch_id": patch_id, + "code_revision": selected_revision, + "worker_id": "worker-006", + "candidate_id": "travel-tgs-only", + "license": "GPL-3.0-or-later", + "source_pack_sha256": ( + "0685d24219d8236caf8b7f1685e93f6d6b59e7fd015a768d88a92bbe8b154944" + ), + "images": { + "travel": "sha256:7b412020f4d8392d1d1ed1b33beadc44140f0ea8f781e62dd69796042334300f", + "parity": "sha256:ceb13548617e4bd3f619766bfdff00af3fa5160946b367828da6d2233dcdcba0", + }, + "authority": { + "visual_quality_accepted": False, + "traversability_accepted": False, + "realtime_accepted": False, + "navigation_or_actuation_allowed": False, + }, + "files": files, + } + release_path = payload / "release.json" + release_path.write_text( + json.dumps(release, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + payload_names = sorted((*files, release_path.name)) + (stage / "manifest.env").write_text( + f"id={patch_id}\ncomponent=mission-core-worker\ntype=qualification-release\n", + encoding="utf-8", + ) + (stage / "files.txt").write_text("\n".join(payload_names) + "\n", encoding="utf-8") + target = output_directory.resolve() / f"nodedc-{patch_id}.tgz" + write_archive(stage, target) + return { + "ok": True, + "artifact": str(target), + "sha256": sha256_file(target), + "patch_id": patch_id, + "code_revision": selected_revision, + "payload_files": payload_names, + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("patch_id") + parser.add_argument( + "--output-directory", + type=Path, + default=REPOSITORY_ROOT / ".runtime/worker-artifacts", + ) + arguments = parser.parse_args() + try: + result = build(arguments.patch_id, arguments.output_directory) + except (ArtifactBuildError, OSError, subprocess.SubprocessError) as exc: + parser.error(str(exc)) + print(json.dumps(result, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_m49_tgs_fail_closed_evidence.py b/tests/test_m49_tgs_fail_closed_evidence.py new file mode 100644 index 0000000..53811f9 --- /dev/null +++ b/tests/test_m49_tgs_fail_closed_evidence.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +import hashlib +import importlib.util +import json +import tarfile +from pathlib import Path + +import numpy as np + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ( + REPOSITORY_ROOT + / "experiments/perception/worker/m49_t3_travel/build_tgs_fail_closed_evidence.py" +) +SPEC = importlib.util.spec_from_file_location("m49_tgs_evidence", SCRIPT) +assert SPEC is not None and SPEC.loader is not None +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) +PREPARE_PATH = ( + REPOSITORY_ROOT + / "experiments/perception/worker/m49_t3_travel/prepare_tgs_fail_closed_inputs.py" +) +PREPARE_SPEC = importlib.util.spec_from_file_location("m49_tgs_prepare", PREPARE_PATH) +assert PREPARE_SPEC is not None and PREPARE_SPEC.loader is not None +PREPARE = importlib.util.module_from_spec(PREPARE_SPEC) +PREPARE_SPEC.loader.exec_module(PREPARE) +BUILDER_PATH = REPOSITORY_ROOT / "scripts/build_m49_tgs_fail_closed_worker_artifact.py" +BUILDER_SPEC = importlib.util.spec_from_file_location("m49_tgs_builder", BUILDER_PATH) +assert BUILDER_SPEC is not None and BUILDER_SPEC.loader is not None +BUILDER = importlib.util.module_from_spec(BUILDER_SPEC) +BUILDER_SPEC.loader.exec_module(BUILDER) + + +def test_gravity_local_input_translates_without_rotating_map_axes() -> None: + result = PREPARE.gravity_local_xyzi( + np.asarray([[2.0, 4.0, 6.0], [3.0, 6.0, 9.0]], dtype=np.float32), + np.asarray([1.0, 2.0, 3.0], dtype=np.float64), + ) + assert np.array_equal( + result, + np.asarray([[1.0, 2.0, 3.0, 0.0], [2.0, 4.0, 6.0, 0.0]], dtype=np.float32), + ) + + +def test_exact_input_complement_is_retained_as_unknown_rejected() -> None: + native = np.asarray( + [[2, 0, 0, 0], [3, 0, 0, 0], [4, 0, 1, 0], [0.5, 0, 0, 0]], + dtype=np.float32, + ) + ground = native[[0]] + nonground = native[[2]] + points, states = MODULE.classify_exact_input(native, ground, nonground) + assert np.array_equal(points, native[:3, :3]) + assert np.array_equal(states, np.asarray([1, 3, 2], dtype=np.uint8)) + + +def test_costmap_priority_is_nonground_then_rejected_then_ground() -> None: + grid = MODULE.costmap_grid(2.0, 1.0) + points = np.asarray( + [[0.1, 0.1, 0.0], [0.2, 0.2, 0.1], [0.3, 0.3, 0.2]], + dtype=np.float32, + ) + states, ground, nonground, rejected, _ = MODULE.rasterize_costmap( + points, + np.asarray([1, 3, 2], dtype=np.uint8), + grid, + cell_size_m=1.0, + ) + target = np.flatnonzero((grid[:, 0] == 0) & (grid[:, 1] == 0)) + assert target.size == 1 + index = int(target[0]) + assert states[index] == 2 + assert (ground[index], nonground[index], rejected[index]) == (1, 1, 1) + + +def test_deterministic_npz_has_identical_bytes(tmp_path: Path) -> None: + arrays = { + "b": np.asarray([3, 2, 1], dtype=np.int32), + "a": np.asarray([[1.0, 2.0]], dtype=np.float32), + } + first = tmp_path / "first.npz" + second = tmp_path / "second.npz" + MODULE.write_deterministic_npz(first, arrays) + MODULE.write_deterministic_npz(second, arrays) + assert first.read_bytes() == second.read_bytes() + with np.load(first, allow_pickle=False) as archive: + assert np.array_equal(archive["a"], arrays["a"]) + assert np.array_equal(archive["b"], arrays["b"]) + + +def test_tgs_worker_artifact_is_deterministic_and_cpu_only(tmp_path: Path) -> None: + revision = "a" * 40 + patch_id = "mission-core-m49-tgs-unit-001" + first = BUILDER.build(patch_id, tmp_path / "first", revision=revision) + second = BUILDER.build(patch_id, tmp_path / "second", revision=revision) + first_bytes = Path(first["artifact"]).read_bytes() + assert first_bytes == Path(second["artifact"]).read_bytes() + assert first["sha256"] == hashlib.sha256(first_bytes).hexdigest() + with tarfile.open(first["artifact"], "r:gz") as archive: + regular = { + member.name: archive.extractfile(member).read() # type: ignore[union-attr] + for member in archive.getmembers() + if member.isfile() + } + release = json.loads(regular["payload/release.json"]) + assert release["candidate_id"] == "travel-tgs-only" + assert release["code_revision"] == revision + assert release["authority"]["navigation_or_actuation_allowed"] is False + assert "--gpus" not in regular["payload/Invoke-M49TgsFailClosedEvidence.ps1"].decode()