Добавление канонического графа M4.7
This commit is contained in:
@@ -0,0 +1,95 @@
|
|||||||
|
{
|
||||||
|
"schema_version": "missioncore.reference-perception-graph-config/v2",
|
||||||
|
"graph_id": "reference-perception-graph/v2",
|
||||||
|
"source_profile_id": "m4-ravnoves00-recorded-realtime/v1",
|
||||||
|
"providers": [
|
||||||
|
{
|
||||||
|
"role": "source",
|
||||||
|
"provider_id": "ravnoves00-recorded-source/v1",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"revision": "m4-ravnoves00-recorded-realtime/v1",
|
||||||
|
"sha256": "ea10359339e6cce31b5780a2710299771cab7cc0c1c2a2b56a1621f786b31fa8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "detector",
|
||||||
|
"provider_id": "triton-yolox-s-raw-kb4/v1",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"revision": "yolox_s:1",
|
||||||
|
"sha256": "ea10359339e6cce31b5780a2710299771cab7cc0c1c2a2b56a1621f786b31fa8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "geometry",
|
||||||
|
"provider_id": "ravnoves00-geometry-association/v1",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"revision": "m4-ravnoves00-e29-e32-geometry/v1",
|
||||||
|
"sha256": "cc666c9389a5e221957faddec89584709b66918d14abaf646f1832e001421999"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "temporal",
|
||||||
|
"provider_id": "bounded-spatial-temporal-layer/v1",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"revision": "m4-bounded-temporal-motion/v1",
|
||||||
|
"sha256": "7130eaee24a95c7d888bf7598010e03e129e1c3ac5b34bcd8401015ff4244b39"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "motion",
|
||||||
|
"provider_id": "class-independent-motion-estimator/v1",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"revision": "m4-bounded-temporal-motion/v1",
|
||||||
|
"sha256": "7130eaee24a95c7d888bf7598010e03e129e1c3ac5b34bcd8401015ff4244b39"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "rolling",
|
||||||
|
"provider_id": "rolling-local-obstacle-map/v1",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"revision": "ravnoves00-rolling-local-obstacle-map/v1",
|
||||||
|
"sha256": "f7e3315eaf6ffaf3aee1e04913933812092cf82bbcc9984c1a6fa2d9250e6784"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "threat",
|
||||||
|
"provider_id": "dual-evidence-replay-threat/v3",
|
||||||
|
"version": "3.0.0",
|
||||||
|
"revision": "m4-ravnoves00-virtual-corridor/v3",
|
||||||
|
"sha256": "8c3a5aa837da1f028f5998fb504a1381f9b2b68de6420a32160410b6dc0887c7"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"queues": [
|
||||||
|
{
|
||||||
|
"stage_id": "detector",
|
||||||
|
"capacity": 2,
|
||||||
|
"deadline_ns": 1000000000,
|
||||||
|
"terminal_timeout_ns": 90000000000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage_id": "geometry",
|
||||||
|
"capacity": 2,
|
||||||
|
"deadline_ns": 1500000000,
|
||||||
|
"terminal_timeout_ns": 90000000000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage_id": "temporal",
|
||||||
|
"capacity": 2,
|
||||||
|
"deadline_ns": 1750000000,
|
||||||
|
"terminal_timeout_ns": 90000000000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage_id": "rolling",
|
||||||
|
"capacity": 2,
|
||||||
|
"deadline_ns": 2000000000,
|
||||||
|
"terminal_timeout_ns": 90000000000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage_id": "threat",
|
||||||
|
"capacity": 2,
|
||||||
|
"deadline_ns": 2250000000,
|
||||||
|
"terminal_timeout_ns": 90000000000
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"authority": {
|
||||||
|
"mode": "replay-simulated",
|
||||||
|
"physical_live": false,
|
||||||
|
"commands_enabled": false,
|
||||||
|
"actuation_allowed": false,
|
||||||
|
"navigation_or_safety_accepted": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
Date: 2026-08-05
|
Date: 2026-08-05
|
||||||
|
|
||||||
Status: in progress; M4.0–M4.6 accepted, E47 diagnostic semantic/SLAM shadow is parallel, M4.7 Worker 006 cutover remains next
|
Status: in progress; M4.0–M4.6 accepted, the M4.7 canonical graph and Worker
|
||||||
|
006 shadow artifact are implemented locally, and Worker preflight/full shadow
|
||||||
|
acceptance plus durable cutover remain open
|
||||||
|
|
||||||
Audit base: `1b3e0b3` on `feat/simulation-polygon-s1`
|
Audit base: `1b3e0b3` on `feat/simulation-polygon-s1`
|
||||||
|
|
||||||
@@ -111,7 +113,7 @@ Those systems remain separate platform workstreams.
|
|||||||
| Realtime worker | Worker 006 has healthy Triton and persistent perception containers plus live Telegraf | Infrastructure exists |
|
| Realtime worker | Worker 006 has healthy Triton and persistent perception containers plus live Telegraf | Infrastructure exists |
|
||||||
| Worker graph | The persistent process still executes `run_e15_shadow_inference.py serve` with E15/E19/E8/E3/E23 profiles | Open architectural blocker: runtime remains LAB-generation-specific |
|
| Worker graph | The persistent process still executes `run_e15_shadow_inference.py serve` with E15/E19/E8/E3/E23 profiles | Open architectural blocker: runtime remains LAB-generation-specific |
|
||||||
| Model service | Canonical Triton currently exposes pinned `yolox_s` and `pointpillars`; PointPillars was rejected as a K1 product candidate | Reuse `yolox_s`; do not reopen PointPillars |
|
| Model service | Canonical Triton currently exposes pinned `yolox_s` and `pointpillars`; PointPillars was rejected as a K1 product candidate | Reuse `yolox_s`; do not reopen PointPillars |
|
||||||
| CV provider interfaces | No executable `DetectorProvider`, `GeometryProvider`, `TemporalProvider` or object-map contract exists in the product package | Open architectural blocker |
|
| CV provider interfaces | Product-owned source, detector, geometry, temporal, motion, rolling-map and threat protocols plus `LocalObstacleMap` exist under `k1link.perception` | Closed in code; Worker evidence remains open |
|
||||||
| LAB execution | 33 evidence definitions are fully classified; M4, E33, E35 and E46J are canonical canaries, E47 is one bounded experimental adapter, and 28 remain read-only legacy | Closed for LAB governance; no bulk legacy migration |
|
| LAB execution | 33 evidence definitions are fully classified; M4, E33, E35 and E46J are canonical canaries, E47 is one bounded experimental adapter, and 28 remain read-only legacy | Closed for LAB governance; no bulk legacy migration |
|
||||||
| Production import graph | 93 compute modules (72,456 lines), 51 web modules and many direct `web → compute.e*/l*` imports remain | Do not clean wholesale; prohibit new dependencies and isolate the new graph |
|
| Production import graph | 93 compute modules (72,456 lines), 51 web modules and many direct `web → compute.e*/l*` imports remain | Do not clean wholesale; prohibit new dependencies and isolate the new graph |
|
||||||
| Telemetry | Generic pipeline telemetry, multi-contour enrollment, bounded journal and current Worker 006 Telegraf service exist | Reuse; add graph-stage and object-state metrics |
|
| Telemetry | Generic pipeline telemetry, multi-contour enrollment, bounded journal and current Worker 006 Telegraf service exist | Reuse; add graph-stage and object-state metrics |
|
||||||
@@ -222,6 +224,9 @@ LiveK1Source (later) ──┘ │
|
|||||||
MotionEstimator
|
MotionEstimator
|
||||||
│
|
│
|
||||||
v
|
v
|
||||||
|
RollingLocalObstacleMap
|
||||||
|
│
|
||||||
|
v
|
||||||
LocalObstacleMap
|
LocalObstacleMap
|
||||||
│
|
│
|
||||||
virtual rig/corridor (M4 only)
|
virtual rig/corridor (M4 only)
|
||||||
@@ -495,6 +500,11 @@ Physical mounted threat acceptance remains outside Milestone 4.
|
|||||||
|
|
||||||
### M4.7 — cut Worker 006 over to the canonical graph
|
### M4.7 — cut Worker 006 over to the canonical graph
|
||||||
|
|
||||||
|
Status: implementation complete locally on 2026-08-23; Worker 006 preflight,
|
||||||
|
full lossless shadow evidence and durable process replacement are not yet
|
||||||
|
accepted. The current E15 worker and Triton identities remain the rollback
|
||||||
|
predecessor. No K1, Zarya or connection-stack change is part of this phase.
|
||||||
|
|
||||||
Deliverables:
|
Deliverables:
|
||||||
|
|
||||||
- package one digest-bound worker artifact containing the graph runner, provider
|
- package one digest-bound worker artifact containing the graph runner, provider
|
||||||
@@ -655,7 +665,7 @@ is requested. None may be silently claimed from a successful recorded replay.
|
|||||||
|
|
||||||
Milestone 4 is complete only when all of the following are true:
|
Milestone 4 is complete only when all of the following are true:
|
||||||
|
|
||||||
- [ ] One versioned reference graph exists outside experiment-named modules.
|
- [x] One versioned reference graph exists outside experiment-named modules.
|
||||||
- [ ] RAVNOVES00 runs through the same graph contract intended for a live source.
|
- [ ] RAVNOVES00 runs through the same graph contract intended for a live source.
|
||||||
- [ ] Semantic class and persistent detector ID are optional diagnostics only.
|
- [ ] Semantic class and persistent detector ID are optional diagnostics only.
|
||||||
- [ ] Camera, fused and geometry-only obstacles share one canonical observation
|
- [ ] Camera, fused and geometry-only obstacles share one canonical observation
|
||||||
@@ -1135,6 +1145,51 @@ after indexing, a 12-frame spatial chunk was served in approximately `33 ms` and
|
|||||||
was about `1.27 MB`. M4.7 may proceed only from the corrected identities above;
|
was about `1.27 MB`. M4.7 may proceed only from the corrected identities above;
|
||||||
physical-live, collision, navigation and actuation authority remain false.
|
physical-live, collision, navigation and actuation authority remain false.
|
||||||
|
|
||||||
|
### 2026-08-23 — M4.7 canonical graph and shadow release implementation
|
||||||
|
|
||||||
|
`reference-perception-graph/v2` is now the product-owned assembly contract. It
|
||||||
|
composes the admitted recorded source, frozen YOLOX detector, current geometry,
|
||||||
|
bounded temporal state, class-independent motion, rolling local occupancy and
|
||||||
|
replay threat providers without importing an E/L-named module. Rolling retained
|
||||||
|
occupancy is an explicit stage rather than an offline side computation, so the
|
||||||
|
published map preserves the accepted M4.5R/M4.6 rule: current plus retained is
|
||||||
|
occupied, held/expired is unknown, and missing republication never means free.
|
||||||
|
|
||||||
|
The graph has two explicit bounded execution policies:
|
||||||
|
|
||||||
|
- `source-paced-latest-wins` for the later 1× realtime canary, with every
|
||||||
|
superseded or stale packet receiving a terminal outcome;
|
||||||
|
- `lossless-replay` for deterministic parity, with bounded backpressure and
|
||||||
|
zero accepted supersession.
|
||||||
|
|
||||||
|
Every stage is deadline-checked before and after provider work, queue
|
||||||
|
high-watermarks are reported, terminal accounting is closed, and V2 restart is
|
||||||
|
fail-closed unless all stateful providers are freshly instantiated. A production
|
||||||
|
assembly boundary validates the digest of every provider profile and takes only
|
||||||
|
explicit paths to the camera index, video, source pack, local surface, valid-FOV
|
||||||
|
mask and Triton origin. The CLI writes immutable content-addressed frame,
|
||||||
|
terminal, report and manifest ledgers; acceptance requires exactly `4,489`
|
||||||
|
delivered frames with zero failed, stale, superseded, rejected or unavailable
|
||||||
|
terminal outcomes.
|
||||||
|
|
||||||
|
The new Worker package uses transition `m47-canonical-graph-shadow-v1`. It is a
|
||||||
|
separate deterministic artifact and does not relabel the accepted historical
|
||||||
|
`m4-detector-shadow-v1` wheel. Its PowerShell runner verifies release files,
|
||||||
|
inputs, dependency trees, disk reserve, pinned Worker/Triton predecessor and
|
||||||
|
container isolation before it can start the one-shot lossless graph shadow. It
|
||||||
|
adds no public port, never changes Triton, removes the candidate container after
|
||||||
|
the run and re-verifies the predecessor. Provider readiness and graph readiness
|
||||||
|
are emitted separately. The production builder refuses a dirty worktree so an
|
||||||
|
artifact cannot claim a Git revision which does not contain its wheel.
|
||||||
|
|
||||||
|
Local contract, graph, result-sealing, artifact and historical-rollback tests
|
||||||
|
pass. This is implementation evidence only. It does not claim that Worker 006
|
||||||
|
has the pinned local-surface input, that preflight has passed, that the 4,489
|
||||||
|
frame graph shadow matches the accepted M4.5R/M4.6 ledgers, or that the durable
|
||||||
|
E15 command has been replaced. Those are the next M4.7 acceptance actions, in
|
||||||
|
that order. K1, Zarya, the stable connection path and physical-live authority
|
||||||
|
remain untouched.
|
||||||
|
|
||||||
## Implementation order
|
## Implementation order
|
||||||
|
|
||||||
The implementation sequence is intentionally strict:
|
The implementation sequence is intentionally strict:
|
||||||
|
|||||||
@@ -0,0 +1,376 @@
|
|||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$ReleaseRoot,
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$ArtifactPath,
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[ValidatePattern("^[a-f0-9]{64}$")]
|
||||||
|
[string]$ExpectedArtifactSha256,
|
||||||
|
[string]$OutputRoot = "D:\NDC_MISSIONCORE\runtime\results\m47-reference-graph",
|
||||||
|
[ValidateRange(1, 1000)]
|
||||||
|
[int]$FreeGiBFloor = 300,
|
||||||
|
[switch]$PreflightOnly
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$ProgressPreference = "SilentlyContinue"
|
||||||
|
|
||||||
|
function Assert-LastExitCode([string]$Operation) {
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "$Operation failed with exit code $LASTEXITCODE"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-Sha256([string]$Path) {
|
||||||
|
return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||||
|
}
|
||||||
|
|
||||||
|
function Assert-FileSha256([string]$Path, [string]$Expected, [string]$Label) {
|
||||||
|
$item = Get-Item -LiteralPath (Resolve-Path -LiteralPath $Path).Path -Force
|
||||||
|
if ($item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) {
|
||||||
|
throw "$Label must be a regular file"
|
||||||
|
}
|
||||||
|
$observed = Get-Sha256 $item.FullName
|
||||||
|
if ($observed -cne $Expected) {
|
||||||
|
throw "$Label SHA-256 changed: expected $Expected, observed $observed"
|
||||||
|
}
|
||||||
|
return $item.FullName
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
$root = [IO.Path]::GetPathRoot($item.FullName).TrimEnd("\")
|
||||||
|
if (
|
||||||
|
-not $item.PSIsContainer -or
|
||||||
|
($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -or
|
||||||
|
$root -ine "D:"
|
||||||
|
) {
|
||||||
|
throw "$Label must be a real D: directory"
|
||||||
|
}
|
||||||
|
return $item.FullName
|
||||||
|
}
|
||||||
|
|
||||||
|
function Convert-ToDockerPath([string]$Path) {
|
||||||
|
return $Path.Replace("\", "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
function Assert-FreeSpace([string]$Phase) {
|
||||||
|
$free = [int64](Get-PSDrive -Name D).Free
|
||||||
|
$floor = [int64]$FreeGiBFloor * 1GB
|
||||||
|
Write-Host (
|
||||||
|
"DISK_GUARD PHASE={0} DRIVE=D FREE_BYTES={1} FREE_GIB={2} FLOOR_GIB={3}" -f
|
||||||
|
$Phase, $free, [math]::Round($free / 1GB, 3), $FreeGiBFloor
|
||||||
|
)
|
||||||
|
if ($free -lt ($floor + 1GB)) {
|
||||||
|
throw "D: lacks the guarded M4.7 reserve during $Phase"
|
||||||
|
}
|
||||||
|
return $free
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-ContainerIdentity([string]$Name) {
|
||||||
|
$json = & docker inspect $Name
|
||||||
|
Assert-LastExitCode "Docker inspection for $Name"
|
||||||
|
$rows = @($json | ConvertFrom-Json)
|
||||||
|
if ($rows.Count -ne 1) {
|
||||||
|
throw "Docker identity for $Name is not unique"
|
||||||
|
}
|
||||||
|
return $rows[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
function Assert-ContainerIdentity(
|
||||||
|
[string]$Name,
|
||||||
|
[string]$ExpectedId,
|
||||||
|
[string]$ExpectedImageId,
|
||||||
|
[bool]$RequireHealthy
|
||||||
|
) {
|
||||||
|
$container = Get-ContainerIdentity $Name
|
||||||
|
if (
|
||||||
|
$container.Id -cne $ExpectedId -or
|
||||||
|
$container.Image -cne $ExpectedImageId -or
|
||||||
|
-not $container.State.Running
|
||||||
|
) {
|
||||||
|
throw "$Name identity or running state changed"
|
||||||
|
}
|
||||||
|
if ($RequireHealthy -and $container.State.Health.Status -cne "healthy") {
|
||||||
|
throw "$Name is not healthy"
|
||||||
|
}
|
||||||
|
return $container
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-DirectoryTreeSha256([string]$Root, [object[]]$Files) {
|
||||||
|
$rootPrefix = $Root.TrimEnd("\") + "\"
|
||||||
|
$rows = @(
|
||||||
|
foreach ($file in $Files) {
|
||||||
|
if (-not $file.FullName.StartsWith($rootPrefix, [StringComparison]::OrdinalIgnoreCase)) {
|
||||||
|
throw "M4.7 dependency file escaped its declared root"
|
||||||
|
}
|
||||||
|
[pscustomobject]@{
|
||||||
|
Relative = $file.FullName.Substring($rootPrefix.Length).Replace("\", "/")
|
||||||
|
File = $file
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
$rows = @($rows | Sort-Object -Property Relative -CaseSensitive)
|
||||||
|
$digest = [Security.Cryptography.IncrementalHash]::CreateHash(
|
||||||
|
[Security.Cryptography.HashAlgorithmName]::SHA256
|
||||||
|
)
|
||||||
|
$encoding = New-Object System.Text.UTF8Encoding($false)
|
||||||
|
try {
|
||||||
|
foreach ($row in $rows) {
|
||||||
|
$record = "{0}`t{1}`t{2}`n" -f `
|
||||||
|
$row.Relative, $row.File.Length, (Get-Sha256 $row.File.FullName)
|
||||||
|
$digest.AppendData($encoding.GetBytes($record))
|
||||||
|
}
|
||||||
|
return ([BitConverter]::ToString($digest.GetHashAndReset())).Replace("-", "").ToLowerInvariant()
|
||||||
|
} finally {
|
||||||
|
$digest.Dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-Utf8NoBom([string]$Path, [string]$Value) {
|
||||||
|
$encoding = New-Object System.Text.UTF8Encoding($false)
|
||||||
|
[IO.File]::WriteAllText($Path, $Value, $encoding)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($env:COMPUTERNAME -cne "DESKTOP-OPJ8J04") {
|
||||||
|
throw "M4.7 shadow release is pinned to DESKTOP-OPJ8J04"
|
||||||
|
}
|
||||||
|
|
||||||
|
$release = Resolve-DDirectory $ReleaseRoot "M4.7 release root" $false
|
||||||
|
$payload = Resolve-DDirectory (Join-Path $release "payload") "M4.7 payload" $false
|
||||||
|
$artifact = Assert-FileSha256 $ArtifactPath $ExpectedArtifactSha256 "M4.7 release artifact"
|
||||||
|
$descriptorPath = Join-Path $payload "mission-core-worker-m47-graph-shadow-v2.json"
|
||||||
|
$descriptor = Get-Content -LiteralPath $descriptorPath -Raw | ConvertFrom-Json
|
||||||
|
if (
|
||||||
|
$descriptor.schema_version -cne "nodedc.mission-core-worker.shadow-release/v2" -or
|
||||||
|
$descriptor.transition -cne "m47-canonical-graph-shadow-v1" -or
|
||||||
|
$descriptor.component -cne "mission-core-worker" -or
|
||||||
|
$descriptor.host.node -cne $env:COMPUTERNAME -or
|
||||||
|
$descriptor.host.worker_id -cne "worker-006" -or
|
||||||
|
$descriptor.boundary.repository -cne "NODEDC_MISSION_CORE" -or
|
||||||
|
$descriptor.boundary.external_deploy_registry -ne $false -or
|
||||||
|
$descriptor.acceptance.run_mode -cne "lossless-replay" -or
|
||||||
|
$descriptor.acceptance.expected_frames -ne 4489 -or
|
||||||
|
$descriptor.acceptance.delivered_frames -ne 4489 -or
|
||||||
|
$descriptor.acceptance.failed_frames -ne 0 -or
|
||||||
|
$descriptor.acceptance.stale_frames -ne 0 -or
|
||||||
|
$descriptor.acceptance.superseded_frames -ne 0 -or
|
||||||
|
$descriptor.acceptance.accepted_parity -ne $true -or
|
||||||
|
$descriptor.readiness.graph.graph_id -cne "reference-perception-graph/v2" -or
|
||||||
|
$descriptor.readiness.graph.actuation_allowed -ne $false
|
||||||
|
) {
|
||||||
|
throw "M4.7 shadow descriptor contract changed"
|
||||||
|
}
|
||||||
|
|
||||||
|
$null = Assert-FileSha256 $PSCommandPath $descriptor.release.runner.sha256 "M4.7 runner"
|
||||||
|
$wheelPath = Assert-FileSha256 (
|
||||||
|
Join-Path $payload $descriptor.release.wheel.name
|
||||||
|
) $descriptor.release.wheel.sha256 "M4.7 wheel"
|
||||||
|
foreach ($entry in $descriptor.release.configs.PSObject.Properties) {
|
||||||
|
$null = Assert-FileSha256 (
|
||||||
|
Join-Path $payload $entry.Value.name
|
||||||
|
) $entry.Value.sha256 ("M4.7 config {0}" -f $entry.Name)
|
||||||
|
}
|
||||||
|
foreach ($entry in $descriptor.inputs.PSObject.Properties) {
|
||||||
|
$null = Assert-FileSha256 $entry.Value.host_path $entry.Value.sha256 (
|
||||||
|
"M4.7 input {0}" -f $entry.Name
|
||||||
|
)
|
||||||
|
}
|
||||||
|
foreach ($dependency in $descriptor.dependencies) {
|
||||||
|
$root = Resolve-DDirectory $dependency.host_path ("M4.7 dependency {0}" -f $dependency.id) $false
|
||||||
|
$files = @()
|
||||||
|
foreach ($include in $dependency.includes) {
|
||||||
|
$candidate = if ($include -eq ".") { $root } else { Join-Path $root $include }
|
||||||
|
$item = Get-Item -LiteralPath (Resolve-Path -LiteralPath $candidate).Path -Force
|
||||||
|
if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) {
|
||||||
|
throw "M4.7 dependency $($dependency.id) contains a reparse point"
|
||||||
|
}
|
||||||
|
if ($item.PSIsContainer) {
|
||||||
|
$files += @(Get-ChildItem -LiteralPath $item.FullName -File -Recurse -Force)
|
||||||
|
} else {
|
||||||
|
$files += @($item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$files = @($files | Sort-Object -Property FullName -Unique)
|
||||||
|
$bytes = [int64]0
|
||||||
|
foreach ($file in $files) {
|
||||||
|
if ($file.Attributes -band [IO.FileAttributes]::ReparsePoint) {
|
||||||
|
throw "M4.7 dependency $($dependency.id) contains a reparse-point file"
|
||||||
|
}
|
||||||
|
$bytes += [int64]$file.Length
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
$files.Count -ne [int]$dependency.file_count -or
|
||||||
|
$bytes -ne [int64]$dependency.bytes -or
|
||||||
|
(Get-DirectoryTreeSha256 $root $files) -cne [string]$dependency.tree_sha256
|
||||||
|
) {
|
||||||
|
throw "M4.7 dependency $($dependency.id) inventory changed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$imageRef = [string]$descriptor.container.image_ref
|
||||||
|
& docker image inspect $imageRef *> $null
|
||||||
|
Assert-LastExitCode "Pinned M4.7 image inspection"
|
||||||
|
$predecessor = $descriptor.predecessor.durable_worker
|
||||||
|
$tritonExpected = $descriptor.predecessor.triton
|
||||||
|
$durable = Assert-ContainerIdentity (
|
||||||
|
$predecessor.name
|
||||||
|
) $predecessor.container_id $predecessor.image_id $false
|
||||||
|
$triton = Assert-ContainerIdentity (
|
||||||
|
$tritonExpected.name
|
||||||
|
) $tritonExpected.container_id $tritonExpected.image_id $true
|
||||||
|
$output = Resolve-DDirectory $OutputRoot "M4.7 output root" $true
|
||||||
|
$freeBefore = Assert-FreeSpace "preflight"
|
||||||
|
|
||||||
|
if ($PreflightOnly) {
|
||||||
|
Write-Output ("PATCH_ID={0}" -f $descriptor.patch_id)
|
||||||
|
Write-Output ("ARTIFACT_SHA256={0}" -f $ExpectedArtifactSha256)
|
||||||
|
Write-Output ("DURABLE_WORKER_ID={0}" -f $durable.Id)
|
||||||
|
Write-Output ("TRITON_CONTAINER_ID={0}" -f $triton.Id)
|
||||||
|
Write-Output "PROVIDER_READINESS=accepted"
|
||||||
|
Write-Output "GRAPH_READINESS=not-run"
|
||||||
|
Write-Output "PREFLIGHT=accepted"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
$candidateName = "ndc-mission-core-m47-graph-shadow"
|
||||||
|
if (& docker ps -a --format "{{.Names}}" --filter "name=^/$candidateName$") {
|
||||||
|
throw "M4.7 candidate container already exists"
|
||||||
|
}
|
||||||
|
$scratch = Join-Path $output (".runtime-{0}" -f $descriptor.patch_id)
|
||||||
|
if (Test-Path -LiteralPath $scratch) {
|
||||||
|
throw "M4.7 runtime scratch already exists"
|
||||||
|
}
|
||||||
|
$null = New-Item -ItemType Directory -Path $scratch
|
||||||
|
$scratch = Resolve-DDirectory $scratch "M4.7 runtime scratch" $false
|
||||||
|
$runtimeIdentityPath = Join-Path $scratch "runtime-identity.json"
|
||||||
|
$dockerPayload = Convert-ToDockerPath $payload
|
||||||
|
$dockerOutput = Convert-ToDockerPath $output
|
||||||
|
$dockerScratch = Convert-ToDockerPath $scratch
|
||||||
|
$dockerArguments = @(
|
||||||
|
"create",
|
||||||
|
"--name", $candidateName,
|
||||||
|
"--network", ("container:{0}" -f $tritonExpected.name),
|
||||||
|
"--read-only",
|
||||||
|
"--security-opt", "no-new-privileges:true",
|
||||||
|
"--cap-drop", "ALL",
|
||||||
|
"--pids-limit", "256",
|
||||||
|
"--tmpfs", "/tmp:rw,noexec,nosuid,size=2g",
|
||||||
|
"-e", "PYTHONDONTWRITEBYTECODE=1",
|
||||||
|
"-e", ("PYTHONPATH=/release/{0}:/opt/media:/opt/opencv:/opt/pillow" -f $descriptor.release.wheel.name),
|
||||||
|
"-v", ("{0}:/release:ro" -f $dockerPayload),
|
||||||
|
"-v", ("{0}:/output:rw" -f $dockerOutput),
|
||||||
|
"-v", ("{0}:/run/mission-core:ro" -f $dockerScratch)
|
||||||
|
)
|
||||||
|
foreach ($entry in $descriptor.inputs.PSObject.Properties) {
|
||||||
|
if ($null -ne $entry.Value.container_path) {
|
||||||
|
$dockerArguments += @(
|
||||||
|
"-v", ("{0}:{1}:ro" -f (
|
||||||
|
Convert-ToDockerPath $entry.Value.host_path
|
||||||
|
), $entry.Value.container_path)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($dependency in $descriptor.dependencies) {
|
||||||
|
$dockerArguments += @(
|
||||||
|
"-v", ("{0}:{1}:ro" -f (
|
||||||
|
Convert-ToDockerPath $dependency.host_path
|
||||||
|
), $dependency.container_path)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
$dockerArguments += @(
|
||||||
|
"--entrypoint", "python3",
|
||||||
|
$imageRef,
|
||||||
|
"-m", "k1link.perception.reference_graph_cli",
|
||||||
|
"--graph-config", "/release/m4-reference-graph-v2.json",
|
||||||
|
"--baseline-profile", "/release/m4-recorded-realtime-baseline-v1.json",
|
||||||
|
"--geometry-profile", "/release/m4-geometry-association-v1.json",
|
||||||
|
"--temporal-motion-profile", "/release/m4-temporal-motion-v1.json",
|
||||||
|
"--rolling-map-profile", "/release/m4-rolling-local-map-v1.json",
|
||||||
|
"--threat-profile", "/release/m4-replay-threat-v3.json",
|
||||||
|
"--camera-index", $descriptor.inputs.camera_index.container_path,
|
||||||
|
"--source-pack", $descriptor.inputs.source_pack.container_path,
|
||||||
|
"--local-surface", $descriptor.inputs.local_surface.container_path,
|
||||||
|
"--video", $descriptor.inputs.video.container_path,
|
||||||
|
"--valid-fov-mask", $descriptor.inputs.valid_fov_mask.container_path,
|
||||||
|
"--temporal-parity-frames", $descriptor.inputs.accepted_temporal_frames.container_path,
|
||||||
|
"--threat-parity-frames", $descriptor.inputs.accepted_threat_frames.container_path,
|
||||||
|
"--triton-origin", $descriptor.container.triton_origin,
|
||||||
|
"--mode", $descriptor.acceptance.run_mode,
|
||||||
|
"--expected-frames", ([string]$descriptor.acceptance.expected_frames),
|
||||||
|
"--output-root", "/output"
|
||||||
|
)
|
||||||
|
|
||||||
|
$candidateCreated = $false
|
||||||
|
$runFailure = $null
|
||||||
|
try {
|
||||||
|
$candidateId = (& docker @dockerArguments).Trim()
|
||||||
|
Assert-LastExitCode "M4.7 candidate creation"
|
||||||
|
if ($candidateId -notmatch "^[a-f0-9]{64}$") {
|
||||||
|
throw "M4.7 candidate id is invalid"
|
||||||
|
}
|
||||||
|
$candidateCreated = $true
|
||||||
|
$candidate = Get-ContainerIdentity $candidateName
|
||||||
|
if (
|
||||||
|
$candidate.Id -cne $candidateId -or
|
||||||
|
$candidate.Image -cne $descriptor.container.image_id -or
|
||||||
|
$candidate.HostConfig.NetworkMode -cne ("container:{0}" -f $triton.Id) -or
|
||||||
|
-not $candidate.HostConfig.ReadonlyRootfs
|
||||||
|
) {
|
||||||
|
throw "M4.7 candidate isolation contract changed"
|
||||||
|
}
|
||||||
|
$runtimeIdentity = [ordered]@{
|
||||||
|
schema_version = "missioncore.reference-graph-runtime-identity/v1"
|
||||||
|
worker_id = "worker-006"
|
||||||
|
worker_node = $env:COMPUTERNAME
|
||||||
|
worker_container_id = $candidate.Id
|
||||||
|
worker_image_id = $candidate.Image
|
||||||
|
triton_container_id = $triton.Id
|
||||||
|
triton_image_id = $triton.Image
|
||||||
|
artifact_sha256 = $ExpectedArtifactSha256
|
||||||
|
code_revision = $descriptor.code_revision
|
||||||
|
graph_id = $descriptor.readiness.graph.graph_id
|
||||||
|
source_mount_read_only = $true
|
||||||
|
model_service_reused = $true
|
||||||
|
public_worker_port_added = $false
|
||||||
|
commands_enabled = $false
|
||||||
|
actuation_allowed = $false
|
||||||
|
}
|
||||||
|
Write-Utf8NoBom $runtimeIdentityPath ($runtimeIdentity | ConvertTo-Json -Depth 4)
|
||||||
|
Write-Output ("PATCH_ID={0}" -f $descriptor.patch_id)
|
||||||
|
Write-Output ("ARTIFACT_SHA256={0}" -f $ExpectedArtifactSha256)
|
||||||
|
Write-Output ("CANDIDATE_CONTAINER_ID={0}" -f $candidate.Id)
|
||||||
|
Write-Output "PROVIDER_READINESS=accepted"
|
||||||
|
& docker start --attach $candidateName
|
||||||
|
Assert-LastExitCode "M4.7 canonical graph shadow"
|
||||||
|
Write-Output "GRAPH_READINESS=accepted"
|
||||||
|
} catch {
|
||||||
|
$runFailure = $_
|
||||||
|
} finally {
|
||||||
|
if ($candidateCreated) {
|
||||||
|
& docker rm --force $candidateName *> $null
|
||||||
|
if ($LASTEXITCODE -ne 0 -and $null -eq $runFailure) {
|
||||||
|
$runFailure = "M4.7 candidate cleanup failed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Remove-Item -LiteralPath $scratch -Force -Recurse -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
|
||||||
|
$null = Assert-ContainerIdentity $predecessor.name $predecessor.container_id (
|
||||||
|
$predecessor.image_id
|
||||||
|
) $false
|
||||||
|
$null = Assert-ContainerIdentity $tritonExpected.name $tritonExpected.container_id (
|
||||||
|
$tritonExpected.image_id
|
||||||
|
) $true
|
||||||
|
$freeAfter = Assert-FreeSpace "completed"
|
||||||
|
Write-Output ("DISK_FREE_BYTES_BEFORE={0}" -f $freeBefore)
|
||||||
|
Write-Output ("DISK_FREE_BYTES_AFTER={0}" -f $freeAfter)
|
||||||
|
Write-Output "DURABLE_WORKER_ACTION=none"
|
||||||
|
Write-Output "TRITON_ACTION=none"
|
||||||
|
if ($null -ne $runFailure) {
|
||||||
|
throw $runFailure
|
||||||
|
}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Build the deterministic Worker 006 M4.7 canonical-graph shadow artifact."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import gzip
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import tarfile
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
BASE_TEMPLATE = REPOSITORY_ROOT / "config/deployment/mission-core-worker-shadow-v1.template.json"
|
||||||
|
RUNNER = REPOSITORY_ROOT / "scripts/Invoke-M47CanonicalGraphShadow.ps1"
|
||||||
|
WHEEL_NAME = "nodedc_mission_core-0.1.0-py3-none-any.whl"
|
||||||
|
DESCRIPTOR_NAME = "mission-core-worker-m47-graph-shadow-v2.json"
|
||||||
|
PATCH_ID = re.compile(r"^[A-Za-z0-9._-]{1,96}$")
|
||||||
|
EXPECTED_BASE_TEMPLATE_SHA256 = (
|
||||||
|
"319e7ac7f14e5911ad44234c9ec918c73e11a3406724d5cee3e2ef64bb036e0c"
|
||||||
|
)
|
||||||
|
CONFIG_PATHS = (
|
||||||
|
Path("config/perception/m4-recorded-realtime-baseline-v1.json"),
|
||||||
|
Path("config/perception/m4-reference-graph-v2.json"),
|
||||||
|
Path("config/perception/m4-geometry-association-v1.json"),
|
||||||
|
Path("config/perception/m4-temporal-motion-v1.json"),
|
||||||
|
Path("config/perception/m4-rolling-local-map-v1.json"),
|
||||||
|
Path("config/perception/m4-replay-threat-v3.json"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ArtifactBuildError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def sha256_file(path: Path) -> str:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with path.open("rb") as handle:
|
||||||
|
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def git_revision(*, require_clean: bool) -> str:
|
||||||
|
revision_result = subprocess.run(
|
||||||
|
["git", "rev-parse", "HEAD"],
|
||||||
|
cwd=REPOSITORY_ROOT,
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
revision = 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")
|
||||||
|
if require_clean:
|
||||||
|
status = subprocess.run(
|
||||||
|
["git", "status", "--porcelain"],
|
||||||
|
cwd=REPOSITORY_ROOT,
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if status.stdout.strip():
|
||||||
|
raise ArtifactBuildError("production artifact requires a clean worktree")
|
||||||
|
return revision
|
||||||
|
|
||||||
|
|
||||||
|
def build_wheel(output: Path) -> Path:
|
||||||
|
environment = os.environ.copy()
|
||||||
|
environment["SOURCE_DATE_EPOCH"] = "0"
|
||||||
|
result = subprocess.run(
|
||||||
|
["uv", "build", "--wheel", "--out-dir", str(output)],
|
||||||
|
cwd=REPOSITORY_ROOT,
|
||||||
|
env=environment,
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
detail = (result.stderr or result.stdout).strip()
|
||||||
|
raise ArtifactBuildError(f"wheel build failed: {detail}")
|
||||||
|
wheel = output / WHEEL_NAME
|
||||||
|
if not wheel.is_file() or wheel.is_symlink():
|
||||||
|
raise ArtifactBuildError("expected wheel was not built")
|
||||||
|
return wheel
|
||||||
|
|
||||||
|
|
||||||
|
def render_descriptor(
|
||||||
|
patch_id: str,
|
||||||
|
revision: str,
|
||||||
|
*,
|
||||||
|
wheel_sha256: str,
|
||||||
|
) -> bytes:
|
||||||
|
if sha256_file(BASE_TEMPLATE) != EXPECTED_BASE_TEMPLATE_SHA256:
|
||||||
|
raise ArtifactBuildError("historical M4 descriptor base changed")
|
||||||
|
descriptor = json.loads(BASE_TEMPLATE.read_text("utf-8"))
|
||||||
|
descriptor.update(
|
||||||
|
{
|
||||||
|
"schema_version": "nodedc.mission-core-worker.shadow-release/v2",
|
||||||
|
"patch_id": patch_id,
|
||||||
|
"code_revision": revision,
|
||||||
|
"transition": "m47-canonical-graph-shadow-v1",
|
||||||
|
"artifact_type": "shadow-release",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
descriptor["container"].update(
|
||||||
|
{
|
||||||
|
"name": "ndc-mission-core-m47-graph-shadow",
|
||||||
|
"python_path": (
|
||||||
|
f"/release/{WHEEL_NAME}:/opt/media:/opt/opencv:/opt/pillow"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
descriptor["inputs"]["local_surface"] = {
|
||||||
|
"container_path": "/source/local-surface/local-surface.npz",
|
||||||
|
"host_path": (
|
||||||
|
"D:\\NDC_MISSIONCORE\\runtime\\derived\\"
|
||||||
|
"k1-local-surface-23762244c8bdb97de26fb721ac957d7a00bc9a63571ac4cfa4be19c4effc7d55"
|
||||||
|
"\\local-surface.npz"
|
||||||
|
),
|
||||||
|
"sha256": "f57eb2485b6cef47f2a97a2d9ff1aa9fd9265fe1eb69cd5852d12f39e13b8bc6",
|
||||||
|
}
|
||||||
|
descriptor["inputs"]["accepted_temporal_frames"] = {
|
||||||
|
"container_path": "/parity/temporal/frames.jsonl",
|
||||||
|
"host_path": (
|
||||||
|
"D:\\NDC_MISSIONCORE\\runtime\\derived\\"
|
||||||
|
"m4-temporal-replay-81e13d5654ac8d1219f6937dd425f7dbcdbacd5748d23fe653297134f0de6c22"
|
||||||
|
"\\frames.jsonl"
|
||||||
|
),
|
||||||
|
"sha256": "e83b80ea06b3462c2d04c5a1b74289a0ec401596c7150ae90f5a1b748b639c3a",
|
||||||
|
}
|
||||||
|
descriptor["inputs"]["accepted_threat_frames"] = {
|
||||||
|
"container_path": "/parity/threat/frames.jsonl",
|
||||||
|
"host_path": (
|
||||||
|
"D:\\NDC_MISSIONCORE\\runtime\\derived\\"
|
||||||
|
"m4-threat-replay-2a953c5f27f2a5b1dddc5c658c1de2c323d7796084a099c024987a1da03aa324"
|
||||||
|
"\\frames.jsonl"
|
||||||
|
),
|
||||||
|
"sha256": "b57be1839f5915e3b80b54355b694e0bd8c9ac318d0cbe6de2bff713082cfa4e",
|
||||||
|
}
|
||||||
|
descriptor["release"] = {
|
||||||
|
"wheel": {"name": WHEEL_NAME, "sha256": wheel_sha256},
|
||||||
|
"runner": {"name": RUNNER.name, "sha256": sha256_file(RUNNER)},
|
||||||
|
"configs": {
|
||||||
|
path.name: {
|
||||||
|
"name": path.name,
|
||||||
|
"sha256": sha256_file(REPOSITORY_ROOT / path),
|
||||||
|
}
|
||||||
|
for path in CONFIG_PATHS
|
||||||
|
},
|
||||||
|
}
|
||||||
|
descriptor["acceptance"] = {
|
||||||
|
"run_mode": "lossless-replay",
|
||||||
|
"expected_frames": 4489,
|
||||||
|
"delivered_frames": 4489,
|
||||||
|
"failed_frames": 0,
|
||||||
|
"stale_frames": 0,
|
||||||
|
"superseded_frames": 0,
|
||||||
|
"accepted_parity": True,
|
||||||
|
"class_routing_used": False,
|
||||||
|
}
|
||||||
|
descriptor["readiness"] = {
|
||||||
|
"provider": [
|
||||||
|
"triton-yolox-s-raw-kb4/v1",
|
||||||
|
"ravnoves00-geometry-association/v1",
|
||||||
|
"bounded-spatial-temporal-layer/v1",
|
||||||
|
"class-independent-motion-estimator/v1",
|
||||||
|
"rolling-local-obstacle-map/v1",
|
||||||
|
"dual-evidence-replay-threat/v3",
|
||||||
|
],
|
||||||
|
"graph": {
|
||||||
|
"graph_id": "reference-perception-graph/v2",
|
||||||
|
"terminal_accounting_required": True,
|
||||||
|
"actuation_allowed": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
descriptor["predecessor"]["durable_worker"]["name"]
|
||||||
|
!= "ndc-mission-core-perception-worker"
|
||||||
|
or descriptor["rollback"]["durable_worker_action"] != "none"
|
||||||
|
or descriptor["boundary"]["external_deploy_registry"] is not False
|
||||||
|
):
|
||||||
|
raise ArtifactBuildError("M4.7 predecessor or deployment boundary changed")
|
||||||
|
return (
|
||||||
|
json.dumps(descriptor, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
|
||||||
|
).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _tar_info(path: Path, arcname: str) -> tarfile.TarInfo:
|
||||||
|
info = tarfile.TarInfo(arcname)
|
||||||
|
info.uid = 0
|
||||||
|
info.gid = 0
|
||||||
|
info.uname = "root"
|
||||||
|
info.gname = "root"
|
||||||
|
info.mtime = 0
|
||||||
|
if path.is_dir():
|
||||||
|
info.type = tarfile.DIRTYPE
|
||||||
|
info.mode = 0o755
|
||||||
|
else:
|
||||||
|
info.type = tarfile.REGTYPE
|
||||||
|
info.mode = 0o644
|
||||||
|
info.size = path.stat().st_size
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
def write_canonical_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, compresslevel=9, mtime=0) as gz,
|
||||||
|
tarfile.open(fileobj=gz, 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 source:
|
||||||
|
archive.addfile(info, source)
|
||||||
|
else:
|
||||||
|
archive.addfile(info, io.BytesIO())
|
||||||
|
|
||||||
|
|
||||||
|
def build_artifact(
|
||||||
|
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")
|
||||||
|
selected_revision = revision or git_revision(require_clean=True)
|
||||||
|
if re.fullmatch(r"[a-f0-9]{40}", selected_revision) is None:
|
||||||
|
raise ArtifactBuildError("artifact revision is invalid")
|
||||||
|
with tempfile.TemporaryDirectory(prefix="mission-core-m47-graph-shadow-") as directory:
|
||||||
|
stage = Path(directory)
|
||||||
|
payload = stage / "payload"
|
||||||
|
payload.mkdir()
|
||||||
|
wheel = build_wheel(stage / "wheel")
|
||||||
|
wheel_sha256 = sha256_file(wheel)
|
||||||
|
payload_files = [RUNNER.name, WHEEL_NAME, DESCRIPTOR_NAME]
|
||||||
|
(payload / RUNNER.name).write_bytes(RUNNER.read_bytes())
|
||||||
|
(payload / WHEEL_NAME).write_bytes(wheel.read_bytes())
|
||||||
|
for relative in CONFIG_PATHS:
|
||||||
|
source = REPOSITORY_ROOT / relative
|
||||||
|
(payload / source.name).write_bytes(source.read_bytes())
|
||||||
|
payload_files.append(source.name)
|
||||||
|
(payload / DESCRIPTOR_NAME).write_bytes(
|
||||||
|
render_descriptor(
|
||||||
|
patch_id,
|
||||||
|
selected_revision,
|
||||||
|
wheel_sha256=wheel_sha256,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
payload_files = sorted(payload_files)
|
||||||
|
(stage / "manifest.env").write_text(
|
||||||
|
f"id={patch_id}\ncomponent=mission-core-worker\ntype=shadow-release\n",
|
||||||
|
"utf-8",
|
||||||
|
)
|
||||||
|
(stage / "files.txt").write_text("\n".join(payload_files) + "\n", "utf-8")
|
||||||
|
target = (
|
||||||
|
output_directory.resolve()
|
||||||
|
/ f"nodedc-mission-core-worker-{patch_id}.tgz"
|
||||||
|
)
|
||||||
|
write_canonical_archive(stage, target)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"patch_id": patch_id,
|
||||||
|
"component": "mission-core-worker",
|
||||||
|
"type": "shadow-release",
|
||||||
|
"artifact": str(target),
|
||||||
|
"sha256": sha256_file(target),
|
||||||
|
"code_revision": selected_revision,
|
||||||
|
"wheel_sha256": wheel_sha256,
|
||||||
|
"payload_files": payload_files,
|
||||||
|
"transition": "m47-canonical-graph-shadow-v1",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("patch_id")
|
||||||
|
parser.add_argument(
|
||||||
|
"--output-directory",
|
||||||
|
type=Path,
|
||||||
|
default=REPOSITORY_ROOT / ".runtime/worker-artifacts",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
try:
|
||||||
|
result = build_artifact(args.patch_id, args.output_directory)
|
||||||
|
except (ArtifactBuildError, OSError, subprocess.SubprocessError, json.JSONDecodeError) as exc:
|
||||||
|
parser.error(str(exc))
|
||||||
|
print(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
+266
-15
@@ -29,19 +29,25 @@ from .contracts import (
|
|||||||
)
|
)
|
||||||
from .graph_contracts import (
|
from .graph_contracts import (
|
||||||
GRAPH_RESULT_SCHEMA,
|
GRAPH_RESULT_SCHEMA,
|
||||||
|
GRAPH_RESULT_SCHEMA_V2,
|
||||||
REFERENCE_GRAPH_ID,
|
REFERENCE_GRAPH_ID,
|
||||||
|
REFERENCE_GRAPH_ID_V2,
|
||||||
TERMINAL_OUTCOME_SCHEMA,
|
TERMINAL_OUTCOME_SCHEMA,
|
||||||
DeliveredFrame,
|
DeliveredFrame,
|
||||||
GraphExecutionError,
|
GraphExecutionError,
|
||||||
|
GraphRunMode,
|
||||||
GraphRunResult,
|
GraphRunResult,
|
||||||
|
GraphRunResultV2,
|
||||||
GraphState,
|
GraphState,
|
||||||
TerminalOutcome,
|
TerminalOutcome,
|
||||||
TerminalOutcomeType,
|
TerminalOutcomeType,
|
||||||
build_graph_run_result,
|
build_graph_run_result,
|
||||||
|
build_graph_run_result_v2,
|
||||||
)
|
)
|
||||||
from .graph_validation import (
|
from .graph_validation import (
|
||||||
validate_observations,
|
validate_observations,
|
||||||
validate_proposals,
|
validate_proposals,
|
||||||
|
validate_rolling,
|
||||||
validate_temporal,
|
validate_temporal,
|
||||||
validate_threats,
|
validate_threats,
|
||||||
)
|
)
|
||||||
@@ -51,6 +57,8 @@ from .providers import (
|
|||||||
MotionProvider,
|
MotionProvider,
|
||||||
ProviderRole,
|
ProviderRole,
|
||||||
ReferencePerceptionGraphConfig,
|
ReferencePerceptionGraphConfig,
|
||||||
|
ReferencePerceptionGraphConfigV2,
|
||||||
|
RollingMapProvider,
|
||||||
SourcePacket,
|
SourcePacket,
|
||||||
SourceProvider,
|
SourceProvider,
|
||||||
TemporalStateProvider,
|
TemporalStateProvider,
|
||||||
@@ -79,6 +87,15 @@ class _Temporal:
|
|||||||
obstacles: tuple[TemporalObstacle, ...]
|
obstacles: tuple[TemporalObstacle, ...]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _Rolled:
|
||||||
|
packet: SourcePacket
|
||||||
|
proposals: tuple[ObjectProposal2D, ...]
|
||||||
|
associated_proposal_ids: frozenset[str]
|
||||||
|
obstacles: tuple[TemporalObstacle, ...]
|
||||||
|
retained: tuple[TemporalObstacle, ...]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class _StopSignal:
|
class _StopSignal:
|
||||||
pass
|
pass
|
||||||
@@ -88,8 +105,9 @@ _STOP = _StopSignal()
|
|||||||
_DetectorItem = SourcePacket | _StopSignal
|
_DetectorItem = SourcePacket | _StopSignal
|
||||||
_GeometryItem = _Detected | _StopSignal
|
_GeometryItem = _Detected | _StopSignal
|
||||||
_TemporalItem = _Associated | _StopSignal
|
_TemporalItem = _Associated | _StopSignal
|
||||||
_ThreatItem = _Temporal | _StopSignal
|
_RollingItem = _Temporal | _StopSignal
|
||||||
_QueueItem = SourcePacket | _Detected | _Associated | _Temporal | _StopSignal
|
_ThreatItem = _Temporal | _Rolled | _StopSignal
|
||||||
|
_QueueItem = SourcePacket | _Detected | _Associated | _Temporal | _Rolled | _StopSignal
|
||||||
_QueueItemT = TypeVar("_QueueItemT", bound=_QueueItem)
|
_QueueItemT = TypeVar("_QueueItemT", bound=_QueueItem)
|
||||||
|
|
||||||
|
|
||||||
@@ -114,6 +132,37 @@ class ReferencePerceptionGraphV1:
|
|||||||
raise GraphExecutionError("reference graph id is incompatible")
|
raise GraphExecutionError("reference graph id is incompatible")
|
||||||
if config.source_profile_id != BASELINE_PROFILE_ID:
|
if config.source_profile_id != BASELINE_PROFILE_ID:
|
||||||
raise GraphExecutionError("reference source profile is incompatible")
|
raise GraphExecutionError("reference source profile is incompatible")
|
||||||
|
self._initialize(
|
||||||
|
config=config,
|
||||||
|
source=source,
|
||||||
|
detector=detector,
|
||||||
|
geometry=geometry,
|
||||||
|
temporal=temporal,
|
||||||
|
motion=motion,
|
||||||
|
rolling=None,
|
||||||
|
threat=threat,
|
||||||
|
run_mode=GraphRunMode.SOURCE_PACED_LATEST_WINS,
|
||||||
|
telemetry_identity=telemetry_identity,
|
||||||
|
telemetry_sink=telemetry_sink,
|
||||||
|
clock_ns=clock_ns,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _initialize(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
config: ReferencePerceptionGraphConfig | ReferencePerceptionGraphConfigV2,
|
||||||
|
source: SourceProvider,
|
||||||
|
detector: DetectorProvider,
|
||||||
|
geometry: GeometryAssociationProvider,
|
||||||
|
temporal: TemporalStateProvider,
|
||||||
|
motion: MotionProvider,
|
||||||
|
rolling: RollingMapProvider | None,
|
||||||
|
threat: ThreatProvider,
|
||||||
|
run_mode: GraphRunMode,
|
||||||
|
telemetry_identity: PipelineTelemetryIdentity | None,
|
||||||
|
telemetry_sink: PipelineTelemetrySink | None,
|
||||||
|
clock_ns: Callable[[], int],
|
||||||
|
) -> None:
|
||||||
if (telemetry_identity is None) is not (telemetry_sink is None):
|
if (telemetry_identity is None) is not (telemetry_sink is None):
|
||||||
raise GraphExecutionError("telemetry identity and sink must be configured together")
|
raise GraphExecutionError("telemetry identity and sink must be configured together")
|
||||||
self.config = config
|
self.config = config
|
||||||
@@ -122,7 +171,9 @@ class ReferencePerceptionGraphV1:
|
|||||||
self.geometry = geometry
|
self.geometry = geometry
|
||||||
self.temporal = temporal
|
self.temporal = temporal
|
||||||
self.motion = motion
|
self.motion = motion
|
||||||
|
self.rolling = rolling
|
||||||
self.threat = threat
|
self.threat = threat
|
||||||
|
self.run_mode = run_mode
|
||||||
self.telemetry_identity = telemetry_identity
|
self.telemetry_identity = telemetry_identity
|
||||||
self.telemetry_sink = telemetry_sink
|
self.telemetry_sink = telemetry_sink
|
||||||
self._clock_ns = clock_ns
|
self._clock_ns = clock_ns
|
||||||
@@ -135,6 +186,9 @@ class ReferencePerceptionGraphV1:
|
|||||||
self._deliveries: list[DeliveredFrame] = []
|
self._deliveries: list[DeliveredFrame] = []
|
||||||
self._admitted: dict[int, SourcePacket] = {}
|
self._admitted: dict[int, SourcePacket] = {}
|
||||||
self._admitted_at_ns: dict[int, int] = {}
|
self._admitted_at_ns: dict[int, int] = {}
|
||||||
|
self._queue_high_watermarks: dict[str, int] = {
|
||||||
|
stage_id: 0 for stage_id in ("detector", "geometry", "temporal", "rolling", "threat")
|
||||||
|
}
|
||||||
self._validate_provider_pins()
|
self._validate_provider_pins()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -149,14 +203,17 @@ class ReferencePerceptionGraphV1:
|
|||||||
def cancel(self) -> None:
|
def cancel(self) -> None:
|
||||||
self._cancel_event.set()
|
self._cancel_event.set()
|
||||||
|
|
||||||
def run(self) -> GraphRunResult:
|
def run(self) -> GraphRunResult | GraphRunResultV2:
|
||||||
self._begin_run()
|
self._begin_run()
|
||||||
started_ns = self._now()
|
started_ns = self._now()
|
||||||
detector_queue: Queue[_DetectorItem] = Queue(self._capacity("detector"))
|
detector_queue: Queue[_DetectorItem] = Queue(self._capacity("detector"))
|
||||||
geometry_queue: Queue[_GeometryItem] = Queue(self._capacity("geometry"))
|
geometry_queue: Queue[_GeometryItem] = Queue(self._capacity("geometry"))
|
||||||
temporal_queue: Queue[_TemporalItem] = Queue(self._capacity("temporal"))
|
temporal_queue: Queue[_TemporalItem] = Queue(self._capacity("temporal"))
|
||||||
threat_queue: Queue[_ThreatItem] = Queue(self._capacity("threat"))
|
threat_queue: Queue[_ThreatItem] = Queue(self._capacity("threat"))
|
||||||
self._threads = [
|
rolling_queue: Queue[_RollingItem] | None = (
|
||||||
|
Queue(self._capacity("rolling")) if self.rolling is not None else None
|
||||||
|
)
|
||||||
|
threads = [
|
||||||
Thread(
|
Thread(
|
||||||
target=self._detector_loop,
|
target=self._detector_loop,
|
||||||
args=(detector_queue, geometry_queue),
|
args=(detector_queue, geometry_queue),
|
||||||
@@ -171,17 +228,29 @@ class ReferencePerceptionGraphV1:
|
|||||||
),
|
),
|
||||||
Thread(
|
Thread(
|
||||||
target=self._temporal_loop,
|
target=self._temporal_loop,
|
||||||
args=(temporal_queue, threat_queue),
|
args=(temporal_queue, rolling_queue or threat_queue),
|
||||||
name="m4-temporal",
|
name="m4-temporal",
|
||||||
daemon=True,
|
daemon=True,
|
||||||
),
|
),
|
||||||
|
]
|
||||||
|
if rolling_queue is not None:
|
||||||
|
threads.append(
|
||||||
|
Thread(
|
||||||
|
target=self._rolling_loop,
|
||||||
|
args=(rolling_queue, threat_queue),
|
||||||
|
name="m4-rolling",
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
threads.append(
|
||||||
Thread(
|
Thread(
|
||||||
target=self._threat_loop,
|
target=self._threat_loop,
|
||||||
args=(threat_queue,),
|
args=(threat_queue,),
|
||||||
name="m4-threat",
|
name="m4-threat",
|
||||||
daemon=True,
|
daemon=True,
|
||||||
),
|
)
|
||||||
]
|
)
|
||||||
|
self._threads = threads
|
||||||
for thread in self._threads:
|
for thread in self._threads:
|
||||||
thread.start()
|
thread.start()
|
||||||
run_emitter = self._run_emitter()
|
run_emitter = self._run_emitter()
|
||||||
@@ -244,6 +313,8 @@ class ReferencePerceptionGraphV1:
|
|||||||
self._deliveries.clear()
|
self._deliveries.clear()
|
||||||
self._admitted.clear()
|
self._admitted.clear()
|
||||||
self._admitted_at_ns.clear()
|
self._admitted_at_ns.clear()
|
||||||
|
for stage_id in self._queue_high_watermarks:
|
||||||
|
self._queue_high_watermarks[stage_id] = 0
|
||||||
|
|
||||||
def _admit(self, packet: SourcePacket) -> bool:
|
def _admit(self, packet: SourcePacket) -> bool:
|
||||||
envelope = packet.envelope
|
envelope = packet.envelope
|
||||||
@@ -283,6 +354,8 @@ class ReferencePerceptionGraphV1:
|
|||||||
validate_proposals(item, proposals)
|
validate_proposals(item, proposals)
|
||||||
if stage is not None:
|
if stage is not None:
|
||||||
stage.output_count = len(proposals)
|
stage.output_count = len(proposals)
|
||||||
|
if self._expired(item, "detector"):
|
||||||
|
continue
|
||||||
self._put_latest(outgoing, _Detected(item, proposals), "geometry")
|
self._put_latest(outgoing, _Detected(item, proposals), "geometry")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
if not isinstance(item, _StopSignal):
|
if not isinstance(item, _StopSignal):
|
||||||
@@ -311,6 +384,8 @@ class ReferencePerceptionGraphV1:
|
|||||||
validate_exclusive_point_ownership(observations)
|
validate_exclusive_point_ownership(observations)
|
||||||
if stage is not None:
|
if stage is not None:
|
||||||
stage.output_count = len(observations)
|
stage.output_count = len(observations)
|
||||||
|
if self._expired(item.packet, "geometry"):
|
||||||
|
continue
|
||||||
self._put_latest(
|
self._put_latest(
|
||||||
outgoing,
|
outgoing,
|
||||||
_Associated(item.packet, item.proposals, observations),
|
_Associated(item.packet, item.proposals, observations),
|
||||||
@@ -325,13 +400,16 @@ class ReferencePerceptionGraphV1:
|
|||||||
def _temporal_loop(
|
def _temporal_loop(
|
||||||
self,
|
self,
|
||||||
incoming: Queue[_TemporalItem],
|
incoming: Queue[_TemporalItem],
|
||||||
outgoing: Queue[_ThreatItem],
|
outgoing: Queue[_RollingItem] | Queue[_ThreatItem],
|
||||||
) -> None:
|
) -> None:
|
||||||
while True:
|
while True:
|
||||||
item = incoming.get()
|
item = incoming.get()
|
||||||
try:
|
try:
|
||||||
if isinstance(item, _StopSignal):
|
if isinstance(item, _StopSignal):
|
||||||
self._put_stop(outgoing, "threat")
|
self._put_stop(
|
||||||
|
cast(Queue[_QueueItem], outgoing),
|
||||||
|
"rolling" if self.rolling is not None else "threat",
|
||||||
|
)
|
||||||
return
|
return
|
||||||
if self._cancelled(item.packet):
|
if self._cancelled(item.packet):
|
||||||
continue
|
continue
|
||||||
@@ -343,8 +421,10 @@ class ReferencePerceptionGraphV1:
|
|||||||
validate_temporal(item.packet, obstacles)
|
validate_temporal(item.packet, obstacles)
|
||||||
if stage is not None:
|
if stage is not None:
|
||||||
stage.output_count = len(obstacles)
|
stage.output_count = len(obstacles)
|
||||||
|
if self._expired(item.packet, "temporal"):
|
||||||
|
continue
|
||||||
self._put_latest(
|
self._put_latest(
|
||||||
outgoing,
|
cast(Queue[_QueueItem], outgoing),
|
||||||
_Temporal(
|
_Temporal(
|
||||||
item.packet,
|
item.packet,
|
||||||
item.proposals,
|
item.proposals,
|
||||||
@@ -356,7 +436,7 @@ class ReferencePerceptionGraphV1:
|
|||||||
),
|
),
|
||||||
obstacles,
|
obstacles,
|
||||||
),
|
),
|
||||||
"threat",
|
"rolling" if self.rolling is not None else "threat",
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
if not isinstance(item, _StopSignal):
|
if not isinstance(item, _StopSignal):
|
||||||
@@ -364,6 +444,48 @@ class ReferencePerceptionGraphV1:
|
|||||||
finally:
|
finally:
|
||||||
incoming.task_done()
|
incoming.task_done()
|
||||||
|
|
||||||
|
def _rolling_loop(
|
||||||
|
self,
|
||||||
|
incoming: Queue[_RollingItem],
|
||||||
|
outgoing: Queue[_ThreatItem],
|
||||||
|
) -> None:
|
||||||
|
rolling = self.rolling
|
||||||
|
if rolling is None:
|
||||||
|
raise GraphExecutionError("rolling loop requires a rolling provider")
|
||||||
|
while True:
|
||||||
|
item = incoming.get()
|
||||||
|
try:
|
||||||
|
if isinstance(item, _StopSignal):
|
||||||
|
self._put_stop(outgoing, "threat")
|
||||||
|
return
|
||||||
|
if self._cancelled(item.packet):
|
||||||
|
continue
|
||||||
|
if self._expired(item.packet, "rolling"):
|
||||||
|
continue
|
||||||
|
with self._stage(item.packet, "rolling", len(item.obstacles)) as stage:
|
||||||
|
retained = rolling.update(item.packet, item.obstacles)
|
||||||
|
validate_rolling(item.obstacles, retained)
|
||||||
|
if stage is not None:
|
||||||
|
stage.output_count = len(retained)
|
||||||
|
if self._expired(item.packet, "rolling"):
|
||||||
|
continue
|
||||||
|
self._put_latest(
|
||||||
|
outgoing,
|
||||||
|
_Rolled(
|
||||||
|
item.packet,
|
||||||
|
item.proposals,
|
||||||
|
item.associated_proposal_ids,
|
||||||
|
item.obstacles,
|
||||||
|
retained,
|
||||||
|
),
|
||||||
|
"threat",
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
if not isinstance(item, _StopSignal):
|
||||||
|
self._failed(item.packet, "rolling", exc)
|
||||||
|
finally:
|
||||||
|
incoming.task_done()
|
||||||
|
|
||||||
def _threat_loop(self, incoming: Queue[_ThreatItem]) -> None:
|
def _threat_loop(self, incoming: Queue[_ThreatItem]) -> None:
|
||||||
while True:
|
while True:
|
||||||
item = incoming.get()
|
item = incoming.get()
|
||||||
@@ -380,6 +502,8 @@ class ReferencePerceptionGraphV1:
|
|||||||
validate_threats(obstacle_map, threats)
|
validate_threats(obstacle_map, threats)
|
||||||
if stage is not None:
|
if stage is not None:
|
||||||
stage.output_count = len(threats)
|
stage.output_count = len(threats)
|
||||||
|
if self._expired(item.packet, "threat"):
|
||||||
|
continue
|
||||||
delivery = DeliveredFrame(
|
delivery = DeliveredFrame(
|
||||||
sequence=item.packet.envelope.sequence,
|
sequence=item.packet.envelope.sequence,
|
||||||
obstacle_map=obstacle_map,
|
obstacle_map=obstacle_map,
|
||||||
@@ -399,14 +523,15 @@ class ReferencePerceptionGraphV1:
|
|||||||
finally:
|
finally:
|
||||||
incoming.task_done()
|
incoming.task_done()
|
||||||
|
|
||||||
def _obstacle_map(self, item: _Temporal) -> LocalObstacleMap:
|
def _obstacle_map(self, item: _Temporal | _Rolled) -> LocalObstacleMap:
|
||||||
packet = item.packet
|
packet = item.packet
|
||||||
now_ns = self._now()
|
now_ns = self._now()
|
||||||
with self._result_lock:
|
with self._result_lock:
|
||||||
admitted_at_ns = self._admitted_at_ns[packet.envelope.sequence]
|
admitted_at_ns = self._admitted_at_ns[packet.envelope.sequence]
|
||||||
occupied = tuple(
|
current = tuple(
|
||||||
obstacle for obstacle in item.obstacles if obstacle.state is TemporalState.CURRENT
|
obstacle for obstacle in item.obstacles if obstacle.state is TemporalState.CURRENT
|
||||||
)
|
)
|
||||||
|
occupied = (*current, *item.retained) if isinstance(item, _Rolled) else current
|
||||||
unknown = tuple(
|
unknown = tuple(
|
||||||
obstacle for obstacle in item.obstacles if obstacle.state is not TemporalState.CURRENT
|
obstacle for obstacle in item.obstacles if obstacle.state is not TemporalState.CURRENT
|
||||||
)
|
)
|
||||||
@@ -484,9 +609,13 @@ class ReferencePerceptionGraphV1:
|
|||||||
item: _QueueItemT,
|
item: _QueueItemT,
|
||||||
stage_id: str,
|
stage_id: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
if self.run_mode is GraphRunMode.LOSSLESS_REPLAY:
|
||||||
|
self._put_lossless(queue, item, stage_id)
|
||||||
|
return
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
queue.put_nowait(item)
|
queue.put_nowait(item)
|
||||||
|
self._record_queue_depth(stage_id, queue.qsize())
|
||||||
return
|
return
|
||||||
except Full:
|
except Full:
|
||||||
pass
|
pass
|
||||||
@@ -506,11 +635,60 @@ class ReferencePerceptionGraphV1:
|
|||||||
finally:
|
finally:
|
||||||
queue.task_done()
|
queue.task_done()
|
||||||
|
|
||||||
|
def _put_lossless(
|
||||||
|
self,
|
||||||
|
queue: Queue[_QueueItemT],
|
||||||
|
item: _QueueItemT,
|
||||||
|
stage_id: str,
|
||||||
|
) -> None:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
queue.put(item, timeout=0.05)
|
||||||
|
self._record_queue_depth(stage_id, queue.qsize())
|
||||||
|
return
|
||||||
|
except Full:
|
||||||
|
if self._cancel_event.is_set() and not isinstance(item, _StopSignal):
|
||||||
|
packet = _packet_from_item(item)
|
||||||
|
self._terminal(
|
||||||
|
packet,
|
||||||
|
TerminalOutcomeType.FAILED,
|
||||||
|
stage_id,
|
||||||
|
"graph-cancelled",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
def _record_queue_depth(self, stage_id: str, depth: int) -> None:
|
||||||
|
with self._result_lock:
|
||||||
|
self._queue_high_watermarks[stage_id] = max(
|
||||||
|
self._queue_high_watermarks.get(stage_id, 0),
|
||||||
|
depth,
|
||||||
|
)
|
||||||
|
|
||||||
def _put_stop(self, queue: Queue[_QueueItemT], stage_id: str) -> None:
|
def _put_stop(self, queue: Queue[_QueueItemT], stage_id: str) -> None:
|
||||||
timeout = self._terminal_timeout(stage_id) / 1_000_000_000
|
timeout = self._terminal_timeout(stage_id) / 1_000_000_000
|
||||||
try:
|
try:
|
||||||
queue.put(cast(_QueueItemT, _STOP), timeout=timeout)
|
queue.put(cast(_QueueItemT, _STOP), timeout=timeout)
|
||||||
|
self._record_queue_depth(stage_id, queue.qsize())
|
||||||
except Full:
|
except Full:
|
||||||
|
if self.run_mode is GraphRunMode.LOSSLESS_REPLAY:
|
||||||
|
self._cancel_event.set()
|
||||||
|
try:
|
||||||
|
stranded = queue.get_nowait()
|
||||||
|
except Empty as exc:
|
||||||
|
raise GraphExecutionError("lossless terminal queue timed out") from exc
|
||||||
|
try:
|
||||||
|
if not isinstance(stranded, _StopSignal):
|
||||||
|
self._terminal(
|
||||||
|
_packet_from_item(stranded),
|
||||||
|
TerminalOutcomeType.FAILED,
|
||||||
|
stage_id,
|
||||||
|
"terminal-queue-timeout",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
queue.task_done()
|
||||||
|
queue.put_nowait(cast(_QueueItemT, _STOP))
|
||||||
|
self._record_queue_depth(stage_id, queue.qsize())
|
||||||
|
return
|
||||||
self._put_latest(queue, cast(_QueueItemT, _STOP), stage_id)
|
self._put_latest(queue, cast(_QueueItemT, _STOP), stage_id)
|
||||||
|
|
||||||
def _join_workers(self) -> None:
|
def _join_workers(self) -> None:
|
||||||
@@ -541,11 +719,27 @@ class ReferencePerceptionGraphV1:
|
|||||||
if set(self._admitted) != set(self._outcomes):
|
if set(self._admitted) != set(self._outcomes):
|
||||||
raise GraphExecutionError("terminal accounting did not close")
|
raise GraphExecutionError("terminal accounting did not close")
|
||||||
|
|
||||||
def _result(self, state: GraphState) -> GraphRunResult:
|
def _result(self, state: GraphState) -> GraphRunResult | GraphRunResultV2:
|
||||||
with self._result_lock:
|
with self._result_lock:
|
||||||
outcomes = tuple(self._outcomes[key] for key in sorted(self._outcomes))
|
outcomes = tuple(self._outcomes[key] for key in sorted(self._outcomes))
|
||||||
deliveries = tuple(sorted(self._deliveries, key=lambda item: item.sequence))
|
deliveries = tuple(sorted(self._deliveries, key=lambda item: item.sequence))
|
||||||
admitted_count = len(self._admitted)
|
admitted_count = len(self._admitted)
|
||||||
|
queue_high_watermarks = tuple(
|
||||||
|
(stage_id, self._queue_high_watermarks[stage_id])
|
||||||
|
for stage_id in sorted(self._queue_high_watermarks)
|
||||||
|
if any(policy.stage_id == stage_id for policy in self.config.queues)
|
||||||
|
)
|
||||||
|
if self.rolling is not None:
|
||||||
|
return build_graph_run_result_v2(
|
||||||
|
graph_id=self.config.graph_id,
|
||||||
|
source_profile_id=self.config.source_profile_id,
|
||||||
|
run_mode=self.run_mode,
|
||||||
|
state=state,
|
||||||
|
admitted_count=admitted_count,
|
||||||
|
outcomes=outcomes,
|
||||||
|
deliveries=deliveries,
|
||||||
|
queue_high_watermarks=queue_high_watermarks,
|
||||||
|
)
|
||||||
return build_graph_run_result(
|
return build_graph_run_result(
|
||||||
graph_id=self.config.graph_id,
|
graph_id=self.config.graph_id,
|
||||||
source_profile_id=self.config.source_profile_id,
|
source_profile_id=self.config.source_profile_id,
|
||||||
@@ -598,6 +792,8 @@ class ReferencePerceptionGraphV1:
|
|||||||
ProviderRole.MOTION: self.motion.provider_id,
|
ProviderRole.MOTION: self.motion.provider_id,
|
||||||
ProviderRole.THREAT: self.threat.provider_id,
|
ProviderRole.THREAT: self.threat.provider_id,
|
||||||
}
|
}
|
||||||
|
if self.rolling is not None:
|
||||||
|
actual[ProviderRole.ROLLING] = self.rolling.provider_id
|
||||||
pinned = {pin.role: pin.provider_id for pin in self.config.providers}
|
pinned = {pin.role: pin.provider_id for pin in self.config.providers}
|
||||||
if actual != pinned:
|
if actual != pinned:
|
||||||
raise GraphExecutionError("configured provider identities do not match runtime")
|
raise GraphExecutionError("configured provider identities do not match runtime")
|
||||||
@@ -627,6 +823,56 @@ class ReferencePerceptionGraphV1:
|
|||||||
self._state = state
|
self._state = state
|
||||||
|
|
||||||
|
|
||||||
|
class ReferencePerceptionGraphV2(ReferencePerceptionGraphV1):
|
||||||
|
"""Final M4 graph with explicit bounded rolling occupancy ownership."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
config: ReferencePerceptionGraphConfigV2,
|
||||||
|
source: SourceProvider,
|
||||||
|
detector: DetectorProvider,
|
||||||
|
geometry: GeometryAssociationProvider,
|
||||||
|
temporal: TemporalStateProvider,
|
||||||
|
motion: MotionProvider,
|
||||||
|
rolling: RollingMapProvider,
|
||||||
|
threat: ThreatProvider,
|
||||||
|
run_mode: GraphRunMode,
|
||||||
|
telemetry_identity: PipelineTelemetryIdentity | None = None,
|
||||||
|
telemetry_sink: PipelineTelemetrySink | None = None,
|
||||||
|
clock_ns: Callable[[], int] = time.monotonic_ns,
|
||||||
|
) -> None:
|
||||||
|
if config.graph_id != REFERENCE_GRAPH_ID_V2:
|
||||||
|
raise GraphExecutionError("reference graph v2 id is incompatible")
|
||||||
|
if config.source_profile_id != BASELINE_PROFILE_ID:
|
||||||
|
raise GraphExecutionError("reference graph v2 source profile is incompatible")
|
||||||
|
self._has_run = False
|
||||||
|
self._run_once_lock = Lock()
|
||||||
|
self._initialize(
|
||||||
|
config=config,
|
||||||
|
source=source,
|
||||||
|
detector=detector,
|
||||||
|
geometry=geometry,
|
||||||
|
temporal=temporal,
|
||||||
|
motion=motion,
|
||||||
|
rolling=rolling,
|
||||||
|
threat=threat,
|
||||||
|
run_mode=run_mode,
|
||||||
|
telemetry_identity=telemetry_identity,
|
||||||
|
telemetry_sink=telemetry_sink,
|
||||||
|
clock_ns=clock_ns,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _begin_run(self) -> None:
|
||||||
|
with self._run_once_lock:
|
||||||
|
if self._has_run:
|
||||||
|
raise GraphExecutionError(
|
||||||
|
"reference graph v2 restart requires freshly instantiated providers"
|
||||||
|
)
|
||||||
|
self._has_run = True
|
||||||
|
super()._begin_run()
|
||||||
|
|
||||||
|
|
||||||
class _NullStage:
|
class _NullStage:
|
||||||
output_count: int | None = None
|
output_count: int | None = None
|
||||||
|
|
||||||
@@ -640,20 +886,25 @@ class _NullStage:
|
|||||||
def _packet_from_item(item: object) -> SourcePacket:
|
def _packet_from_item(item: object) -> SourcePacket:
|
||||||
if isinstance(item, SourcePacket):
|
if isinstance(item, SourcePacket):
|
||||||
return item
|
return item
|
||||||
if isinstance(item, (_Detected, _Associated, _Temporal)):
|
if isinstance(item, (_Detected, _Associated, _Temporal, _Rolled)):
|
||||||
return item.packet
|
return item.packet
|
||||||
raise GraphExecutionError("queue contained an incompatible item")
|
raise GraphExecutionError("queue contained an incompatible item")
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"GRAPH_RESULT_SCHEMA",
|
"GRAPH_RESULT_SCHEMA",
|
||||||
|
"GRAPH_RESULT_SCHEMA_V2",
|
||||||
"REFERENCE_GRAPH_ID",
|
"REFERENCE_GRAPH_ID",
|
||||||
|
"REFERENCE_GRAPH_ID_V2",
|
||||||
"TERMINAL_OUTCOME_SCHEMA",
|
"TERMINAL_OUTCOME_SCHEMA",
|
||||||
"DeliveredFrame",
|
"DeliveredFrame",
|
||||||
"GraphExecutionError",
|
"GraphExecutionError",
|
||||||
"GraphRunResult",
|
"GraphRunResult",
|
||||||
|
"GraphRunResultV2",
|
||||||
|
"GraphRunMode",
|
||||||
"GraphState",
|
"GraphState",
|
||||||
"ReferencePerceptionGraphV1",
|
"ReferencePerceptionGraphV1",
|
||||||
|
"ReferencePerceptionGraphV2",
|
||||||
"TerminalOutcome",
|
"TerminalOutcome",
|
||||||
"TerminalOutcomeType",
|
"TerminalOutcomeType",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ from typing import Final
|
|||||||
from .contracts import LocalObstacleMap, ThreatAssessment
|
from .contracts import LocalObstacleMap, ThreatAssessment
|
||||||
|
|
||||||
GRAPH_RESULT_SCHEMA: Final = "missioncore.reference-perception-graph-result/v1"
|
GRAPH_RESULT_SCHEMA: Final = "missioncore.reference-perception-graph-result/v1"
|
||||||
|
GRAPH_RESULT_SCHEMA_V2: Final = "missioncore.reference-perception-graph-result/v2"
|
||||||
TERMINAL_OUTCOME_SCHEMA: Final = "missioncore.perception-terminal-outcome/v1"
|
TERMINAL_OUTCOME_SCHEMA: Final = "missioncore.perception-terminal-outcome/v1"
|
||||||
REFERENCE_GRAPH_ID: Final = "reference-perception-graph/v1"
|
REFERENCE_GRAPH_ID: Final = "reference-perception-graph/v1"
|
||||||
|
REFERENCE_GRAPH_ID_V2: Final = "reference-perception-graph/v2"
|
||||||
_SAFE_IDENTIFIER: Final = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,159}$")
|
_SAFE_IDENTIFIER: Final = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,159}$")
|
||||||
|
|
||||||
|
|
||||||
@@ -31,6 +33,13 @@ class GraphState(StrEnum):
|
|||||||
FAILED = "failed"
|
FAILED = "failed"
|
||||||
|
|
||||||
|
|
||||||
|
class GraphRunMode(StrEnum):
|
||||||
|
"""Bounded queue behavior for realtime and deterministic replay."""
|
||||||
|
|
||||||
|
SOURCE_PACED_LATEST_WINS = "source-paced-latest-wins"
|
||||||
|
LOSSLESS_REPLAY = "lossless-replay"
|
||||||
|
|
||||||
|
|
||||||
class TerminalOutcomeType(StrEnum):
|
class TerminalOutcomeType(StrEnum):
|
||||||
DELIVERED = "delivered"
|
DELIVERED = "delivered"
|
||||||
SUPERSEDED = "superseded"
|
SUPERSEDED = "superseded"
|
||||||
@@ -110,6 +119,33 @@ class GraphRunResult:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class GraphRunResultV2:
|
||||||
|
graph_id: str
|
||||||
|
source_profile_id: str
|
||||||
|
run_mode: GraphRunMode
|
||||||
|
state: GraphState
|
||||||
|
admitted_count: int
|
||||||
|
terminal_outcomes: tuple[TerminalOutcome, ...]
|
||||||
|
deliveries: tuple[DeliveredFrame, ...]
|
||||||
|
queue_high_watermarks: tuple[tuple[str, int], ...]
|
||||||
|
canonical_payload_sha256: str
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"schema_version": GRAPH_RESULT_SCHEMA_V2,
|
||||||
|
"graph_id": self.graph_id,
|
||||||
|
"source_profile_id": self.source_profile_id,
|
||||||
|
"run_mode": self.run_mode.value,
|
||||||
|
"state": self.state.value,
|
||||||
|
"admitted_count": self.admitted_count,
|
||||||
|
"terminal_outcomes": [item.to_dict() for item in self.terminal_outcomes],
|
||||||
|
"deliveries": [item.canonical_dict() for item in self.deliveries],
|
||||||
|
"queue_high_watermarks": dict(self.queue_high_watermarks),
|
||||||
|
"canonical_payload_sha256": self.canonical_payload_sha256,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def build_graph_run_result(
|
def build_graph_run_result(
|
||||||
*,
|
*,
|
||||||
graph_id: str,
|
graph_id: str,
|
||||||
@@ -137,15 +173,52 @@ def build_graph_run_result(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_graph_run_result_v2(
|
||||||
|
*,
|
||||||
|
graph_id: str,
|
||||||
|
source_profile_id: str,
|
||||||
|
run_mode: GraphRunMode,
|
||||||
|
state: GraphState,
|
||||||
|
admitted_count: int,
|
||||||
|
outcomes: tuple[TerminalOutcome, ...],
|
||||||
|
deliveries: tuple[DeliveredFrame, ...],
|
||||||
|
queue_high_watermarks: tuple[tuple[str, int], ...],
|
||||||
|
) -> GraphRunResultV2:
|
||||||
|
canonical = {
|
||||||
|
"graph_id": graph_id,
|
||||||
|
"source_profile_id": source_profile_id,
|
||||||
|
"run_mode": run_mode.value,
|
||||||
|
"terminal_outcomes": [item.to_dict() for item in outcomes],
|
||||||
|
"deliveries": [item.canonical_dict() for item in deliveries],
|
||||||
|
}
|
||||||
|
payload = json.dumps(canonical, sort_keys=True, separators=(",", ":")).encode()
|
||||||
|
return GraphRunResultV2(
|
||||||
|
graph_id=graph_id,
|
||||||
|
source_profile_id=source_profile_id,
|
||||||
|
run_mode=run_mode,
|
||||||
|
state=state,
|
||||||
|
admitted_count=admitted_count,
|
||||||
|
terminal_outcomes=outcomes,
|
||||||
|
deliveries=deliveries,
|
||||||
|
queue_high_watermarks=queue_high_watermarks,
|
||||||
|
canonical_payload_sha256=hashlib.sha256(payload).hexdigest(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"GRAPH_RESULT_SCHEMA",
|
"GRAPH_RESULT_SCHEMA",
|
||||||
|
"GRAPH_RESULT_SCHEMA_V2",
|
||||||
"REFERENCE_GRAPH_ID",
|
"REFERENCE_GRAPH_ID",
|
||||||
|
"REFERENCE_GRAPH_ID_V2",
|
||||||
"TERMINAL_OUTCOME_SCHEMA",
|
"TERMINAL_OUTCOME_SCHEMA",
|
||||||
"DeliveredFrame",
|
"DeliveredFrame",
|
||||||
"GraphExecutionError",
|
"GraphExecutionError",
|
||||||
"GraphRunResult",
|
"GraphRunResult",
|
||||||
|
"GraphRunResultV2",
|
||||||
|
"GraphRunMode",
|
||||||
"GraphState",
|
"GraphState",
|
||||||
"TerminalOutcome",
|
"TerminalOutcome",
|
||||||
"TerminalOutcomeType",
|
"TerminalOutcomeType",
|
||||||
"build_graph_run_result",
|
"build_graph_run_result",
|
||||||
|
"build_graph_run_result_v2",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -69,6 +69,20 @@ def validate_temporal(
|
|||||||
raise GraphExecutionError("current temporal occupancy was invented without LiDAR")
|
raise GraphExecutionError("current temporal occupancy was invented without LiDAR")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_rolling(
|
||||||
|
temporal: tuple[TemporalObstacle, ...],
|
||||||
|
retained: tuple[TemporalObstacle, ...],
|
||||||
|
) -> None:
|
||||||
|
retained_ids = [obstacle.component_id for obstacle in retained]
|
||||||
|
temporal_ids = {obstacle.component_id for obstacle in temporal}
|
||||||
|
if len(set(retained_ids)) != len(retained_ids):
|
||||||
|
raise GraphExecutionError("rolling component identities are duplicated")
|
||||||
|
if temporal_ids.intersection(retained_ids):
|
||||||
|
raise GraphExecutionError("rolling and temporal component identities overlap")
|
||||||
|
if any(obstacle.state is not TemporalState.RETAINED for obstacle in retained):
|
||||||
|
raise GraphExecutionError("rolling provider published non-retained evidence")
|
||||||
|
|
||||||
|
|
||||||
def validate_threats(
|
def validate_threats(
|
||||||
obstacle_map: LocalObstacleMap,
|
obstacle_map: LocalObstacleMap,
|
||||||
threats: tuple[ThreatAssessment, ...],
|
threats: tuple[ThreatAssessment, ...],
|
||||||
@@ -92,6 +106,7 @@ def validate_threats(
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"validate_observations",
|
"validate_observations",
|
||||||
"validate_proposals",
|
"validate_proposals",
|
||||||
|
"validate_rolling",
|
||||||
"validate_temporal",
|
"validate_temporal",
|
||||||
"validate_threats",
|
"validate_threats",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -19,7 +19,11 @@ from .contracts import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
REFERENCE_GRAPH_CONFIG_SCHEMA: Final = "missioncore.reference-perception-graph-config/v1"
|
REFERENCE_GRAPH_CONFIG_SCHEMA: Final = "missioncore.reference-perception-graph-config/v1"
|
||||||
|
REFERENCE_GRAPH_CONFIG_SCHEMA_V2: Final = "missioncore.reference-perception-graph-config/v2"
|
||||||
REFERENCE_GRAPH_STAGE_IDS: Final = frozenset({"detector", "geometry", "temporal", "threat"})
|
REFERENCE_GRAPH_STAGE_IDS: Final = frozenset({"detector", "geometry", "temporal", "threat"})
|
||||||
|
REFERENCE_GRAPH_STAGE_IDS_V2: Final = frozenset(
|
||||||
|
{"detector", "geometry", "temporal", "rolling", "threat"}
|
||||||
|
)
|
||||||
_SHA256 = re.compile(r"^[a-f0-9]{64}$")
|
_SHA256 = re.compile(r"^[a-f0-9]{64}$")
|
||||||
_IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,159}$")
|
_IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,159}$")
|
||||||
|
|
||||||
@@ -34,9 +38,23 @@ class ProviderRole(StrEnum):
|
|||||||
GEOMETRY = "geometry"
|
GEOMETRY = "geometry"
|
||||||
TEMPORAL = "temporal"
|
TEMPORAL = "temporal"
|
||||||
MOTION = "motion"
|
MOTION = "motion"
|
||||||
|
ROLLING = "rolling"
|
||||||
THREAT = "threat"
|
THREAT = "threat"
|
||||||
|
|
||||||
|
|
||||||
|
REFERENCE_GRAPH_PROVIDER_ROLES: Final = frozenset(
|
||||||
|
{
|
||||||
|
ProviderRole.SOURCE,
|
||||||
|
ProviderRole.DETECTOR,
|
||||||
|
ProviderRole.GEOMETRY,
|
||||||
|
ProviderRole.TEMPORAL,
|
||||||
|
ProviderRole.MOTION,
|
||||||
|
ProviderRole.THREAT,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
REFERENCE_GRAPH_PROVIDER_ROLES_V2: Final = frozenset(ProviderRole)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class SourcePacket:
|
class SourcePacket:
|
||||||
"""Execution-only carrier; the graph never interprets opaque sensor payloads."""
|
"""Execution-only carrier; the graph never interprets opaque sensor payloads."""
|
||||||
@@ -211,7 +229,7 @@ class ReferencePerceptionGraphConfig:
|
|||||||
_identifier(self.graph_id, "graph id")
|
_identifier(self.graph_id, "graph id")
|
||||||
_identifier(self.source_profile_id, "source profile id")
|
_identifier(self.source_profile_id, "source profile id")
|
||||||
roles = [provider.role for provider in self.providers]
|
roles = [provider.role for provider in self.providers]
|
||||||
if len(set(roles)) != len(roles) or set(roles) != set(ProviderRole):
|
if len(set(roles)) != len(roles) or set(roles) != REFERENCE_GRAPH_PROVIDER_ROLES:
|
||||||
raise ProviderContractError("graph must pin each provider role exactly once")
|
raise ProviderContractError("graph must pin each provider role exactly once")
|
||||||
stage_ids = [queue.stage_id for queue in self.queues]
|
stage_ids = [queue.stage_id for queue in self.queues]
|
||||||
if len(set(stage_ids)) != len(stage_ids):
|
if len(set(stage_ids)) != len(stage_ids):
|
||||||
@@ -248,6 +266,57 @@ class ReferencePerceptionGraphConfig:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ReferencePerceptionGraphConfigV2:
|
||||||
|
"""Final M4 graph contract with explicit retained rolling occupancy."""
|
||||||
|
|
||||||
|
graph_id: str
|
||||||
|
source_profile_id: str
|
||||||
|
providers: tuple[ProviderPin, ...]
|
||||||
|
queues: tuple[QueuePolicy, ...]
|
||||||
|
authority: GraphAuthority = GraphAuthority()
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
_identifier(self.graph_id, "graph id")
|
||||||
|
_identifier(self.source_profile_id, "source profile id")
|
||||||
|
roles = [provider.role for provider in self.providers]
|
||||||
|
if len(set(roles)) != len(roles) or set(roles) != REFERENCE_GRAPH_PROVIDER_ROLES_V2:
|
||||||
|
raise ProviderContractError("graph v2 must pin each provider role exactly once")
|
||||||
|
stage_ids = [queue.stage_id for queue in self.queues]
|
||||||
|
if len(set(stage_ids)) != len(stage_ids):
|
||||||
|
raise ProviderContractError("graph v2 queue policies must be unique")
|
||||||
|
if set(stage_ids) != REFERENCE_GRAPH_STAGE_IDS_V2:
|
||||||
|
raise ProviderContractError("graph v2 must bound each reference stage exactly once")
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"schema_version": REFERENCE_GRAPH_CONFIG_SCHEMA_V2,
|
||||||
|
"graph_id": self.graph_id,
|
||||||
|
"source_profile_id": self.source_profile_id,
|
||||||
|
"providers": [provider.to_dict() for provider in self.providers],
|
||||||
|
"queues": [queue.to_dict() for queue in self.queues],
|
||||||
|
"authority": self.authority.to_dict(),
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, value: object) -> ReferencePerceptionGraphConfigV2:
|
||||||
|
document = _object(value, "reference graph v2 config")
|
||||||
|
_exact_keys(
|
||||||
|
document,
|
||||||
|
{"schema_version", "graph_id", "source_profile_id", "providers", "queues", "authority"},
|
||||||
|
"reference graph v2 config",
|
||||||
|
)
|
||||||
|
if document.get("schema_version") != REFERENCE_GRAPH_CONFIG_SCHEMA_V2:
|
||||||
|
raise ProviderContractError("reference graph v2 config schema is incompatible")
|
||||||
|
return cls(
|
||||||
|
graph_id=_string(document, "graph_id"),
|
||||||
|
source_profile_id=_string(document, "source_profile_id"),
|
||||||
|
providers=tuple(ProviderPin.from_dict(item) for item in _array(document, "providers")),
|
||||||
|
queues=tuple(QueuePolicy.from_dict(item) for item in _array(document, "queues")),
|
||||||
|
authority=GraphAuthority.from_dict(document.get("authority")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SourceProvider(Protocol):
|
class SourceProvider(Protocol):
|
||||||
provider_id: str
|
provider_id: str
|
||||||
|
|
||||||
@@ -290,6 +359,16 @@ class MotionProvider(Protocol):
|
|||||||
) -> tuple[TemporalObstacle, ...]: ...
|
) -> tuple[TemporalObstacle, ...]: ...
|
||||||
|
|
||||||
|
|
||||||
|
class RollingMapProvider(Protocol):
|
||||||
|
provider_id: str
|
||||||
|
|
||||||
|
def update(
|
||||||
|
self,
|
||||||
|
packet: SourcePacket,
|
||||||
|
obstacles: tuple[TemporalObstacle, ...],
|
||||||
|
) -> tuple[TemporalObstacle, ...]: ...
|
||||||
|
|
||||||
|
|
||||||
class ThreatProvider(Protocol):
|
class ThreatProvider(Protocol):
|
||||||
provider_id: str
|
provider_id: str
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""Run and seal the M4.7 canonical graph on explicit Worker 006 inputs."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .graph_contracts import GraphRunMode, GraphRunResultV2
|
||||||
|
from .reference_graph_parity import compare_reference_graph_to_accepted_ledgers
|
||||||
|
from .reference_graph_result import seal_reference_graph_result
|
||||||
|
from .reference_graph_runtime import ReferenceGraphRuntimePaths, build_reference_graph_runtime
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--graph-config", type=Path, required=True)
|
||||||
|
parser.add_argument("--baseline-profile", type=Path, required=True)
|
||||||
|
parser.add_argument("--geometry-profile", type=Path, required=True)
|
||||||
|
parser.add_argument("--temporal-motion-profile", type=Path, required=True)
|
||||||
|
parser.add_argument("--rolling-map-profile", type=Path, required=True)
|
||||||
|
parser.add_argument("--threat-profile", type=Path, required=True)
|
||||||
|
parser.add_argument("--camera-index", type=Path, required=True)
|
||||||
|
parser.add_argument("--source-pack", type=Path, required=True)
|
||||||
|
parser.add_argument("--local-surface", type=Path, required=True)
|
||||||
|
parser.add_argument("--video", type=Path, required=True)
|
||||||
|
parser.add_argument("--valid-fov-mask", type=Path, required=True)
|
||||||
|
parser.add_argument("--temporal-parity-frames", type=Path, required=True)
|
||||||
|
parser.add_argument("--threat-parity-frames", type=Path, required=True)
|
||||||
|
parser.add_argument("--triton-origin", default="http://127.0.0.1:8000")
|
||||||
|
parser.add_argument(
|
||||||
|
"--mode",
|
||||||
|
choices=tuple(item.value for item in GraphRunMode),
|
||||||
|
default=GraphRunMode.LOSSLESS_REPLAY.value,
|
||||||
|
)
|
||||||
|
parser.add_argument("--expected-frames", type=int, default=4489)
|
||||||
|
parser.add_argument("--output-root", type=Path, required=True)
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
paths = ReferenceGraphRuntimePaths(
|
||||||
|
graph_config=args.graph_config,
|
||||||
|
baseline_profile=args.baseline_profile,
|
||||||
|
geometry_profile=args.geometry_profile,
|
||||||
|
temporal_motion_profile=args.temporal_motion_profile,
|
||||||
|
rolling_map_profile=args.rolling_map_profile,
|
||||||
|
threat_profile=args.threat_profile,
|
||||||
|
camera_index=args.camera_index,
|
||||||
|
source_pack=args.source_pack,
|
||||||
|
local_surface=args.local_surface,
|
||||||
|
video=args.video,
|
||||||
|
valid_fov_mask=args.valid_fov_mask,
|
||||||
|
)
|
||||||
|
started_ns = time.perf_counter_ns()
|
||||||
|
with build_reference_graph_runtime(
|
||||||
|
paths=paths,
|
||||||
|
triton_origin=args.triton_origin,
|
||||||
|
run_mode=GraphRunMode(args.mode),
|
||||||
|
) as runtime:
|
||||||
|
graph_result = runtime.graph.run()
|
||||||
|
if not isinstance(graph_result, GraphRunResultV2):
|
||||||
|
raise RuntimeError("M4.7 runtime returned a legacy graph result")
|
||||||
|
parity = compare_reference_graph_to_accepted_ledgers(
|
||||||
|
graph_result,
|
||||||
|
temporal_frames_path=args.temporal_parity_frames,
|
||||||
|
threat_frames_path=args.threat_parity_frames,
|
||||||
|
expected_frames=args.expected_frames,
|
||||||
|
)
|
||||||
|
sealed = seal_reference_graph_result(
|
||||||
|
graph_result,
|
||||||
|
output_root=args.output_root,
|
||||||
|
expected_frames=args.expected_frames,
|
||||||
|
parity=parity,
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"accepted": sealed.accepted,
|
||||||
|
"elapsed_seconds": (time.perf_counter_ns() - started_ns) / 1_000_000_000,
|
||||||
|
"result_id": sealed.result_id,
|
||||||
|
"result_root": str(sealed.result_root),
|
||||||
|
},
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return 0 if sealed.accepted else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
"""Frame-exact parity against the accepted M4.5R and M4.6 ledgers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
|
from .contracts import TemporalState
|
||||||
|
from .graph_contracts import GraphRunResultV2
|
||||||
|
|
||||||
|
REFERENCE_GRAPH_PARITY_SCHEMA: Final = "missioncore.reference-perception-graph-parity/v1"
|
||||||
|
|
||||||
|
|
||||||
|
class ReferenceGraphParityError(RuntimeError):
|
||||||
|
"""Accepted parity evidence is missing, malformed or source-inconsistent."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ReferenceGraphParityReport:
|
||||||
|
expected_frames: int
|
||||||
|
compared_frames: int
|
||||||
|
temporal_frames_sha256: str
|
||||||
|
threat_frames_sha256: str
|
||||||
|
mismatch_counts: tuple[tuple[str, int], ...]
|
||||||
|
accepted: bool
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.expected_frames < 1 or not 0 <= self.compared_frames <= self.expected_frames:
|
||||||
|
raise ReferenceGraphParityError("parity frame accounting is invalid")
|
||||||
|
for digest in (self.temporal_frames_sha256, self.threat_frames_sha256):
|
||||||
|
if len(digest) != 64 or any(
|
||||||
|
character not in "0123456789abcdef" for character in digest
|
||||||
|
):
|
||||||
|
raise ReferenceGraphParityError("parity ledger digest is invalid")
|
||||||
|
names = [name for name, _ in self.mismatch_counts]
|
||||||
|
if (
|
||||||
|
names != sorted(names)
|
||||||
|
or len(set(names)) != len(names)
|
||||||
|
or any(not name or count < 0 for name, count in self.mismatch_counts)
|
||||||
|
):
|
||||||
|
raise ReferenceGraphParityError("parity mismatch accounting is invalid")
|
||||||
|
exact = self.compared_frames == self.expected_frames and all(
|
||||||
|
count == 0 for _, count in self.mismatch_counts
|
||||||
|
)
|
||||||
|
if self.accepted is not exact:
|
||||||
|
raise ReferenceGraphParityError("parity acceptance disagrees with accounting")
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"schema_version": REFERENCE_GRAPH_PARITY_SCHEMA,
|
||||||
|
"expected_frames": self.expected_frames,
|
||||||
|
"compared_frames": self.compared_frames,
|
||||||
|
"temporal_frames_sha256": self.temporal_frames_sha256,
|
||||||
|
"threat_frames_sha256": self.threat_frames_sha256,
|
||||||
|
"mismatch_counts": dict(self.mismatch_counts),
|
||||||
|
"accepted": self.accepted,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def compare_reference_graph_to_accepted_ledgers(
|
||||||
|
result: GraphRunResultV2,
|
||||||
|
*,
|
||||||
|
temporal_frames_path: Path,
|
||||||
|
threat_frames_path: Path,
|
||||||
|
expected_frames: int,
|
||||||
|
) -> ReferenceGraphParityReport:
|
||||||
|
if expected_frames < 1:
|
||||||
|
raise ReferenceGraphParityError("expected parity frame count must be positive")
|
||||||
|
if len(result.deliveries) != expected_frames:
|
||||||
|
raise ReferenceGraphParityError("graph delivery count cannot support full parity")
|
||||||
|
temporal_path = _regular_file(temporal_frames_path, "temporal parity ledger")
|
||||||
|
threat_path = _regular_file(threat_frames_path, "threat parity ledger")
|
||||||
|
mismatch = {
|
||||||
|
"source_binding": 0,
|
||||||
|
"current": 0,
|
||||||
|
"rolling_retained": 0,
|
||||||
|
"held": 0,
|
||||||
|
"expired": 0,
|
||||||
|
"camera_uncertainty": 0,
|
||||||
|
"threat_assessments": 0,
|
||||||
|
}
|
||||||
|
compared = 0
|
||||||
|
with temporal_path.open("rb") as temporal_stream, threat_path.open("rb") as threat_stream:
|
||||||
|
for expected_sequence, delivery in enumerate(result.deliveries):
|
||||||
|
temporal = _read_row(temporal_stream.readline(), "temporal", expected_sequence)
|
||||||
|
threat = _read_row(threat_stream.readline(), "threat", expected_sequence)
|
||||||
|
obstacle_map = delivery.obstacle_map
|
||||||
|
if (
|
||||||
|
delivery.sequence != expected_sequence
|
||||||
|
or temporal.get("sequence") != expected_sequence
|
||||||
|
or threat.get("sequence") != expected_sequence
|
||||||
|
or temporal.get("frame_id") != obstacle_map.frame_id
|
||||||
|
or threat.get("frame_id") != obstacle_map.frame_id
|
||||||
|
):
|
||||||
|
mismatch["source_binding"] += 1
|
||||||
|
occupied_current = [
|
||||||
|
item.to_dict()
|
||||||
|
for item in obstacle_map.occupied
|
||||||
|
if item.state is TemporalState.CURRENT
|
||||||
|
]
|
||||||
|
rolling_retained = [
|
||||||
|
item.to_dict()
|
||||||
|
for item in obstacle_map.occupied
|
||||||
|
if item.state is TemporalState.RETAINED
|
||||||
|
]
|
||||||
|
held = [
|
||||||
|
item.to_dict()
|
||||||
|
for item in obstacle_map.unknown
|
||||||
|
if item.state is TemporalState.HELD
|
||||||
|
]
|
||||||
|
expired = [
|
||||||
|
item.to_dict()
|
||||||
|
for item in obstacle_map.unknown
|
||||||
|
if item.state is TemporalState.EXPIRED
|
||||||
|
]
|
||||||
|
for label, observed in (
|
||||||
|
("current", occupied_current),
|
||||||
|
("rolling_retained", rolling_retained),
|
||||||
|
("held", held),
|
||||||
|
("expired", expired),
|
||||||
|
):
|
||||||
|
if observed != _array(temporal, label):
|
||||||
|
mismatch[label] += 1
|
||||||
|
expected_camera_uncertainty = [
|
||||||
|
_string(row, "proposal_id")
|
||||||
|
for row in _array(threat, "camera_proposals")
|
||||||
|
if _boolean(row, "occupied_support") is False
|
||||||
|
]
|
||||||
|
observed_camera_uncertainty = [
|
||||||
|
item.proposal_id for item in obstacle_map.camera_uncertainty
|
||||||
|
]
|
||||||
|
if observed_camera_uncertainty != expected_camera_uncertainty:
|
||||||
|
mismatch["camera_uncertainty"] += 1
|
||||||
|
if [item.to_dict() for item in delivery.threats] != _array(
|
||||||
|
threat,
|
||||||
|
"assessments",
|
||||||
|
):
|
||||||
|
mismatch["threat_assessments"] += 1
|
||||||
|
compared += 1
|
||||||
|
if temporal_stream.readline() or threat_stream.readline():
|
||||||
|
raise ReferenceGraphParityError("accepted parity ledger exceeds graph delivery count")
|
||||||
|
accepted = compared == expected_frames and all(value == 0 for value in mismatch.values())
|
||||||
|
return ReferenceGraphParityReport(
|
||||||
|
expected_frames=expected_frames,
|
||||||
|
compared_frames=compared,
|
||||||
|
temporal_frames_sha256=_sha256_file(temporal_path),
|
||||||
|
threat_frames_sha256=_sha256_file(threat_path),
|
||||||
|
mismatch_counts=tuple(sorted(mismatch.items())),
|
||||||
|
accepted=accepted,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _regular_file(path: Path, label: str) -> Path:
|
||||||
|
resolved = path.resolve(strict=True)
|
||||||
|
if resolved.is_symlink() or not resolved.is_file():
|
||||||
|
raise ReferenceGraphParityError(f"{label} must be a regular file")
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
def _read_row(raw: bytes, label: str, sequence: int) -> dict[str, object]:
|
||||||
|
if not raw:
|
||||||
|
raise ReferenceGraphParityError(f"{label} parity ledger ended at frame {sequence}")
|
||||||
|
try:
|
||||||
|
value = json.loads(raw)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise ReferenceGraphParityError(f"{label} parity row is invalid JSON") from exc
|
||||||
|
if not isinstance(value, dict) or any(not isinstance(key, str) for key in value):
|
||||||
|
raise ReferenceGraphParityError(f"{label} parity row is not an object")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256_file(path: Path) -> str:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with path.open("rb") as handle:
|
||||||
|
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _array(document: dict[str, object], key: str) -> list[dict[str, object]]:
|
||||||
|
value = document.get(key)
|
||||||
|
if not isinstance(value, list) or any(
|
||||||
|
not isinstance(item, dict) or any(not isinstance(name, str) for name in item)
|
||||||
|
for item in value
|
||||||
|
):
|
||||||
|
raise ReferenceGraphParityError(f"parity {key} must be an object array")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _string(document: dict[str, object], key: str) -> str:
|
||||||
|
value = document.get(key)
|
||||||
|
if not isinstance(value, str) or not value:
|
||||||
|
raise ReferenceGraphParityError(f"parity {key} must be a nonempty string")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _boolean(document: dict[str, object], key: str) -> bool:
|
||||||
|
value = document.get(key)
|
||||||
|
if not isinstance(value, bool):
|
||||||
|
raise ReferenceGraphParityError(f"parity {key} must be boolean")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"REFERENCE_GRAPH_PARITY_SCHEMA",
|
||||||
|
"ReferenceGraphParityError",
|
||||||
|
"ReferenceGraphParityReport",
|
||||||
|
"compare_reference_graph_to_accepted_ledgers",
|
||||||
|
]
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
"""Immutable result sealing for the M4.7 canonical graph shadow."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import uuid
|
||||||
|
from collections import Counter
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
|
from .graph_contracts import GraphRunMode, GraphRunResultV2, GraphState, TerminalOutcomeType
|
||||||
|
from .reference_graph_parity import ReferenceGraphParityReport
|
||||||
|
|
||||||
|
REFERENCE_GRAPH_RESULT_PREFIX: Final = "m47-reference-graph-"
|
||||||
|
REFERENCE_GRAPH_REPORT_SCHEMA: Final = "missioncore.reference-perception-graph-report/v1"
|
||||||
|
REFERENCE_GRAPH_MANIFEST_SCHEMA: Final = "missioncore.reference-perception-graph-manifest/v1"
|
||||||
|
|
||||||
|
|
||||||
|
class ReferenceGraphResultError(RuntimeError):
|
||||||
|
"""A graph result cannot be sealed without complete terminal evidence."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SealedReferenceGraphResult:
|
||||||
|
result_id: str
|
||||||
|
result_root: Path
|
||||||
|
accepted: bool
|
||||||
|
report: dict[str, object]
|
||||||
|
manifest: dict[str, object]
|
||||||
|
|
||||||
|
|
||||||
|
def seal_reference_graph_result(
|
||||||
|
result: GraphRunResultV2,
|
||||||
|
*,
|
||||||
|
output_root: Path,
|
||||||
|
expected_frames: int,
|
||||||
|
parity: ReferenceGraphParityReport,
|
||||||
|
) -> SealedReferenceGraphResult:
|
||||||
|
if expected_frames < 1:
|
||||||
|
raise ReferenceGraphResultError("expected frame count must be positive")
|
||||||
|
outcomes = Counter(item.outcome.value for item in result.terminal_outcomes)
|
||||||
|
gates = {
|
||||||
|
"lossless_replay_mode": result.run_mode is GraphRunMode.LOSSLESS_REPLAY,
|
||||||
|
"graph_stopped": result.state is GraphState.STOPPED,
|
||||||
|
"admitted_frame_count": result.admitted_count == expected_frames,
|
||||||
|
"terminal_accounting_closed": len(result.terminal_outcomes) == expected_frames,
|
||||||
|
"delivery_count": outcomes[TerminalOutcomeType.DELIVERED.value] == expected_frames,
|
||||||
|
"delivery_payload_count": len(result.deliveries) == expected_frames,
|
||||||
|
"no_failed_frames": outcomes[TerminalOutcomeType.FAILED.value] == 0,
|
||||||
|
"no_stale_frames": outcomes[TerminalOutcomeType.STALE.value] == 0,
|
||||||
|
"no_superseded_frames": outcomes[TerminalOutcomeType.SUPERSEDED.value] == 0,
|
||||||
|
"no_rejected_frames": outcomes[TerminalOutcomeType.REJECTED.value] == 0,
|
||||||
|
"no_unavailable_frames": outcomes[TerminalOutcomeType.UNAVAILABLE.value] == 0,
|
||||||
|
"accepted_m45r_m46_parity": parity.accepted,
|
||||||
|
}
|
||||||
|
accepted = all(gates.values())
|
||||||
|
report: dict[str, object] = {
|
||||||
|
"schema_version": REFERENCE_GRAPH_REPORT_SCHEMA,
|
||||||
|
"graph_id": result.graph_id,
|
||||||
|
"source_profile_id": result.source_profile_id,
|
||||||
|
"run_mode": result.run_mode.value,
|
||||||
|
"state": result.state.value,
|
||||||
|
"expected_frames": expected_frames,
|
||||||
|
"admitted_frames": result.admitted_count,
|
||||||
|
"terminal_outcomes": dict(sorted(outcomes.items())),
|
||||||
|
"delivery_payloads": len(result.deliveries),
|
||||||
|
"queue_high_watermarks": dict(result.queue_high_watermarks),
|
||||||
|
"canonical_payload_sha256": result.canonical_payload_sha256,
|
||||||
|
"accepted_parity": parity.to_dict(),
|
||||||
|
"gates": gates,
|
||||||
|
"accepted": accepted,
|
||||||
|
"authority": {
|
||||||
|
"physical_live": False,
|
||||||
|
"commands_enabled": False,
|
||||||
|
"actuation_allowed": False,
|
||||||
|
"navigation_or_safety_accepted": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
root = output_root.expanduser().resolve()
|
||||||
|
root.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||||
|
if root.is_symlink() or not root.is_dir():
|
||||||
|
raise ReferenceGraphResultError("result root must be a real directory")
|
||||||
|
staging = root / f".reference-graph.{uuid.uuid4().hex}.tmp"
|
||||||
|
staging.mkdir(mode=0o700, exist_ok=False)
|
||||||
|
try:
|
||||||
|
frames_path = staging / "frames.jsonl"
|
||||||
|
outcomes_path = staging / "outcomes.jsonl"
|
||||||
|
report_path = staging / "report.json"
|
||||||
|
_write_json_lines(
|
||||||
|
frames_path,
|
||||||
|
tuple(delivery.canonical_dict() for delivery in result.deliveries),
|
||||||
|
)
|
||||||
|
_write_json_lines(
|
||||||
|
outcomes_path,
|
||||||
|
tuple(outcome.to_dict() for outcome in result.terminal_outcomes),
|
||||||
|
)
|
||||||
|
_write_json(report_path, report)
|
||||||
|
file_rows = {
|
||||||
|
name: {
|
||||||
|
"sha256": _sha256_file(staging / name),
|
||||||
|
"bytes": (staging / name).stat().st_size,
|
||||||
|
}
|
||||||
|
for name in ("frames.jsonl", "outcomes.jsonl", "report.json")
|
||||||
|
}
|
||||||
|
identity: dict[str, object] = {
|
||||||
|
"graph_id": result.graph_id,
|
||||||
|
"source_profile_id": result.source_profile_id,
|
||||||
|
"run_mode": result.run_mode.value,
|
||||||
|
"canonical_payload_sha256": result.canonical_payload_sha256,
|
||||||
|
"files": file_rows,
|
||||||
|
}
|
||||||
|
identity_sha256 = hashlib.sha256(_canonical_json(identity)).hexdigest()
|
||||||
|
result_id = f"{REFERENCE_GRAPH_RESULT_PREFIX}{identity_sha256}"
|
||||||
|
manifest: dict[str, object] = {
|
||||||
|
"schema_version": REFERENCE_GRAPH_MANIFEST_SCHEMA,
|
||||||
|
"result_id": result_id,
|
||||||
|
"identity_sha256": identity_sha256,
|
||||||
|
**identity,
|
||||||
|
"accepted": accepted,
|
||||||
|
}
|
||||||
|
_write_json(staging / "manifest.json", manifest)
|
||||||
|
target = root / result_id
|
||||||
|
_publish_immutable(staging, target)
|
||||||
|
except Exception:
|
||||||
|
shutil.rmtree(staging, ignore_errors=True)
|
||||||
|
raise
|
||||||
|
return SealedReferenceGraphResult(
|
||||||
|
result_id=result_id,
|
||||||
|
result_root=target,
|
||||||
|
accepted=accepted,
|
||||||
|
report=report,
|
||||||
|
manifest=manifest,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_json_lines(path: Path, rows: tuple[dict[str, object], ...]) -> None:
|
||||||
|
with path.open("wb") as handle:
|
||||||
|
for row in rows:
|
||||||
|
handle.write(_canonical_json(row) + b"\n")
|
||||||
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
|
||||||
|
|
||||||
|
def _write_json(path: Path, document: dict[str, object]) -> None:
|
||||||
|
with path.open("wb") as handle:
|
||||||
|
handle.write(_canonical_json(document) + b"\n")
|
||||||
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
|
||||||
|
|
||||||
|
def _publish_immutable(staging: Path, target: Path) -> None:
|
||||||
|
if target.exists():
|
||||||
|
if target.is_symlink() or not target.is_dir():
|
||||||
|
raise ReferenceGraphResultError("immutable result target is not a real directory")
|
||||||
|
expected = {
|
||||||
|
path.name: _sha256_file(path)
|
||||||
|
for path in staging.iterdir()
|
||||||
|
if path.is_file()
|
||||||
|
}
|
||||||
|
observed = {
|
||||||
|
path.name: _sha256_file(path)
|
||||||
|
for path in target.iterdir()
|
||||||
|
if path.is_file()
|
||||||
|
}
|
||||||
|
if expected != observed:
|
||||||
|
raise ReferenceGraphResultError("immutable result identity collision")
|
||||||
|
shutil.rmtree(staging)
|
||||||
|
return
|
||||||
|
staging.replace(target)
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_json(document: dict[str, object]) -> bytes:
|
||||||
|
return json.dumps(document, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256_file(path: Path) -> str:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with path.open("rb") as handle:
|
||||||
|
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"REFERENCE_GRAPH_MANIFEST_SCHEMA",
|
||||||
|
"REFERENCE_GRAPH_REPORT_SCHEMA",
|
||||||
|
"REFERENCE_GRAPH_RESULT_PREFIX",
|
||||||
|
"ReferenceGraphResultError",
|
||||||
|
"SealedReferenceGraphResult",
|
||||||
|
"seal_reference_graph_result",
|
||||||
|
]
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
"""Production assembly boundary for the source-neutral M4.7 reference graph."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .baseline import load_m4_baseline
|
||||||
|
from .detector import FrozenYoloxDetectorProvider
|
||||||
|
from .geometry import (
|
||||||
|
Ravnoves00GeometryAssociationProvider,
|
||||||
|
RecordedGeometryStore,
|
||||||
|
load_geometry_profile,
|
||||||
|
)
|
||||||
|
from .graph import ReferencePerceptionGraphV2
|
||||||
|
from .graph_contracts import GraphRunMode
|
||||||
|
from .motion import ClassIndependentMotionEstimator
|
||||||
|
from .providers import ProviderRole, ReferencePerceptionGraphConfigV2
|
||||||
|
from .recorded_source import (
|
||||||
|
DecodedRecordedSource,
|
||||||
|
PyAvRecordedImageDecoder,
|
||||||
|
RecordedRavnoves00Source,
|
||||||
|
ReplayPacing,
|
||||||
|
)
|
||||||
|
from .rolling_map import RollingLocalObstacleMapProvider, load_rolling_map_profile
|
||||||
|
from .temporal import BoundedSpatialTemporalProvider, load_temporal_motion_profile
|
||||||
|
from .threat import (
|
||||||
|
DualEvidenceReplayThreatProvider,
|
||||||
|
RecordedReplayBodyFrameResolver,
|
||||||
|
load_replay_threat_profile,
|
||||||
|
)
|
||||||
|
from .yolox_object_detector import TritonHttpInferenceBackend, load_valid_fov_mask
|
||||||
|
|
||||||
|
|
||||||
|
class ReferenceGraphRuntimeError(RuntimeError):
|
||||||
|
"""The production graph cannot be assembled from the pinned release inputs."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ReferenceGraphRuntimePaths:
|
||||||
|
graph_config: Path
|
||||||
|
baseline_profile: Path
|
||||||
|
geometry_profile: Path
|
||||||
|
temporal_motion_profile: Path
|
||||||
|
rolling_map_profile: Path
|
||||||
|
threat_profile: Path
|
||||||
|
camera_index: Path
|
||||||
|
source_pack: Path
|
||||||
|
local_surface: Path
|
||||||
|
video: Path
|
||||||
|
valid_fov_mask: Path
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class ReferenceGraphRuntime:
|
||||||
|
"""Own one graph and its persistent inference transport."""
|
||||||
|
|
||||||
|
graph: ReferencePerceptionGraphV2
|
||||||
|
inference_backend: TritonHttpInferenceBackend
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self.inference_backend.close()
|
||||||
|
|
||||||
|
def __enter__(self) -> ReferenceGraphRuntime:
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *args: object) -> None:
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
|
||||||
|
def build_reference_graph_runtime(
|
||||||
|
*,
|
||||||
|
paths: ReferenceGraphRuntimePaths,
|
||||||
|
triton_origin: str,
|
||||||
|
run_mode: GraphRunMode,
|
||||||
|
) -> ReferenceGraphRuntime:
|
||||||
|
"""Instantiate every admitted provider from explicit immutable inputs."""
|
||||||
|
|
||||||
|
config = _load_graph_config(paths.graph_config)
|
||||||
|
pinned_files = {
|
||||||
|
ProviderRole.SOURCE: paths.baseline_profile,
|
||||||
|
ProviderRole.DETECTOR: paths.baseline_profile,
|
||||||
|
ProviderRole.GEOMETRY: paths.geometry_profile,
|
||||||
|
ProviderRole.TEMPORAL: paths.temporal_motion_profile,
|
||||||
|
ProviderRole.MOTION: paths.temporal_motion_profile,
|
||||||
|
ProviderRole.ROLLING: paths.rolling_map_profile,
|
||||||
|
ProviderRole.THREAT: paths.threat_profile,
|
||||||
|
}
|
||||||
|
_validate_provider_digests(config, pinned_files)
|
||||||
|
|
||||||
|
load_m4_baseline(paths.baseline_profile)
|
||||||
|
geometry_profile = load_geometry_profile(paths.geometry_profile)
|
||||||
|
temporal_motion_profile = load_temporal_motion_profile(paths.temporal_motion_profile)
|
||||||
|
rolling_map_profile = load_rolling_map_profile(paths.rolling_map_profile)
|
||||||
|
threat_profile = load_replay_threat_profile(paths.threat_profile)
|
||||||
|
|
||||||
|
source = DecodedRecordedSource(
|
||||||
|
source=RecordedRavnoves00Source(
|
||||||
|
camera_index_path=paths.camera_index,
|
||||||
|
source_pack_path=paths.source_pack,
|
||||||
|
pacing=(
|
||||||
|
ReplayPacing.ONE_X
|
||||||
|
if run_mode is GraphRunMode.SOURCE_PACED_LATEST_WINS
|
||||||
|
else ReplayPacing.UNCAPPED
|
||||||
|
),
|
||||||
|
),
|
||||||
|
decoder=PyAvRecordedImageDecoder(paths.video),
|
||||||
|
)
|
||||||
|
backend = TritonHttpInferenceBackend(triton_origin)
|
||||||
|
try:
|
||||||
|
store = RecordedGeometryStore(
|
||||||
|
source_pack_path=paths.source_pack,
|
||||||
|
local_surface_path=paths.local_surface,
|
||||||
|
profile=geometry_profile,
|
||||||
|
)
|
||||||
|
body_frame_resolver = RecordedReplayBodyFrameResolver(
|
||||||
|
store,
|
||||||
|
profile=threat_profile.body_frame,
|
||||||
|
)
|
||||||
|
graph = ReferencePerceptionGraphV2(
|
||||||
|
config=config,
|
||||||
|
source=source,
|
||||||
|
detector=FrozenYoloxDetectorProvider(
|
||||||
|
mask=load_valid_fov_mask(paths.valid_fov_mask),
|
||||||
|
backend=backend,
|
||||||
|
),
|
||||||
|
geometry=Ravnoves00GeometryAssociationProvider(store=store),
|
||||||
|
temporal=BoundedSpatialTemporalProvider(
|
||||||
|
point_resolver=store,
|
||||||
|
profile=temporal_motion_profile,
|
||||||
|
),
|
||||||
|
motion=ClassIndependentMotionEstimator(profile=temporal_motion_profile),
|
||||||
|
rolling=RollingLocalObstacleMapProvider(
|
||||||
|
pose_resolver=store,
|
||||||
|
profile=rolling_map_profile,
|
||||||
|
),
|
||||||
|
threat=DualEvidenceReplayThreatProvider(
|
||||||
|
body_frame_resolver=body_frame_resolver,
|
||||||
|
profile=threat_profile,
|
||||||
|
),
|
||||||
|
run_mode=run_mode,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
backend.close()
|
||||||
|
raise
|
||||||
|
return ReferenceGraphRuntime(graph=graph, inference_backend=backend)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_graph_config(path: Path) -> ReferencePerceptionGraphConfigV2:
|
||||||
|
try:
|
||||||
|
document = json.loads(path.resolve(strict=True).read_text("utf-8"))
|
||||||
|
return ReferencePerceptionGraphConfigV2.from_dict(document)
|
||||||
|
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
||||||
|
raise ReferenceGraphRuntimeError("reference graph config is invalid") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_provider_digests(
|
||||||
|
config: ReferencePerceptionGraphConfigV2,
|
||||||
|
pinned_files: dict[ProviderRole, Path],
|
||||||
|
) -> None:
|
||||||
|
pins = {pin.role: pin for pin in config.providers}
|
||||||
|
if set(pins) != set(pinned_files):
|
||||||
|
raise ReferenceGraphRuntimeError("reference graph provider pins are incomplete")
|
||||||
|
for role, path in pinned_files.items():
|
||||||
|
if _sha256_file(path) != pins[role].sha256:
|
||||||
|
raise ReferenceGraphRuntimeError(f"{role.value} provider profile digest changed")
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256_file(path: Path) -> str:
|
||||||
|
try:
|
||||||
|
resolved = path.resolve(strict=True)
|
||||||
|
except OSError as exc:
|
||||||
|
raise ReferenceGraphRuntimeError("pinned graph input is missing") from exc
|
||||||
|
if resolved.is_symlink() or not resolved.is_file():
|
||||||
|
raise ReferenceGraphRuntimeError("pinned graph input must be a regular file")
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with resolved.open("rb") as handle:
|
||||||
|
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ReferenceGraphRuntime",
|
||||||
|
"ReferenceGraphRuntimeError",
|
||||||
|
"ReferenceGraphRuntimePaths",
|
||||||
|
"build_reference_graph_runtime",
|
||||||
|
]
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import tarfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
BUILDER_PATH = REPOSITORY_ROOT / "scripts/build_m47_worker_graph_shadow_artifact.py"
|
||||||
|
SPEC = importlib.util.spec_from_file_location("m47_worker_graph_shadow_builder", BUILDER_PATH)
|
||||||
|
assert SPEC is not None and SPEC.loader is not None
|
||||||
|
BUILDER = importlib.util.module_from_spec(SPEC)
|
||||||
|
SPEC.loader.exec_module(BUILDER)
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256(value: bytes) -> str:
|
||||||
|
return hashlib.sha256(value).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _regular_files(archive: tarfile.TarFile) -> dict[str, bytes]:
|
||||||
|
result: dict[str, bytes] = {}
|
||||||
|
for member in archive.getmembers():
|
||||||
|
if not member.isfile():
|
||||||
|
continue
|
||||||
|
extracted = archive.extractfile(member)
|
||||||
|
assert extracted is not None
|
||||||
|
result[member.name] = extracted.read()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def test_m47_worker_artifact_is_deterministic_and_self_contained(tmp_path: Path) -> None:
|
||||||
|
patch_id = "mission-core-m47-graph-shadow-unit-001"
|
||||||
|
revision = "d" * 40
|
||||||
|
first = BUILDER.build_artifact(patch_id, tmp_path / "first", revision=revision)
|
||||||
|
second = BUILDER.build_artifact(patch_id, tmp_path / "second", revision=revision)
|
||||||
|
first_bytes = Path(first["artifact"]).read_bytes()
|
||||||
|
second_bytes = Path(second["artifact"]).read_bytes()
|
||||||
|
|
||||||
|
assert first_bytes == second_bytes
|
||||||
|
assert first["sha256"] == _sha256(first_bytes)
|
||||||
|
assert first["transition"] == "m47-canonical-graph-shadow-v1"
|
||||||
|
with tarfile.open(first["artifact"], "r:gz") as archive:
|
||||||
|
regular = _regular_files(archive)
|
||||||
|
payload_names = sorted(
|
||||||
|
name.removeprefix("payload/")
|
||||||
|
for name in regular
|
||||||
|
if name.startswith("payload/")
|
||||||
|
)
|
||||||
|
assert payload_names == first["payload_files"]
|
||||||
|
assert regular["files.txt"].decode().splitlines() == first["payload_files"]
|
||||||
|
assert _sha256(regular[f"payload/{BUILDER.WHEEL_NAME}"]) == first["wheel_sha256"]
|
||||||
|
assert regular[f"payload/{BUILDER.RUNNER.name}"] == BUILDER.RUNNER.read_bytes()
|
||||||
|
for relative in BUILDER.CONFIG_PATHS:
|
||||||
|
assert regular[f"payload/{relative.name}"] == (
|
||||||
|
BUILDER.REPOSITORY_ROOT / relative
|
||||||
|
).read_bytes()
|
||||||
|
|
||||||
|
|
||||||
|
def test_m47_descriptor_preserves_predecessor_and_separates_readiness() -> None:
|
||||||
|
descriptor = json.loads(
|
||||||
|
BUILDER.render_descriptor(
|
||||||
|
"mission-core-m47-graph-shadow-unit-002",
|
||||||
|
"e" * 40,
|
||||||
|
wheel_sha256="f" * 64,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert descriptor["schema_version"] == "nodedc.mission-core-worker.shadow-release/v2"
|
||||||
|
assert descriptor["transition"] == "m47-canonical-graph-shadow-v1"
|
||||||
|
assert descriptor["boundary"]["external_deploy_registry"] is False
|
||||||
|
assert descriptor["container"]["public_ports"] is False
|
||||||
|
assert descriptor["inputs"]["local_surface"]["sha256"] == (
|
||||||
|
"f57eb2485b6cef47f2a97a2d9ff1aa9fd9265fe1eb69cd5852d12f39e13b8bc6"
|
||||||
|
)
|
||||||
|
assert descriptor["inputs"]["accepted_temporal_frames"]["sha256"] == (
|
||||||
|
"e83b80ea06b3462c2d04c5a1b74289a0ec401596c7150ae90f5a1b748b639c3a"
|
||||||
|
)
|
||||||
|
assert descriptor["inputs"]["accepted_threat_frames"]["sha256"] == (
|
||||||
|
"b57be1839f5915e3b80b54355b694e0bd8c9ac318d0cbe6de2bff713082cfa4e"
|
||||||
|
)
|
||||||
|
assert descriptor["predecessor"]["durable_worker"]["name"] == (
|
||||||
|
"ndc-mission-core-perception-worker"
|
||||||
|
)
|
||||||
|
assert descriptor["rollback"] == {
|
||||||
|
"durable_worker_action": "none",
|
||||||
|
"preserve_failed_evidence": True,
|
||||||
|
"remove_candidate_container": True,
|
||||||
|
"remove_unaccepted_release": True,
|
||||||
|
"triton_action": "none",
|
||||||
|
}
|
||||||
|
assert descriptor["acceptance"] == {
|
||||||
|
"run_mode": "lossless-replay",
|
||||||
|
"expected_frames": 4489,
|
||||||
|
"delivered_frames": 4489,
|
||||||
|
"failed_frames": 0,
|
||||||
|
"stale_frames": 0,
|
||||||
|
"superseded_frames": 0,
|
||||||
|
"accepted_parity": True,
|
||||||
|
"class_routing_used": False,
|
||||||
|
}
|
||||||
|
assert descriptor["readiness"]["graph"] == {
|
||||||
|
"graph_id": "reference-perception-graph/v2",
|
||||||
|
"terminal_accounting_required": True,
|
||||||
|
"actuation_allowed": False,
|
||||||
|
}
|
||||||
|
assert set(descriptor["release"]["configs"]) == {
|
||||||
|
path.name for path in BUILDER.CONFIG_PATHS
|
||||||
|
}
|
||||||
|
serialized = json.dumps(descriptor).encode()
|
||||||
|
assert b"PRIVATE KEY" not in serialized
|
||||||
|
assert b"password=" not in serialized.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_m47_runner_calls_only_the_canonical_graph_entrypoint() -> None:
|
||||||
|
runner = BUILDER.RUNNER.read_text("utf-8")
|
||||||
|
|
||||||
|
assert "k1link.perception.reference_graph_cli" in runner
|
||||||
|
assert "k1link.perception.detector_replay_cli" not in runner
|
||||||
|
assert '"--mode", $descriptor.acceptance.run_mode' in runner
|
||||||
|
assert '"--temporal-parity-frames"' in runner
|
||||||
|
assert '"--threat-parity-frames"' in runner
|
||||||
|
assert 'Write-Output "PROVIDER_READINESS=accepted"' in runner
|
||||||
|
assert 'Write-Output "GRAPH_READINESS=accepted"' in runner
|
||||||
|
assert 'Write-Output "DURABLE_WORKER_ACTION=none"' in runner
|
||||||
|
assert 'Write-Output "TRITON_ACTION=none"' in runner
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import importlib.util
|
import importlib.util
|
||||||
import json
|
import json
|
||||||
import tarfile
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
|
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
|
||||||
BUILDER_PATH = REPOSITORY_ROOT / "scripts/build_m4_worker_shadow_artifact.py"
|
BUILDER_PATH = REPOSITORY_ROOT / "scripts/build_m4_worker_shadow_artifact.py"
|
||||||
SPEC = importlib.util.spec_from_file_location("m4_worker_shadow_builder", BUILDER_PATH)
|
SPEC = importlib.util.spec_from_file_location("m4_worker_shadow_builder", BUILDER_PATH)
|
||||||
@@ -13,98 +13,12 @@ assert SPEC is not None and SPEC.loader is not None
|
|||||||
BUILDER = importlib.util.module_from_spec(SPEC)
|
BUILDER = importlib.util.module_from_spec(SPEC)
|
||||||
SPEC.loader.exec_module(BUILDER)
|
SPEC.loader.exec_module(BUILDER)
|
||||||
|
|
||||||
|
def test_historical_worker_shadow_refuses_runtime_relabeling(
|
||||||
def _sha256(value: bytes) -> str:
|
|
||||||
return hashlib.sha256(value).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def _regular_files(archive: tarfile.TarFile) -> dict[str, bytes]:
|
|
||||||
result: dict[str, bytes] = {}
|
|
||||||
for member in archive.getmembers():
|
|
||||||
if not member.isfile():
|
|
||||||
continue
|
|
||||||
extracted = archive.extractfile(member)
|
|
||||||
assert extracted is not None
|
|
||||||
result[member.name] = extracted.read()
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def test_worker_shadow_artifact_is_deterministic_narrow_and_self_contained(
|
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
patch_id = "mission-core-m4-detector-shadow-unit-001"
|
patch_id = "mission-core-m4-detector-shadow-unit-001"
|
||||||
first = BUILDER.build_artifact(patch_id, tmp_path / "first")
|
with pytest.raises(BUILDER.ArtifactBuildError, match="runtime wheel digest changed"):
|
||||||
second = BUILDER.build_artifact(patch_id, tmp_path / "second")
|
BUILDER.build_artifact(patch_id, tmp_path)
|
||||||
first_bytes = Path(first["artifact"]).read_bytes()
|
|
||||||
second_bytes = Path(second["artifact"]).read_bytes()
|
|
||||||
|
|
||||||
assert first_bytes == second_bytes
|
|
||||||
assert first["sha256"] == _sha256(first_bytes)
|
|
||||||
assert first["sha256"] == second["sha256"]
|
|
||||||
assert first["payload_files"] == list(BUILDER.PAYLOAD_FILES)
|
|
||||||
|
|
||||||
with tarfile.open(first["artifact"], "r:gz") as archive:
|
|
||||||
members = archive.getmembers()
|
|
||||||
names = [member.name for member in members]
|
|
||||||
regular = _regular_files(archive)
|
|
||||||
|
|
||||||
assert names == [
|
|
||||||
"manifest.env",
|
|
||||||
"files.txt",
|
|
||||||
"payload",
|
|
||||||
"payload/Invoke-M4DetectorShadow.ps1",
|
|
||||||
"payload/m4-recorded-realtime-baseline-v1.json",
|
|
||||||
"payload/mission-core-worker-shadow-v1.json",
|
|
||||||
f"payload/{BUILDER.WHEEL_NAME}",
|
|
||||||
]
|
|
||||||
assert (
|
|
||||||
regular["manifest.env"]
|
|
||||||
== (f"id={patch_id}\ncomponent=mission-core-worker\ntype=shadow-release\n").encode()
|
|
||||||
)
|
|
||||||
assert regular["files.txt"].decode().splitlines() == list(BUILDER.PAYLOAD_FILES)
|
|
||||||
assert regular[f"payload/{BUILDER.RUNNER_NAME}"] == BUILDER.RUNNER.read_bytes()
|
|
||||||
assert _sha256(regular[f"payload/{BUILDER.WHEEL_NAME}"]) == (BUILDER.EXPECTED_WHEEL_SHA256)
|
|
||||||
assert _sha256(regular["payload/m4-recorded-realtime-baseline-v1.json"]) == (
|
|
||||||
BUILDER.EXPECTED_BASELINE_SHA256
|
|
||||||
)
|
|
||||||
descriptor = json.loads(regular["payload/mission-core-worker-shadow-v1.json"])
|
|
||||||
assert descriptor["patch_id"] == patch_id
|
|
||||||
assert descriptor["code_revision"] == first["code_revision"]
|
|
||||||
assert descriptor["boundary"] == {
|
|
||||||
"external_deploy_registry": False,
|
|
||||||
"nodedc_platform_repository": False,
|
|
||||||
"repository": "NODEDC_MISSION_CORE",
|
|
||||||
"server_docker_runtime": False,
|
|
||||||
}
|
|
||||||
assert descriptor["dependency_tree_identity"] == (
|
|
||||||
"relative-path-tab-size-tab-file-sha256-lf/v1"
|
|
||||||
)
|
|
||||||
assert descriptor["release"]["runner"] == {
|
|
||||||
"name": BUILDER.RUNNER_NAME,
|
|
||||||
"sha256": _sha256(BUILDER.RUNNER.read_bytes()),
|
|
||||||
}
|
|
||||||
assert descriptor["release"]["wheel"] == {
|
|
||||||
"name": BUILDER.WHEEL_NAME,
|
|
||||||
"sha256": BUILDER.EXPECTED_WHEEL_SHA256,
|
|
||||||
}
|
|
||||||
assert descriptor["container"]["public_ports"] is False
|
|
||||||
assert descriptor["rollback"] == {
|
|
||||||
"durable_worker_action": "none",
|
|
||||||
"preserve_failed_evidence": True,
|
|
||||||
"remove_candidate_container": True,
|
|
||||||
"remove_unaccepted_release": True,
|
|
||||||
"triton_action": "none",
|
|
||||||
}
|
|
||||||
all_bytes = b"\n".join(regular.values())
|
|
||||||
assert b"PRIVATE KEY" not in all_bytes
|
|
||||||
assert b"password=" not in all_bytes.lower()
|
|
||||||
assert not any(
|
|
||||||
Path(name).name.startswith(".env")
|
|
||||||
or "/secrets/" in name
|
|
||||||
or "/runtime/" in name
|
|
||||||
or "/recordings/" in name
|
|
||||||
for name in names
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_worker_shadow_descriptor_pins_external_inputs_and_preserves_e15() -> None:
|
def test_worker_shadow_descriptor_pins_external_inputs_and_preserves_e15() -> None:
|
||||||
|
|||||||
@@ -320,6 +320,7 @@ def test_reference_graph_config_pins_all_roles_and_queue_bounds() -> None:
|
|||||||
providers=tuple(
|
providers=tuple(
|
||||||
ProviderPin(role, f"{role.value}-provider", "v1", "78a3dc2", "a" * 64)
|
ProviderPin(role, f"{role.value}-provider", "v1", "78a3dc2", "a" * 64)
|
||||||
for role in ProviderRole
|
for role in ProviderRole
|
||||||
|
if role is not ProviderRole.ROLLING
|
||||||
),
|
),
|
||||||
queues=tuple(
|
queues=tuple(
|
||||||
QueuePolicy(stage, 2, 80_000_000, 200_000_000)
|
QueuePolicy(stage, 2, 80_000_000, 200_000_000)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import threading
|
|||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
from dataclasses import replace
|
from dataclasses import replace
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from queue import Queue
|
||||||
from threading import Event
|
from threading import Event
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@@ -36,9 +37,12 @@ from k1link.perception.contracts import (
|
|||||||
TimestampBundle,
|
TimestampBundle,
|
||||||
)
|
)
|
||||||
from k1link.perception.graph import (
|
from k1link.perception.graph import (
|
||||||
|
GraphExecutionError,
|
||||||
|
GraphRunMode,
|
||||||
GraphRunResult,
|
GraphRunResult,
|
||||||
GraphState,
|
GraphState,
|
||||||
ReferencePerceptionGraphV1,
|
ReferencePerceptionGraphV1,
|
||||||
|
ReferencePerceptionGraphV2,
|
||||||
TerminalOutcomeType,
|
TerminalOutcomeType,
|
||||||
)
|
)
|
||||||
from k1link.perception.providers import (
|
from k1link.perception.providers import (
|
||||||
@@ -47,6 +51,7 @@ from k1link.perception.providers import (
|
|||||||
ProviderRole,
|
ProviderRole,
|
||||||
QueuePolicy,
|
QueuePolicy,
|
||||||
ReferencePerceptionGraphConfig,
|
ReferencePerceptionGraphConfig,
|
||||||
|
ReferencePerceptionGraphConfigV2,
|
||||||
SourcePacket,
|
SourcePacket,
|
||||||
)
|
)
|
||||||
from k1link.perception.recorded_source import (
|
from k1link.perception.recorded_source import (
|
||||||
@@ -230,6 +235,44 @@ class _Motion:
|
|||||||
return obstacles
|
return obstacles
|
||||||
|
|
||||||
|
|
||||||
|
class _Rolling:
|
||||||
|
provider_id = "test-rolling/v1"
|
||||||
|
|
||||||
|
def update(
|
||||||
|
self,
|
||||||
|
packet: SourcePacket,
|
||||||
|
obstacles: tuple[TemporalObstacle, ...],
|
||||||
|
) -> tuple[TemporalObstacle, ...]:
|
||||||
|
if packet.envelope.sequence == 0:
|
||||||
|
return ()
|
||||||
|
return (
|
||||||
|
TemporalObstacle(
|
||||||
|
component_id=f"rolling-{packet.envelope.sequence}",
|
||||||
|
identity_scope="ephemeral",
|
||||||
|
state=TemporalState.RETAINED,
|
||||||
|
ttl_ns=3_000_000_000,
|
||||||
|
last_hit_ns=packet.envelope.timestamps.source_ns - 100_000_000,
|
||||||
|
age_ns=100_000_000,
|
||||||
|
association_basis="registered-map-increment-retention",
|
||||||
|
history=(
|
||||||
|
HistorySample(
|
||||||
|
frame_id=f"frame-{packet.envelope.sequence - 1:06d}",
|
||||||
|
evidence_time_ns=(
|
||||||
|
packet.envelope.timestamps.source_ns - 100_000_000
|
||||||
|
),
|
||||||
|
centroid_xyz_m=(3.0, 0.5, 0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
cells=(GridCell(99, packet.envelope.sequence, 0),),
|
||||||
|
coordinate_frame="map",
|
||||||
|
last_centroid_xyz_m=(3.0, 0.5, 0.5),
|
||||||
|
motion=MotionState.UNKNOWN,
|
||||||
|
motion_confidence=0.0,
|
||||||
|
motion_reason="retained-map-increment-no-current-motion",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class _Threat:
|
class _Threat:
|
||||||
provider_id = "test-threat/v1"
|
provider_id = "test-threat/v1"
|
||||||
|
|
||||||
@@ -312,6 +355,39 @@ def _config(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _config_v2(
|
||||||
|
capacity: int = 8,
|
||||||
|
terminal_timeout_ns: int = 500_000_000,
|
||||||
|
) -> ReferencePerceptionGraphConfigV2:
|
||||||
|
ids = {
|
||||||
|
ProviderRole.SOURCE: _Source.provider_id,
|
||||||
|
ProviderRole.DETECTOR: _Detector.provider_id,
|
||||||
|
ProviderRole.GEOMETRY: _Geometry.provider_id,
|
||||||
|
ProviderRole.TEMPORAL: _Temporal.provider_id,
|
||||||
|
ProviderRole.MOTION: _Motion.provider_id,
|
||||||
|
ProviderRole.ROLLING: _Rolling.provider_id,
|
||||||
|
ProviderRole.THREAT: _Threat.provider_id,
|
||||||
|
}
|
||||||
|
return ReferencePerceptionGraphConfigV2(
|
||||||
|
graph_id="reference-perception-graph/v2",
|
||||||
|
source_profile_id=BASELINE_PROFILE_ID,
|
||||||
|
providers=tuple(
|
||||||
|
ProviderPin(role, provider_id, "v1", "test-revision", "b" * 64)
|
||||||
|
for role, provider_id in ids.items()
|
||||||
|
),
|
||||||
|
queues=tuple(
|
||||||
|
QueuePolicy(
|
||||||
|
stage,
|
||||||
|
capacity,
|
||||||
|
min(80_000_000, terminal_timeout_ns),
|
||||||
|
terminal_timeout_ns,
|
||||||
|
)
|
||||||
|
for stage in ("detector", "geometry", "temporal", "rolling", "threat")
|
||||||
|
),
|
||||||
|
authority=GraphAuthority(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class _MemoryTelemetry:
|
class _MemoryTelemetry:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.records: list[dict[str, object]] = []
|
self.records: list[dict[str, object]] = []
|
||||||
@@ -358,6 +434,28 @@ def _graph(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _graph_v2(
|
||||||
|
source: object,
|
||||||
|
*,
|
||||||
|
detector: object | None = None,
|
||||||
|
capacity: int = 8,
|
||||||
|
run_mode: GraphRunMode = GraphRunMode.LOSSLESS_REPLAY,
|
||||||
|
terminal_timeout_ns: int = 500_000_000,
|
||||||
|
) -> ReferencePerceptionGraphV2:
|
||||||
|
return ReferencePerceptionGraphV2(
|
||||||
|
config=_config_v2(capacity, terminal_timeout_ns),
|
||||||
|
source=source,
|
||||||
|
detector=detector or _Detector(),
|
||||||
|
geometry=_Geometry(),
|
||||||
|
temporal=_Temporal(),
|
||||||
|
motion=_Motion(),
|
||||||
|
rolling=_Rolling(),
|
||||||
|
threat=_Threat(),
|
||||||
|
run_mode=run_mode,
|
||||||
|
clock_ns=lambda: 10_000,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_reference_graph_closes_accounting_telemetry_and_deterministic_digest() -> None:
|
def test_reference_graph_closes_accounting_telemetry_and_deterministic_digest() -> None:
|
||||||
telemetry = _MemoryTelemetry()
|
telemetry = _MemoryTelemetry()
|
||||||
graph = _graph(_Source((_packet(0), _packet(1))), telemetry=telemetry)
|
graph = _graph(_Source((_packet(0), _packet(1))), telemetry=telemetry)
|
||||||
@@ -386,6 +484,88 @@ def test_reference_graph_closes_accounting_telemetry_and_deterministic_digest()
|
|||||||
assert {"detector", "geometry", "temporal", "threat"}.issubset(stage_ids)
|
assert {"detector", "geometry", "temporal", "threat"}.issubset(stage_ids)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reference_graph_v2_publishes_current_and_retained_occupancy() -> None:
|
||||||
|
result = _graph_v2(_Source((_packet(0), _packet(1)))).run()
|
||||||
|
|
||||||
|
assert result.graph_id == "reference-perception-graph/v2"
|
||||||
|
assert result.run_mode is GraphRunMode.LOSSLESS_REPLAY
|
||||||
|
assert [item.outcome for item in result.terminal_outcomes] == [
|
||||||
|
TerminalOutcomeType.DELIVERED,
|
||||||
|
TerminalOutcomeType.DELIVERED,
|
||||||
|
]
|
||||||
|
second = result.deliveries[1].obstacle_map
|
||||||
|
assert [item.state for item in second.occupied] == [
|
||||||
|
TemporalState.CURRENT,
|
||||||
|
TemporalState.RETAINED,
|
||||||
|
]
|
||||||
|
assert second.unknown == ()
|
||||||
|
assert set(dict(result.queue_high_watermarks)) == {
|
||||||
|
"detector",
|
||||||
|
"geometry",
|
||||||
|
"temporal",
|
||||||
|
"rolling",
|
||||||
|
"threat",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_reference_graph_v2_lossless_mode_applies_bounded_backpressure() -> None:
|
||||||
|
release = Event()
|
||||||
|
|
||||||
|
class BlockingDetector(_Detector):
|
||||||
|
def detect(self, packet: SourcePacket) -> tuple[ObjectProposal2D, ...]:
|
||||||
|
if packet.envelope.sequence == 0:
|
||||||
|
assert release.wait(2)
|
||||||
|
return super().detect(packet)
|
||||||
|
|
||||||
|
BlockingDetector.provider_id = _Detector.provider_id
|
||||||
|
graph = _graph_v2(
|
||||||
|
_Source(tuple(_packet(index) for index in range(4))),
|
||||||
|
detector=BlockingDetector(),
|
||||||
|
capacity=1,
|
||||||
|
)
|
||||||
|
holder: list[GraphRunResult] = []
|
||||||
|
runner = threading.Thread(target=lambda: holder.append(graph.run()))
|
||||||
|
runner.start()
|
||||||
|
release.set()
|
||||||
|
runner.join(3)
|
||||||
|
|
||||||
|
assert not runner.is_alive()
|
||||||
|
assert len(holder[0].terminal_outcomes) == 4
|
||||||
|
assert all(
|
||||||
|
item.outcome is TerminalOutcomeType.DELIVERED
|
||||||
|
for item in holder[0].terminal_outcomes
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reference_graph_v2_restart_requires_fresh_stateful_providers() -> None:
|
||||||
|
graph = _graph_v2(_Source((_packet(0),)))
|
||||||
|
|
||||||
|
assert graph.run().state is GraphState.STOPPED
|
||||||
|
with pytest.raises(
|
||||||
|
GraphExecutionError,
|
||||||
|
match="restart requires freshly instantiated providers",
|
||||||
|
):
|
||||||
|
graph.run()
|
||||||
|
|
||||||
|
|
||||||
|
def test_reference_graph_v2_terminal_timeout_fails_stranded_packet() -> None:
|
||||||
|
packet = _packet(0)
|
||||||
|
graph = _graph_v2(
|
||||||
|
_Source(()),
|
||||||
|
capacity=1,
|
||||||
|
terminal_timeout_ns=1,
|
||||||
|
)
|
||||||
|
queue = Queue(maxsize=1)
|
||||||
|
queue.put_nowait(packet)
|
||||||
|
|
||||||
|
graph._put_stop(queue, "detector")
|
||||||
|
|
||||||
|
outcome = graph._outcomes[0]
|
||||||
|
assert outcome.outcome is TerminalOutcomeType.FAILED
|
||||||
|
assert outcome.reason == "terminal-queue-timeout"
|
||||||
|
assert queue.qsize() == 1
|
||||||
|
|
||||||
|
|
||||||
def test_reference_graph_marks_unavailable_stale_and_provider_failure() -> None:
|
def test_reference_graph_marks_unavailable_stale_and_provider_failure() -> None:
|
||||||
class FailingDetector(_Detector):
|
class FailingDetector(_Detector):
|
||||||
def detect(self, packet: SourcePacket) -> tuple[ObjectProposal2D, ...]:
|
def detect(self, packet: SourcePacket) -> tuple[ObjectProposal2D, ...]:
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from k1link.perception.contracts import LocalObstacleMap, SourceAccounting
|
||||||
|
from k1link.perception.graph_contracts import (
|
||||||
|
REFERENCE_GRAPH_ID_V2,
|
||||||
|
DeliveredFrame,
|
||||||
|
GraphRunMode,
|
||||||
|
GraphState,
|
||||||
|
TerminalOutcome,
|
||||||
|
TerminalOutcomeType,
|
||||||
|
build_graph_run_result_v2,
|
||||||
|
)
|
||||||
|
from k1link.perception.reference_graph_parity import (
|
||||||
|
compare_reference_graph_to_accepted_ledgers,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _result():
|
||||||
|
obstacle_map = LocalObstacleMap(
|
||||||
|
source_id="RAVNOVES00",
|
||||||
|
session_id="20260720T065719Z_viewer_live",
|
||||||
|
frame_id="frame-000000",
|
||||||
|
graph_id=REFERENCE_GRAPH_ID_V2,
|
||||||
|
generated_monotonic_ns=1,
|
||||||
|
output_age_ns=0,
|
||||||
|
occupied=(),
|
||||||
|
unknown=(),
|
||||||
|
camera_uncertainty=(),
|
||||||
|
accounting=SourceAccounting(1, 1, 0, 0),
|
||||||
|
)
|
||||||
|
return build_graph_run_result_v2(
|
||||||
|
graph_id=REFERENCE_GRAPH_ID_V2,
|
||||||
|
source_profile_id="m4-ravnoves00-recorded-realtime/v1",
|
||||||
|
run_mode=GraphRunMode.LOSSLESS_REPLAY,
|
||||||
|
state=GraphState.STOPPED,
|
||||||
|
admitted_count=1,
|
||||||
|
outcomes=(
|
||||||
|
TerminalOutcome(
|
||||||
|
source_id="RAVNOVES00",
|
||||||
|
session_id="20260720T065719Z_viewer_live",
|
||||||
|
frame_id="frame-000000",
|
||||||
|
sequence=0,
|
||||||
|
outcome=TerminalOutcomeType.DELIVERED,
|
||||||
|
stage_id="threat",
|
||||||
|
reason="object-payload-delivered",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
deliveries=(DeliveredFrame(0, obstacle_map, ()),),
|
||||||
|
queue_high_watermarks=(("detector", 1),),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_ledgers(root: Path, *, threat_assessments: list[dict[str, object]]) -> tuple[Path, Path]:
|
||||||
|
temporal = root / "temporal.jsonl"
|
||||||
|
threat = root / "threat.jsonl"
|
||||||
|
temporal.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"sequence": 0,
|
||||||
|
"frame_id": "frame-000000",
|
||||||
|
"current": [],
|
||||||
|
"rolling_retained": [],
|
||||||
|
"held": [],
|
||||||
|
"expired": [],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
+ "\n",
|
||||||
|
"utf-8",
|
||||||
|
)
|
||||||
|
threat.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"sequence": 0,
|
||||||
|
"frame_id": "frame-000000",
|
||||||
|
"camera_proposals": [],
|
||||||
|
"assessments": threat_assessments,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
+ "\n",
|
||||||
|
"utf-8",
|
||||||
|
)
|
||||||
|
return temporal, threat
|
||||||
|
|
||||||
|
|
||||||
|
def test_reference_graph_parity_accepts_exact_frame_semantics(tmp_path: Path) -> None:
|
||||||
|
temporal, threat = _write_ledgers(tmp_path, threat_assessments=[])
|
||||||
|
|
||||||
|
report = compare_reference_graph_to_accepted_ledgers(
|
||||||
|
_result(),
|
||||||
|
temporal_frames_path=temporal,
|
||||||
|
threat_frames_path=threat,
|
||||||
|
expected_frames=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert report.accepted is True
|
||||||
|
assert dict(report.mismatch_counts) == {
|
||||||
|
"camera_uncertainty": 0,
|
||||||
|
"current": 0,
|
||||||
|
"expired": 0,
|
||||||
|
"held": 0,
|
||||||
|
"rolling_retained": 0,
|
||||||
|
"source_binding": 0,
|
||||||
|
"threat_assessments": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_reference_graph_parity_reports_threat_drift(tmp_path: Path) -> None:
|
||||||
|
temporal, threat = _write_ledgers(tmp_path, threat_assessments=[{"component_id": "x"}])
|
||||||
|
|
||||||
|
report = compare_reference_graph_to_accepted_ledgers(
|
||||||
|
_result(),
|
||||||
|
temporal_frames_path=temporal,
|
||||||
|
threat_frames_path=threat,
|
||||||
|
expected_frames=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert report.accepted is False
|
||||||
|
assert dict(report.mismatch_counts)["threat_assessments"] == 1
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from k1link.perception.contracts import LocalObstacleMap, SourceAccounting
|
||||||
|
from k1link.perception.graph_contracts import (
|
||||||
|
REFERENCE_GRAPH_ID_V2,
|
||||||
|
DeliveredFrame,
|
||||||
|
GraphRunMode,
|
||||||
|
GraphState,
|
||||||
|
TerminalOutcome,
|
||||||
|
TerminalOutcomeType,
|
||||||
|
build_graph_run_result_v2,
|
||||||
|
)
|
||||||
|
from k1link.perception.providers import ReferencePerceptionGraphConfigV2
|
||||||
|
from k1link.perception.reference_graph_parity import ReferenceGraphParityReport
|
||||||
|
from k1link.perception.reference_graph_result import seal_reference_graph_result
|
||||||
|
|
||||||
|
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def _parity(accepted: bool = True) -> ReferenceGraphParityReport:
|
||||||
|
return ReferenceGraphParityReport(
|
||||||
|
expected_frames=1,
|
||||||
|
compared_frames=1,
|
||||||
|
temporal_frames_sha256="a" * 64,
|
||||||
|
threat_frames_sha256="b" * 64,
|
||||||
|
mismatch_counts=(("threat_assessments", 0 if accepted else 1),),
|
||||||
|
accepted=accepted,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _result(outcome: TerminalOutcomeType = TerminalOutcomeType.DELIVERED):
|
||||||
|
terminal = TerminalOutcome(
|
||||||
|
source_id="RAVNOVES00",
|
||||||
|
session_id="20260720T065719Z_viewer_live",
|
||||||
|
frame_id="frame-000000",
|
||||||
|
sequence=0,
|
||||||
|
outcome=outcome,
|
||||||
|
stage_id="threat" if outcome is TerminalOutcomeType.DELIVERED else "detector",
|
||||||
|
reason=(
|
||||||
|
"object-payload-delivered"
|
||||||
|
if outcome is TerminalOutcomeType.DELIVERED
|
||||||
|
else "bounded-queue-latest-wins"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
deliveries = (
|
||||||
|
(
|
||||||
|
DeliveredFrame(
|
||||||
|
sequence=0,
|
||||||
|
obstacle_map=LocalObstacleMap(
|
||||||
|
source_id="RAVNOVES00",
|
||||||
|
session_id="20260720T065719Z_viewer_live",
|
||||||
|
frame_id="frame-000000",
|
||||||
|
graph_id=REFERENCE_GRAPH_ID_V2,
|
||||||
|
generated_monotonic_ns=123,
|
||||||
|
output_age_ns=45,
|
||||||
|
occupied=(),
|
||||||
|
unknown=(),
|
||||||
|
camera_uncertainty=(),
|
||||||
|
accounting=SourceAccounting(1, 1, 0, 0),
|
||||||
|
),
|
||||||
|
threats=(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if outcome is TerminalOutcomeType.DELIVERED
|
||||||
|
else ()
|
||||||
|
)
|
||||||
|
return build_graph_run_result_v2(
|
||||||
|
graph_id=REFERENCE_GRAPH_ID_V2,
|
||||||
|
source_profile_id="m4-ravnoves00-recorded-realtime/v1",
|
||||||
|
run_mode=GraphRunMode.LOSSLESS_REPLAY,
|
||||||
|
state=GraphState.STOPPED,
|
||||||
|
admitted_count=1,
|
||||||
|
outcomes=(terminal,),
|
||||||
|
deliveries=deliveries,
|
||||||
|
queue_high_watermarks=(("detector", 1), ("threat", 1)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_m47_graph_config_round_trips_and_pins_rolling_stage() -> None:
|
||||||
|
path = REPOSITORY_ROOT / "config/perception/m4-reference-graph-v2.json"
|
||||||
|
document = json.loads(path.read_text("utf-8"))
|
||||||
|
config = ReferencePerceptionGraphConfigV2.from_dict(document)
|
||||||
|
|
||||||
|
assert config.to_dict() == document
|
||||||
|
assert config.graph_id == REFERENCE_GRAPH_ID_V2
|
||||||
|
assert {provider.role.value for provider in config.providers} == {
|
||||||
|
"source",
|
||||||
|
"detector",
|
||||||
|
"geometry",
|
||||||
|
"temporal",
|
||||||
|
"motion",
|
||||||
|
"rolling",
|
||||||
|
"threat",
|
||||||
|
}
|
||||||
|
assert {queue.stage_id for queue in config.queues} == {
|
||||||
|
"detector",
|
||||||
|
"geometry",
|
||||||
|
"temporal",
|
||||||
|
"rolling",
|
||||||
|
"threat",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_reference_graph_result_is_content_addressed_and_reproducible(tmp_path: Path) -> None:
|
||||||
|
first = seal_reference_graph_result(
|
||||||
|
_result(),
|
||||||
|
output_root=tmp_path / "one",
|
||||||
|
expected_frames=1,
|
||||||
|
parity=_parity(),
|
||||||
|
)
|
||||||
|
second = seal_reference_graph_result(
|
||||||
|
_result(),
|
||||||
|
output_root=tmp_path / "two",
|
||||||
|
expected_frames=1,
|
||||||
|
parity=_parity(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert first.accepted is True
|
||||||
|
assert first.result_id == second.result_id
|
||||||
|
assert first.report["gates"] == {
|
||||||
|
"lossless_replay_mode": True,
|
||||||
|
"graph_stopped": True,
|
||||||
|
"admitted_frame_count": True,
|
||||||
|
"terminal_accounting_closed": True,
|
||||||
|
"delivery_count": True,
|
||||||
|
"delivery_payload_count": True,
|
||||||
|
"no_failed_frames": True,
|
||||||
|
"no_stale_frames": True,
|
||||||
|
"no_superseded_frames": True,
|
||||||
|
"no_rejected_frames": True,
|
||||||
|
"no_unavailable_frames": True,
|
||||||
|
"accepted_m45r_m46_parity": True,
|
||||||
|
}
|
||||||
|
assert {
|
||||||
|
path.name: path.read_bytes() for path in first.result_root.iterdir()
|
||||||
|
} == {path.name: path.read_bytes() for path in second.result_root.iterdir()}
|
||||||
|
|
||||||
|
|
||||||
|
def test_reference_graph_result_fails_closed_on_supersession(tmp_path: Path) -> None:
|
||||||
|
sealed = seal_reference_graph_result(
|
||||||
|
_result(TerminalOutcomeType.SUPERSEDED),
|
||||||
|
output_root=tmp_path,
|
||||||
|
expected_frames=1,
|
||||||
|
parity=_parity(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert sealed.accepted is False
|
||||||
|
assert sealed.report["gates"]["no_superseded_frames"] is False
|
||||||
|
assert sealed.report["gates"]["delivery_count"] is False
|
||||||
Reference in New Issue
Block a user