From 28d52972370097d3f3e376e17c9fdb388b0e7ebf Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Wed, 26 Aug 2026 19:25:19 +0300 Subject: [PATCH] feat(perception): add TRAVEL RAVNOVES compatibility probe --- .../m49-travel-ravnoves-compatibility-v1.json | 56 +++++ .../Invoke-M49T3TravelRavnovesProbe.ps1 | 205 +++++++++++++++ ...T3TravelRavnovesProbeAsInteractiveUser.ps1 | 61 +++++ .../m49_t3_travel/analyze_ravnoves_probe.py | 214 ++++++++++++++++ .../m49_t3_travel/prepare_ravnoves_probe.py | 238 ++++++++++++++++++ .../m49_t3_travel/run_ravnoves_probe.sh | 34 +++ ..._m49_t3_travel_ravnoves_worker_artifact.py | 175 +++++++++++++ tests/test_m49_t3_travel_ravnoves_probe.py | 88 +++++++ 8 files changed, 1071 insertions(+) create mode 100644 config/perception/m49-travel-ravnoves-compatibility-v1.json create mode 100644 experiments/perception/worker/Invoke-M49T3TravelRavnovesProbe.ps1 create mode 100644 experiments/perception/worker/Invoke-M49T3TravelRavnovesProbeAsInteractiveUser.ps1 create mode 100644 experiments/perception/worker/m49_t3_travel/analyze_ravnoves_probe.py create mode 100644 experiments/perception/worker/m49_t3_travel/prepare_ravnoves_probe.py create mode 100644 experiments/perception/worker/m49_t3_travel/run_ravnoves_probe.sh create mode 100644 scripts/build_m49_t3_travel_ravnoves_worker_artifact.py create mode 100644 tests/test_m49_t3_travel_ravnoves_probe.py diff --git a/config/perception/m49-travel-ravnoves-compatibility-v1.json b/config/perception/m49-travel-ravnoves-compatibility-v1.json new file mode 100644 index 0000000..b47b687 --- /dev/null +++ b/config/perception/m49-travel-ravnoves-compatibility-v1.json @@ -0,0 +1,56 @@ +{ + "schema_version": "missioncore.m49-travel-ravnoves-compatibility/v1", + "profile_id": "m49-travel-ravnoves-compatibility/v1", + "candidate": { + "id": "travel", + "revision": "95dc2fbd66a343efd9060c45a5711b6307a950a4", + "worker_image_id": "sha256:7b412020f4d8392d1d1ed1b33beadc44140f0ea8f781e62dd69796042334300f", + "upstream_profile": "kitti-64x4500" + }, + "source": { + "source_id": "RAVNOVES00", + "session_id": "20260720T065719Z_viewer_live", + "representation": "registered-map-increment-v1", + "source_pack_id": "e10-lidar-pack-576c994a6c814e2592dd6240ace3902a5db94843312c759a73ba0c9166157d2b", + "source_pack_sha256": "0685d24219d8236caf8b7f1685e93f6d6b59e7fd015a768d88a92bbe8b154944", + "intensity_available": false, + "ring_available": false, + "per_point_time_available": false, + "native_ray_model_available": false + }, + "anchors": [171, 306, 368, 402, 450, 509, 525, 744, 1122, 1856], + "input_profiles": [ + { + "id": "current_increment", + "description": "Exact current registered map increment transformed into the current lidar frame.", + "history_seconds": 0.0, + "local_radius_m": null, + "travel_min_range_m": 1.0, + "travel_max_range_m": 80.0 + }, + { + "id": "causal_rolling_1s", + "description": "Current and past registered increments from one second, bounded by the accepted local-map radius.", + "history_seconds": 1.0, + "local_radius_m": 12.0, + "travel_min_range_m": 1.0, + "travel_max_range_m": 80.0 + } + ], + "order_variants": ["source", "reversed"], + "compatibility_acceptance": { + "ground_and_nonground_account_for_all_in_range_input_points": true, + "minimum_partition_multiset_jaccard": 0.99, + "minimum_labeled_multiset_jaccard": 0.99, + "all_outputs_must_be_finite": true + }, + "invariants": { + "future_frames_allowed": false, + "missing_support_means_free": false, + "rolling_support_can_clear_space": false, + "zero_filled_intensity_has_semantic_authority": false, + "camera_resize_or_rectification_allowed": false, + "gpu_allowed": false, + "navigation_or_actuation_allowed": false + } +} diff --git a/experiments/perception/worker/Invoke-M49T3TravelRavnovesProbe.ps1 b/experiments/perception/worker/Invoke-M49T3TravelRavnovesProbe.ps1 new file mode 100644 index 0000000..486ed0f --- /dev/null +++ b/experiments/perception/worker/Invoke-M49T3TravelRavnovesProbe.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-t3-travel-ravnoves" +) + +$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 T3 RAVNOVES probe is pinned to Worker 006" +} + +$release = Resolve-DDirectory $ReleaseRoot "M49 T3 probe release" $false +$payload = Resolve-DDirectory (Join-Path $release "payload") "M49 T3 probe payload" $false +$sourcePack = Resolve-DFile $SourcePackPath "RAVNOVES00 source pack" +$output = Resolve-DDirectory $OutputRoot "M49 T3 probe output root" $true +$runOutputCandidate = Join-Path $output $RunId +if (Test-Path -LiteralPath $runOutputCandidate) { throw "M49 T3 probe output already exists" } +$null = New-Item -ItemType Directory -Path $runOutputCandidate +$runOutput = Resolve-DDirectory $runOutputCandidate "M49 T3 probe run output" $false + +$releaseDocument = Get-Content -LiteralPath (Join-Path $payload "release.json") -Raw | ConvertFrom-Json +if ( + $releaseDocument.schema_version -cne "missioncore.m49-t3-ravnoves-worker-release/v1" -or + $releaseDocument.worker_id -cne "worker-006" -or + $releaseDocument.candidate_id -cne "travel" +) { + throw "M49 T3 probe 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 T3 probe 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 T3 probe requires 16 GiB free memory; observed {0:N2} GiB" -f $freeMemoryGiB) +} +$canonicalTritonBefore = Get-Container "ndc-mission-core-triton" +if ( + -not $canonicalTritonBefore.State.Running -or + $canonicalTritonBefore.State.Health.Status -cne "healthy" +) { + throw "Canonical Mission Core Triton must remain healthy during M49 T3 probe" +} +Assert-Image $TravelImageTag $TravelImageId +Assert-Image $ParityImageTag $ParityImageId + +$prepareName = "ndc-mission-core-m49-t3-prepare-$RunId" +$travelName = "ndc-mission-core-m49-t3-probe-$RunId" +$analyzeName = "ndc-mission-core-m49-t3-analyze-$RunId" +foreach ($name in @($prepareName, $travelName, $analyzeName)) { + if (& docker ps -a --format "{{.Names}}" --filter "name=^/$name$") { + throw "M49 T3 probe 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) + ":/probe") ` + $ParityImageTag /release/prepare_ravnoves_probe.py ` + --source-pack /source/lidar-pack.npz ` + --config /release/m49-travel-ravnoves-compatibility-v1.json ` + --output-root /probe/inputs + Assert-LastExitCode "M49 T3 RAVNOVES input preparation" + + & docker run --rm --name $travelName --network none --cpus 16 --memory 24g ` + --entrypoint /bin/bash ` + --volume ((Convert-ToDockerPath $payload) + ":/release:ro") ` + --volume ((Convert-ToDockerPath $runOutput) + ":/probe") ` + $TravelImageTag /release/run_ravnoves_probe.sh + Assert-LastExitCode "M49 T3 RAVNOVES TRAVEL probe" + + & docker run --rm --name $analyzeName --network none --cpus 8 --memory 16g ` + --entrypoint python3 ` + --volume ((Convert-ToDockerPath $payload) + ":/release:ro") ` + --volume ((Convert-ToDockerPath $runOutput) + ":/probe") ` + $ParityImageTag /release/analyze_ravnoves_probe.py ` + --probe-root /probe ` + --config /release/m49-travel-ravnoves-compatibility-v1.json + Assert-LastExitCode "M49 T3 RAVNOVES result analysis" +} finally { + foreach ($name in @($prepareName, $travelName, $analyzeName)) { + Remove-ExactContainer $name + } +} +$completed = [DateTimeOffset]::UtcNow + +$resultPath = Join-Path $runOutput "result.json" +if (-not (Test-Path -LiteralPath $resultPath -PathType Leaf)) { + throw "M49 T3 RAVNOVES result.json is missing" +} +$result = Get-Content -LiteralPath $resultPath -Raw | ConvertFrom-Json +if ($result.status -cne "passed") { throw "M49 T3 RAVNOVES probe did not complete" } +$canonicalTritonAfter = Get-Container "ndc-mission-core-triton" +if ( + -not $canonicalTritonAfter.State.Running -or + $canonicalTritonAfter.State.Health.Status -cne "healthy" -or + [string]$canonicalTritonAfter.Id -cne [string]$canonicalTritonBefore.Id +) { + throw "Canonical Mission Core Triton changed during M49 T3 probe" +} + +$summary = [ordered]@{ + schema_version = "missioncore.m49-t3-ravnoves-worker-summary/v1" + worker_id = "worker-006" + run_id = $RunId + code_revision = [string]$releaseDocument.code_revision + travel_image_id = $TravelImageId + parity_image_id = $ParityImageId + source_pack_sha256 = $sourcePackSha + 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]$canonicalTritonAfter.Id + canonical_triton_health = [string]$canonicalTritonAfter.State.Health.Status + candidate_run_count = [int]$result.candidate_run_count + direct_upstream_profile_compatible = [bool]$result.summary.direct_upstream_profile_compatible + ravnoves00_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-M49T3TravelRavnovesProbeAsInteractiveUser.ps1 b/experiments/perception/worker/Invoke-M49T3TravelRavnovesProbeAsInteractiveUser.ps1 new file mode 100644 index 0000000..adbe824 --- /dev/null +++ b/experiments/perception/worker/Invoke-M49T3TravelRavnovesProbeAsInteractiveUser.ps1 @@ -0,0 +1,61 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$ReleaseRoot, + [Parameter(Mandatory = $true)] + [ValidatePattern("^[A-Za-z0-9._-]{1,96}$")] + [string]$RunId +) + +$ErrorActionPreference = "Stop" +$taskName = "MissionCore-M49T3TravelRavnoves" +$release = (Resolve-Path -LiteralPath $ReleaseRoot).Path +$runner = Join-Path $release "payload\Invoke-M49T3TravelRavnovesProbe.ps1" +if (-not (Test-Path -LiteralPath $runner -PathType Leaf)) { + throw "M49 T3 RAVNOVES 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 M49 T3 TRAVEL compatibility probe on RAVNOVES00." ` + -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/analyze_ravnoves_probe.py b/experiments/perception/worker/m49_t3_travel/analyze_ravnoves_probe.py new file mode 100644 index 0000000..64715ca --- /dev/null +++ b/experiments/perception/worker/m49_t3_travel/analyze_ravnoves_probe.py @@ -0,0 +1,214 @@ +#!/usr/bin/env python3 +"""Analyze TRAVEL partitions without assigning navigation or visual-quality authority.""" + +from __future__ import annotations + +import argparse +import csv +import hashlib +import json +from collections import Counter +from pathlib import Path + +import numpy as np + + +class ProbeAnalysisError(RuntimeError): + """The candidate output does not satisfy the evidence 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 _load(path: Path, columns: int) -> np.ndarray: + values = np.fromfile(path, dtype=np.float32) + if values.size % columns: + raise ProbeAnalysisError(f"malformed float32 output: {path}") + result = values.reshape(-1, columns) + if not np.isfinite(result).all(): + raise ProbeAnalysisError(f"non-finite output: {path}") + return result + + +def _point_multiset(points: np.ndarray) -> Counter[bytes]: + contiguous = np.ascontiguousarray(points[:, :3], dtype=np.float32) + return Counter(row.tobytes() for row in contiguous) + + +def multiset_jaccard(left: np.ndarray, right: np.ndarray) -> float: + left_counts = _point_multiset(left) + right_counts = _point_multiset(right) + keys = left_counts.keys() | right_counts.keys() + intersection = sum(min(left_counts[key], right_counts[key]) for key in keys) + union = sum(max(left_counts[key], right_counts[key]) for key in keys) + return 1.0 if union == 0 else intersection / union + + +def _timings(path: Path) -> dict[tuple[str, str, int], dict[str, float | int]]: + result: dict[tuple[str, str, int], dict[str, float | int]] = {} + with path.open(newline="", encoding="utf-8") as stream: + for row in csv.DictReader(stream, delimiter="\t"): + key = (row["profile"], row["variant"], int(row["slot"])) + result[key] = { + "wall_seconds": float(row["wall_seconds"]), + "max_rss_kib": int(row["max_rss_kib"]), + } + if len(result) != 40: + raise ProbeAnalysisError("timing row count changed") + return result + + +def analyze(probe_root: Path, config_path: Path) -> dict[str, object]: + inputs = probe_root / "inputs" + outputs = probe_root / "outputs" + manifest = json.loads((inputs / "input-manifest.json").read_text(encoding="utf-8")) + config = json.loads(config_path.read_text(encoding="utf-8")) + if ( + manifest.get("schema_version") != "missioncore.m49-travel-ravnoves-probe-input/v1" + or manifest.get("config_sha256") != sha256_file(config_path) + or len(manifest.get("records", [])) != 20 + ): + raise ProbeAnalysisError("probe input manifest changed") + timings = _timings(probe_root / "travel-timing.tsv") + rows: list[dict[str, object]] = [] + variants: dict[tuple[str, int, str], np.ndarray] = {} + for record in manifest["records"]: + profile = str(record["profile_id"]) + slot = int(record["slot"]) + for variant in ("source", "reversed"): + input_path = inputs / record["variants"][variant]["relative_path"] + if sha256_file(input_path) != record["variants"][variant]["sha256"]: + raise ProbeAnalysisError("probe input changed after preparation") + native = _load(input_path, 4) + xy_range = np.linalg.norm(native[:, :2].astype(np.float64), axis=1) + eligible_input_points = int(np.count_nonzero((xy_range > 1.0) & (xy_range < 80.0))) + base = outputs / profile / variant / str(slot) + ground = _load(base.with_name(base.name + "_ground.bin"), 4) + nonground = _load(base.with_name(base.name + "_nonground.bin"), 4) + labeled = _load(base.with_name(base.name + "_labeled.bin"), 5) + variants[(profile, slot, variant + "-ground")] = ground + variants[(profile, slot, variant + "-nonground")] = nonground + variants[(profile, slot, variant + "-labeled")] = labeled + cluster_ids = np.unique(labeled[:, 4].astype(np.int64)) + cluster_ids = cluster_ids[cluster_ids > 0] + rows.append( + { + "profile_id": profile, + "slot": slot, + "anchor_frame_index": int(record["anchor_frame_index"]), + "variant": variant, + "input_points": int(native.shape[0]), + "eligible_input_points": eligible_input_points, + "ground_points": int(ground.shape[0]), + "nonground_points": int(nonground.shape[0]), + "labeled_points": int(labeled.shape[0]), + "cluster_count": int(cluster_ids.size), + "partition_accounted_for_all_eligible_input_points": bool( + ground.shape[0] + nonground.shape[0] == eligible_input_points + ), + "nonground_labeled_fraction": ( + 1.0 + if nonground.shape[0] == 0 + else float(labeled.shape[0] / nonground.shape[0]) + ), + **timings[(profile, variant, slot)], + } + ) + threshold = float(config["compatibility_acceptance"]["minimum_partition_multiset_jaccard"]) + labeled_threshold = float( + config["compatibility_acceptance"]["minimum_labeled_multiset_jaccard"] + ) + comparisons: list[dict[str, object]] = [] + for profile in ("current_increment", "causal_rolling_1s"): + for slot, anchor in enumerate(config["anchors"]): + ground_jaccard = multiset_jaccard( + variants[(profile, slot, "source-ground")], + variants[(profile, slot, "reversed-ground")], + ) + nonground_jaccard = multiset_jaccard( + variants[(profile, slot, "source-nonground")], + variants[(profile, slot, "reversed-nonground")], + ) + labeled_jaccard = multiset_jaccard( + variants[(profile, slot, "source-labeled")], + variants[(profile, slot, "reversed-labeled")], + ) + comparisons.append( + { + "profile_id": profile, + "slot": slot, + "anchor_frame_index": int(anchor), + "ground_multiset_jaccard": ground_jaccard, + "nonground_multiset_jaccard": nonground_jaccard, + "labeled_multiset_jaccard": labeled_jaccard, + "partition_order_accepted": bool( + ground_jaccard >= threshold and nonground_jaccard >= threshold + ), + "aos_order_accepted": bool(labeled_jaccard >= labeled_threshold), + } + ) + partition_complete = all( + bool(row["partition_accounted_for_all_eligible_input_points"]) for row in rows + ) + partition_order = all(bool(row["partition_order_accepted"]) for row in comparisons) + aos_order = all(bool(row["aos_order_accepted"]) for row in comparisons) + wall = np.asarray([float(row["wall_seconds"]) for row in rows], dtype=np.float64) + rss = np.asarray([int(row["max_rss_kib"]) for row in rows], dtype=np.int64) + result = { + "schema_version": "missioncore.m49-travel-ravnoves-compatibility-result/v1", + "status": "passed", + "source_pack_sha256": manifest["source_pack_sha256"], + "config_sha256": manifest["config_sha256"], + "input_record_count": len(manifest["records"]), + "candidate_run_count": len(rows), + "rows": rows, + "order_comparisons": comparisons, + "summary": { + "partition_complete": partition_complete, + "partition_order_accepted": partition_order, + "aos_order_accepted": aos_order, + "direct_upstream_profile_compatible": bool( + partition_complete and partition_order and aos_order + ), + "wall_seconds_p50": float(np.median(wall)), + "wall_seconds_p95": float(np.percentile(wall, 95)), + "max_rss_kib": int(rss.max()), + "minimum_ground_multiset_jaccard": min( + float(row["ground_multiset_jaccard"]) for row in comparisons + ), + "minimum_nonground_multiset_jaccard": min( + float(row["nonground_multiset_jaccard"]) for row in comparisons + ), + "minimum_labeled_multiset_jaccard": min( + float(row["labeled_multiset_jaccard"]) for row in comparisons + ), + }, + "authority": { + "ravnoves00_visual_quality_accepted": False, + "realtime_accepted": False, + "navigation_or_actuation_allowed": False, + }, + } + (probe_root / "result.json").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("--probe-root", type=Path, required=True) + parser.add_argument("--config", type=Path, required=True) + arguments = parser.parse_args() + result = analyze(arguments.probe_root, arguments.config) + 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_ravnoves_probe.py b/experiments/perception/worker/m49_t3_travel/prepare_ravnoves_probe.py new file mode 100644 index 0000000..740e1ea --- /dev/null +++ b/experiments/perception/worker/m49_t3_travel/prepare_ravnoves_probe.py @@ -0,0 +1,238 @@ +#!/usr/bin/env python3 +"""Prepare causal RAVNOVES00 inputs for the pinned TRAVEL compatibility probe.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import math +from pathlib import Path + +import numpy as np + +SOURCE_PACK_SHA256 = "0685d24219d8236caf8b7f1685e93f6d6b59e7fd015a768d88a92bbe8b154944" +EXPECTED_ARRAYS = { + "cloud_offsets", + "cloud_points_map", + "frame_indices", + "pose_positions_map", + "pose_quaternions_map_from_lidar", + "sample_available", + "session_seconds", +} + + +class ProbePreparationError(RuntimeError): + """The immutable source or requested compatibility profile 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 rotation_map_from_lidar(quaternion_xyzw: np.ndarray) -> np.ndarray: + quaternion = np.asarray(quaternion_xyzw, dtype=np.float64) + if quaternion.shape != (4,) or not np.isfinite(quaternion).all(): + raise ProbePreparationError("pose quaternion is invalid") + norm = float(np.linalg.norm(quaternion)) + if not math.isfinite(norm) or not 0.99 <= norm <= 1.01: + raise ProbePreparationError("pose quaternion is not normalized") + x, y, z, w = quaternion / norm + return np.asarray( + [ + [1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w)], + [2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w)], + [2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)], + ], + dtype=np.float64, + ) + + +def points_in_current_lidar_frame( + points_map: np.ndarray, + position_map: np.ndarray, + quaternion_map_from_lidar: 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 ProbePreparationError("point or pose arrays are invalid") + rotation = rotation_map_from_lidar(quaternion_map_from_lidar) + result = (points - position) @ rotation + if not np.isfinite(result).all(): + raise ProbePreparationError("sensor-frame points are non-finite") + return result + + +def xyzi(points_lidar: np.ndarray) -> np.ndarray: + result = np.zeros((points_lidar.shape[0], 4), dtype=np.float32) + result[:, :3] = points_lidar.astype(np.float32) + return result + + +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["pose_quaternions_map_from_lidar"].shape != (frames, 4) + or arrays["pose_quaternions_map_from_lidar"].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 ProbePreparationError("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 _profile_points( + arrays: dict[str, np.ndarray], + *, + anchor: int, + profile: dict[str, object], +) -> tuple[np.ndarray, tuple[int, ...]]: + seconds = arrays["session_seconds"] + if profile["id"] == "current_increment": + contributors = (anchor,) + elif profile["id"] == "causal_rolling_1s": + history = float(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]) + ) + else: + raise ProbePreparationError("unknown input profile") + if not contributors or contributors[-1] != anchor: + raise ProbePreparationError("causal profile does not contain its anchor") + points_map = np.concatenate( + [_frame_points(arrays, frame_index) for frame_index in contributors], axis=0 + ) + local_radius = profile["local_radius_m"] + if local_radius is not None: + 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) <= float(local_radius)] + sensor_points = points_in_current_lidar_frame( + points_map, + arrays["pose_positions_map"][anchor], + arrays["pose_quaternions_map_from_lidar"][anchor], + ) + if sensor_points.shape[0] == 0: + raise ProbePreparationError("compatibility profile produced an empty cloud") + return xyzi(sensor_points), contributors + + +def prepare(source_pack: Path, config_path: Path, output_root: Path) -> dict[str, object]: + if output_root.exists(): + raise ProbePreparationError("probe input root already exists") + if sha256_file(source_pack) != SOURCE_PACK_SHA256: + raise ProbePreparationError("RAVNOVES00 lidar-pack digest changed") + config = json.loads(config_path.read_text(encoding="utf-8")) + if ( + config.get("schema_version") != "missioncore.m49-travel-ravnoves-compatibility/v1" + or config.get("source", {}).get("source_pack_sha256") != SOURCE_PACK_SHA256 + or config.get("order_variants") != ["source", "reversed"] + ): + raise ProbePreparationError("compatibility config changed") + with np.load(source_pack, allow_pickle=False) as archive: + if not EXPECTED_ARRAYS.issubset(archive.files): + raise ProbePreparationError("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 ProbePreparationError("compatibility anchors changed") + records: list[dict[str, object]] = [] + for profile in config["input_profiles"]: + profile_id = str(profile["id"]) + for slot, anchor in enumerate(anchors): + if not bool(arrays["sample_available"][anchor]): + raise ProbePreparationError(f"anchor {anchor} has no lidar sample") + native, contributors = _profile_points(arrays, anchor=anchor, profile=profile) + record: dict[str, object] = { + "slot": slot, + "anchor_frame_index": anchor, + "anchor_source_frame_index": int(arrays["frame_indices"][anchor]), + "anchor_session_seconds": float(arrays["session_seconds"][anchor]), + "profile_id": profile_id, + "point_count": int(native.shape[0]), + "contributing_frame_indices": list(contributors), + "contributing_source_frame_indices": [ + int(arrays["frame_indices"][value]) for value in contributors + ], + "contributing_session_seconds": [ + float(arrays["session_seconds"][value]) for value in contributors + ], + "variants": {}, + } + for variant, points in (("source", native), ("reversed", native[::-1])): + target = ( + output_root / "profiles" / profile_id / variant / "velodyne" / f"{slot:06d}.bin" + ) + target.parent.mkdir(parents=True, exist_ok=True) + contiguous = np.ascontiguousarray(points, dtype=np.float32) + target.write_bytes(contiguous.tobytes()) + record["variants"][variant] = { + "relative_path": target.relative_to(output_root).as_posix(), + "bytes": target.stat().st_size, + "sha256": sha256_file(target), + } + records.append(record) + manifest = { + "schema_version": "missioncore.m49-travel-ravnoves-probe-input/v1", + "source_pack_sha256": SOURCE_PACK_SHA256, + "config_sha256": sha256_file(config_path), + "intensity_policy": "zero-filled-algorithm-compatibility-only", + "coordinate_frame": "current-lidar", + "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_ravnoves_probe.sh b/experiments/perception/worker/m49_t3_travel/run_ravnoves_probe.sh new file mode 100644 index 0000000..c4569e3 --- /dev/null +++ b/experiments/perception/worker/m49_t3_travel/run_ravnoves_probe.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail + +readonly INPUT_ROOT=/probe/inputs +readonly OUTPUT_ROOT=/probe/outputs +readonly LOG_ROOT=/probe/logs +readonly TIMING_PATH=/probe/travel-timing.tsv +readonly TRAVEL_BIN=/opt/travel/core-build/examples/run_travel_kitti + +test -f "${INPUT_ROOT}/input-manifest.json" +test ! -e "${OUTPUT_ROOT}" +test ! -e "${LOG_ROOT}" +mkdir -p "${OUTPUT_ROOT}" "${LOG_ROOT}" +printf 'profile\tvariant\tslot\twall_seconds\tmax_rss_kib\n' > "${TIMING_PATH}" + +for profile in current_increment causal_rolling_1s; do + for variant in source reversed; do + sequence="${INPUT_ROOT}/profiles/${profile}/${variant}" + output="${OUTPUT_ROOT}/${profile}/${variant}" + mkdir -p "${output}" + for slot in $(seq 0 9); do + log="${LOG_ROOT}/${profile}-${variant}-${slot}.log" + timing="${LOG_ROOT}/${profile}-${variant}-${slot}.time" + /usr/bin/time -f '%e\t%M' -o "${timing}" \ + "${TRAVEL_BIN}" "${sequence}" "${slot}" "${output}" > "${log}" 2>&1 + read -r wall rss < "${timing}" + printf '%s\t%s\t%s\t%s\t%s\n' \ + "${profile}" "${variant}" "${slot}" "${wall}" "${rss}" >> "${TIMING_PATH}" + for suffix in ground nonground labeled; do + test -f "${output}/${slot}_${suffix}.bin" + done + done + done +done diff --git a/scripts/build_m49_t3_travel_ravnoves_worker_artifact.py b/scripts/build_m49_t3_travel_ravnoves_worker_artifact.py new file mode 100644 index 0000000..367e162 --- /dev/null +++ b/scripts/build_m49_t3_travel_ravnoves_worker_artifact.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +"""Build the deterministic M49 T3 TRAVEL RAVNOVES00 probe 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_ravnoves_probe.py"), + Path("experiments/perception/worker/m49_t3_travel/run_ravnoves_probe.sh"), + Path("experiments/perception/worker/m49_t3_travel/analyze_ravnoves_probe.py"), + Path("experiments/perception/worker/Invoke-M49T3TravelRavnovesProbe.ps1"), + Path("experiments/perception/worker/Invoke-M49T3TravelRavnovesProbeAsInteractiveUser.ps1"), + Path("config/perception/m49-travel-ravnoves-compatibility-v1.json"), +) +PATCH_ID = re.compile(r"^[A-Za-z0-9._-]{1,96}$") + + +class ArtifactBuildError(RuntimeError): + """The probe 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-t3-ravnoves-") 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-t3-ravnoves-worker-release/v1", + "patch_id": patch_id, + "code_revision": selected_revision, + "worker_id": "worker-006", + "candidate_id": "travel", + "license": "GPL-3.0-or-later", + "source_pack_sha256": ( + "0685d24219d8236caf8b7f1685e93f6d6b59e7fd015a768d88a92bbe8b154944" + ), + "images": { + "travel": "sha256:7b412020f4d8392d1d1ed1b33beadc44140f0ea8f781e62dd69796042334300f", + "parity": "sha256:ceb13548617e4bd3f619766bfdff00af3fa5160946b367828da6d2233dcdcba0", + }, + "authority": { + "ravnoves00_visual_quality_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_t3_travel_ravnoves_probe.py b/tests/test_m49_t3_travel_ravnoves_probe.py new file mode 100644 index 0000000..49258cb --- /dev/null +++ b/tests/test_m49_t3_travel_ravnoves_probe.py @@ -0,0 +1,88 @@ +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] + + +def _load(name: str, path: Path): + spec = importlib.util.spec_from_file_location(name, path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +PREPARE = _load( + "m49_t3_prepare", + REPOSITORY_ROOT / "experiments/perception/worker/m49_t3_travel/prepare_ravnoves_probe.py", +) +ANALYZE = _load( + "m49_t3_analyze", + REPOSITORY_ROOT / "experiments/perception/worker/m49_t3_travel/analyze_ravnoves_probe.py", +) +BUILDER = _load( + "m49_t3_ravnoves_builder", + REPOSITORY_ROOT / "scripts/build_m49_t3_travel_ravnoves_worker_artifact.py", +) + + +def _sha256(value: bytes) -> str: + return hashlib.sha256(value).hexdigest() + + +def test_map_points_are_transformed_into_current_lidar_frame() -> None: + points = np.asarray([[2.0, 4.0, 6.0], [3.0, 6.0, 9.0]], dtype=np.float32) + result = PREPARE.points_in_current_lidar_frame( + points, + np.asarray([1.0, 2.0, 3.0], dtype=np.float64), + np.asarray([0.0, 0.0, 0.0, 1.0], dtype=np.float64), + ) + assert np.array_equal(result, np.asarray([[1.0, 2.0, 3.0], [2.0, 4.0, 6.0]])) + + +def test_multiset_jaccard_ignores_order_but_not_membership() -> None: + left = np.asarray([[1, 2, 3, 0], [4, 5, 6, 0], [4, 5, 6, 0]], dtype=np.float32) + reversed_points = left[::-1] + changed = left.copy() + changed[0, 0] = 99 + assert ANALYZE.multiset_jaccard(left, reversed_points) == 1.0 + assert ANALYZE.multiset_jaccard(left, changed) < 1.0 + + +def test_probe_worker_artifact_is_deterministic_and_bounded(tmp_path: Path) -> None: + patch_id = "mission-core-m49-t3-ravnoves-unit-001" + revision = "a" * 40 + 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"] == _sha256(first_bytes) + with tarfile.open(first["artifact"], "r:gz") as archive: + members = archive.getmembers() + regular = { + member.name: archive.extractfile(member).read() # type: ignore[union-attr] + for member in members + if member.isfile() + } + assert all(not member.issym() and not member.islnk() for member in members) + assert set(regular) == { + "manifest.env", + "files.txt", + *(f"payload/{name}" for name in first["payload_files"]), + } + release = json.loads(regular["payload/release.json"]) + assert release["code_revision"] == revision + assert release["candidate_id"] == "travel" + assert release["authority"] == { + "navigation_or_actuation_allowed": False, + "ravnoves00_visual_quality_accepted": False, + "realtime_accepted": False, + } + assert "--gpus" not in regular["payload/Invoke-M49T3TravelRavnovesProbe.ps1"].decode()