Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -113,10 +113,15 @@ jobs:
python: ["3.10", "3.13"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22.14
cache: npm
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}
cache: pip
- run: npm ci
- run: python -m pip install -e './python[test]'
- run: python -m pytest -m 'not integration' python/tests

Expand Down
22 changes: 16 additions & 6 deletions app/3d/igvc/IGVCEnvironmentDocument.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { canonicalFiniteNumber } from "../../simulation/kernel/SimulationHashes.js";

const ROAD_COORDINATES = `42°40'05.93"N 83°13'03.15"W -> 42°40'04.71"N 83°13'03.11"W
42°40'04.59"N 83°13'02.44"W -> 42°40'04.59"N 83°13'02.95"W
42°40'04.58"N 83°13'03.24"W -> 42°40'04.57"N 83°13'03.78"W
Expand Down Expand Up @@ -48,8 +50,8 @@ function parseCoordinate(value) {
function toMercator({ latitude, longitude }) {
const radius = 6_378_137;
return {
x: radius * longitude * Math.PI / 180,
z: radius * Math.log(Math.tan(latitude * Math.PI / 360 + Math.PI / 4)),
x: canonicalFiniteNumber(radius * longitude * Math.PI / 180),
z: canonicalFiniteNumber(radius * Math.log(Math.tan(latitude * Math.PI / 360 + Math.PI / 4))),
};
}

Expand Down Expand Up @@ -140,11 +142,14 @@ export function createBuiltInIGVCEnvironmentDocument() {
const origin = geographicRoads[0][0];
const localCoordinate = (latitude, longitude) => {
const projected = toMercator(parseCoordinate(`${latitude} ${longitude}`));
return { x: projected.x - origin.x, z: -(projected.z - origin.z) };
return {
x: canonicalFiniteNumber(projected.x - origin.x),
z: canonicalFiniteNumber(-(projected.z - origin.z)),
};
};
const roads = geographicRoads.map((road) => road.map((point) => ({
x: point.x - origin.x,
z: -(point.z - origin.z),
x: canonicalFiniteNumber(point.x - origin.x),
z: canonicalFiniteNumber(-(point.z - origin.z)),
})));
const nodes = [];
const links = new Map();
Expand All @@ -156,7 +161,12 @@ export function createBuiltInIGVCEnvironmentDocument() {
z: sum.z + point.z / selected.points.length,
}), { x: 0, z: 0 });
const id = `intersection:${intersectionIndex}`;
nodes.push({ id, x: center.x, z: center.z, kind: "intersection" });
nodes.push({
id,
x: canonicalFiniteNumber(center.x),
z: canonicalFiniteNumber(center.z),
kind: "intersection",
});
roadIndexes.forEach((roadIndex, offset) => {
const entries = links.get(roadIndex) ?? [];
entries.push({ id, endpoint: (selected.mask >> offset) & 1 });
Expand Down
5 changes: 3 additions & 2 deletions app/autonomy/CalibrationBundle.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
eulerToQuaternion,
rep103PoseToThree,
} from "./CoordinateFrames.js";
import { canonicalNumericTree } from "../simulation/kernel/SimulationHashes.js";

function text(value, fallback = "") {
const normalized = String(value ?? "").trim();
Expand Down Expand Up @@ -140,7 +141,7 @@ export function buildCalibrationBundle(manifest, options = {}) {
frameIds.add(sensor.measurementFrameId);
}

const bundle = {
const bundle = canonicalNumericTree({
kind: "cev-sim.calibration-bundle",
version: 2,
manifestId: manifest.id,
Expand All @@ -158,7 +159,7 @@ export function buildCalibrationBundle(manifest, options = {}) {
sensors,
staticTransforms,
frameIds: [...frameIds].sort(),
};
});
bundle.hash = calibrationBundleHash(bundle);
return bundle;
}
Expand Down
5 changes: 3 additions & 2 deletions app/scenarios/route/Route.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} from "./roadGraph.js";
import { hashWaypoints, normalizeWaypoints } from "./waypoints.js";
import { stableStringify } from "./hash.js";
import { canonicalNumericTree } from "../../simulation/kernel/SimulationHashes.js";

const EPSILON = 1e-9;
export const ROUTE_SCHEMA = "cev-sim.route";
Expand Down Expand Up @@ -411,7 +412,7 @@ export function verifyRoute(first, second, third) {
}

const flattened = flattenSections(sections);
const verification = {
const verification = canonicalNumericTree({
algorithm: "directed-a-star",
algorithmVersion: 1,
environmentId: document.environmentId ?? null,
Expand All @@ -422,7 +423,7 @@ export function verifyRoute(first, second, third) {
polyline: flattened.polyline,
cumulativeDistances: flattened.cumulativeDistances,
totalLength: flattened.totalLength,
};
});
const verifiedRoute = {
...route,
schema: route.schema ?? ROUTE_SCHEMA,
Expand Down
3 changes: 3 additions & 0 deletions app/scenarios/route/hash.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { canonicalFiniteNumber } from "../../simulation/kernel/SimulationHashes.js";

/**
* JSON-compatible stable serialization used by scenario route hashes.
* Object keys and Map entries are sorted; array order remains significant.
Expand All @@ -13,6 +15,7 @@ export function stableStringify(value) {
const normalize = (item) => {
if (item === null || typeof item !== "object") {
if (typeof item === "number" && !Number.isFinite(item)) return null;
if (typeof item === "number") return canonicalFiniteNumber(item);
return item;
}

Expand Down
5 changes: 4 additions & 1 deletion app/simulation/RunManifest.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { validateSensorRigFrames, validateSyncGroups } from "../simulation/Trans
import { validateScalarParameterTarget } from "../scenarios/ScenarioDocument.js";
import { sha256 } from "@noble/hashes/sha2.js";
import { bytesToHex } from "@noble/hashes/utils.js";
import { canonicalNumericTree } from "./kernel/SimulationHashes.js";

export const RUN_MANIFEST_KIND = "cev-sim.run-manifest";
export const RUN_MANIFEST_VERSION = 9;
Expand Down Expand Up @@ -834,5 +835,7 @@ export function stripRunMetadata(value) {

/** Full portable resolved-run integrity hash. Keep this distinct from simulationSemanticHash. */
export function computeResolvedRunHash(value) {
return bytesToHex(sha256(new TextEncoder().encode(canonicalStringify(stripRunMetadata(value)))));
return bytesToHex(sha256(new TextEncoder().encode(
canonicalStringify(canonicalNumericTree(stripRunMetadata(value))),
)));
}
30 changes: 26 additions & 4 deletions app/simulation/kernel/SimulationHashes.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,31 @@ import { bytesToHex } from "@noble/hashes/utils.js";

export const SIMULATION_HASH_VERSION = 1;

/**
* Decimal places used so hashed floats are stable across CPU libm implementations.
* Six places is 1e-6 (micrometer-scale for meter quantities). Independently
* resolved IGVC local frames retain ~1e-9 m of Mercator cancellation noise,
* which 12-decimal rounding does not absorb.
*/
export const CANONICAL_NUMBER_DECIMALS = 6;

export function canonicalFiniteNumber(value) {
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new TypeError("Simulation hashes require finite numbers.");
}
const rounded = Number(value.toFixed(CANONICAL_NUMBER_DECIMALS));
return Object.is(rounded, -0) ? 0 : rounded;
}

export function canonicalNumericTree(value) {
if (typeof value === "number" && Number.isFinite(value)) return canonicalFiniteNumber(value);
if (Array.isArray(value)) return value.map(canonicalNumericTree);
if (!value || typeof value !== "object") return value;
return Object.fromEntries(
Object.entries(value).map(([key, entry]) => [key, canonicalNumericTree(entry)]),
);
}

const textEncoder = new TextEncoder();
const utf8KeyCache = new Map();
const VOLATILE_KEYS = new Set([
Expand Down Expand Up @@ -55,10 +80,7 @@ export function canonicalizeSimulationValue(value, seen = new WeakSet()) {
return { $bigint: value.toString(10) };
}
if (typeof value === "number") {
if (!Number.isFinite(value)) {
throw new TypeError("Simulation hashes require finite numbers.");
}
return Object.is(value, -0) ? 0 : value;
return canonicalFiniteNumber(value);
}
if (typeof value !== "object") return value;

Expand Down
4 changes: 2 additions & 2 deletions app/simulation/lidar/LidarGeometry.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { perceptionClassId } from "../../autonomy/PerceptionLabelCatalog.js";
import { compareUtf8 } from "../world/WorldDescription.js";
import { canonicalizeSimulationValue, simulationSha256 } from "../kernel/SimulationHashes.js";
import { canonicalFiniteNumber, canonicalizeSimulationValue, simulationSha256 } from "../kernel/SimulationHashes.js";
import { allocateLidarInstanceIds, stableInstanceIdFromSource } from "./LidarInstanceIds.js";

export const LIDAR_GEOMETRY_KIND = "cev-sim.lidar-geometry";
Expand All @@ -10,7 +10,7 @@ export const INTERSECTION_SEGMENTS = 64;
function finite(value, label) {
const result = Number(value);
if (!Number.isFinite(result)) throw new TypeError(`${label} must be finite.`);
return Object.is(result, -0) ? 0 : result;
return canonicalFiniteNumber(result);
}

function vec3(value, label) {
Expand Down
4 changes: 2 additions & 2 deletions app/simulation/world/WorldDescription.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createBuiltInIGVCEnvironmentDocument } from "../../3d/igvc/IGVCEnvironmentDocument.js";
import { hashEnvironmentRoadNetwork } from "../../scenarios/route/roadGraph.js";
import { canonicalizeSimulationValue, simulationSha256 } from "../kernel/SimulationHashes.js";
import { canonicalFiniteNumber, canonicalizeSimulationValue, simulationSha256 } from "../kernel/SimulationHashes.js";

export const WORLD_DESCRIPTION_KIND = "cev-sim.world-description";
export const WORLD_DESCRIPTION_VERSION = 1;
Expand Down Expand Up @@ -28,7 +28,7 @@ export function compareUtf8(left, right) {
function finite(value, label) {
const result = Number(value);
if (!Number.isFinite(result)) throw new TypeError(`${label} must be finite.`);
return Object.is(result, -0) ? 0 : result;
return canonicalFiniteNumber(result);
}

function positive(value, label, fallback = null) {
Expand Down
6 changes: 5 additions & 1 deletion docs/headless-release.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,11 @@ npm test
the browser `SimulationEngine` adapter, direct headless session, CLI, UDS gRPC
supervisor, and Python client. Each case uses one resolved bundle and policy
action tape. Same-platform episode/trajectory hashes, tensor bytes, discrete
state, ordering, and final results must be exact. The generated
state, ordering, and final results must be exact. Finite numbers that enter
hashed world, route, calibration, lidar, and simulation-identity documents
round to 6 decimal places so independently resolved bundles match across macOS
and Linux. RFC 8785 `canonicalStringify` is unchanged. The
generated
`cev-sim.headless.parity-report` v1 also contains a cross-platform semantic
projection with these tolerances:

Expand Down
26 changes: 25 additions & 1 deletion docs/headless-simulation-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ language-neutral API authority is
- Current milestone: **PR 12 — implementation complete; external hardware acceptance pending**
- Next planned milestone: **None — the numbered headless implementation roadmap is complete**
- Default implementation/review reasoning level: **Extra High**
- Last updated: **2026-08-31** (browser play-loop canonical-state v2)
- Last updated: **2026-09-02** (portable hashed-number canonicalization)

Progress:

Expand Down Expand Up @@ -843,3 +843,27 @@ plus latest sample. Episode/semantic hash algorithms, proto field numbers, and
per-step hashing cadence are unchanged. Browser RAF notifications use a shallow
HUD snapshot and no longer `structuredClone` the resolved run bundle every
frame.

### 2026-09-02 — Portable hashed-number canonicalization

Hosted macOS/Linux semantic parity was comparing independently resolved
bundle hashes. IGVC Mercator projection, road hypotenuses, and other derived
geometry differ by 1 ULP across CPU libm implementations, so `resolvedHash`,
`simulationSemanticHash`, and `episodeHash` diverged even when discrete
tensors matched and numeric observations stayed well inside the declared
Float64/Float32 tolerances.

v1 hash algorithms, field sets, and `SIMULATION_HASH_VERSION` are unchanged.
RFC 8785 `canonicalStringify` stays byte-identical with Python `rfc8785`.
Finite numbers that enter hashed world, route, calibration, lidar, and
simulation-identity documents round to 6 decimal places first so the same
authored environment resolves to the same identity hashes on linux-x64 and
darwin-arm64. Twelve decimal places left Mercator local-frame cancellation
(~1e-9 m) in the hash; six places absorb that noise at micrometer scale.

Python unit CI installs `./python[test]` without Stable-Baselines3. Collection
of `test_integration.py` is skipped unless that extra is present; VecEnv seed
coverage remains an optional unit test behind `importorskip`. JS-backed unit
tests skip unless `node_modules` is installed; the Python version matrix runs
`npm ci` so those checks still execute.

6 changes: 5 additions & 1 deletion docs/python-headless.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@ provides the local supervisor executable; neither package is published to a
registry. See [Headless release and CI gates](headless-release.md).

Python 3.10–3.13 is supported. The base package does not install
Stable-Baselines3 or PyTorch. Generated Protobuf bindings are committed and
Stable-Baselines3 or PyTorch. Unit tests (`pytest -m 'not integration'`)
collect without the `sb3` extra; Gymnasium/SB3 integration tests require
`./python[sb3,test]`. JS-backed unit tests (`test_bundle.py` envelope/rfc8785
checks and the session fixture) skip unless `node_modules` is present.
Generated Protobuf bindings are committed and
must be regenerated, never edited, after an additive protocol change:

```bash
Expand Down
6 changes: 5 additions & 1 deletion docs/run-manifests.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,11 @@ artifact paths, runner labels, and baseline comparisons never enter

Cross-platform parity reports record the already-resolved bundle hashes and
backend/profile identities, then compare a semantic projection under the
declared Float64/Float32/CPU-LiDAR tolerances. They do not re-resolve or mutate
declared Float64/Float32/CPU-LiDAR tolerances. Finite numbers that enter
hashed world, route, calibration, lidar, and simulation-identity documents
round to 6 decimal places so independently resolved worlds stay portable
across CPU libm implementations. RFC 8785 `canonicalStringify` is unchanged.
They do not re-resolve or mutate
the bundle. Same-platform paths still require exact episode/trajectory hashes
and tensor bytes. See [Headless release and CI gates](headless-release.md).

Expand Down
14 changes: 14 additions & 0 deletions python/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import importlib.util
import json
import subprocess
from pathlib import Path
Expand All @@ -11,13 +12,26 @@
CLI_PATH = REPOSITORY_ROOT / "bin" / "cev-sim.js"


def javascript_workspace_available() -> bool:
return (REPOSITORY_ROOT / "node_modules").is_dir()


def pytest_ignore_collect(collection_path: Path, config: pytest.Config) -> bool:
del config
if collection_path.name != "test_integration.py":
return False
return importlib.util.find_spec("stable_baselines3") is None


@pytest.fixture(scope="session")
def repository_root() -> Path:
return REPOSITORY_ROOT


@pytest.fixture(scope="session")
def headless_fixture(tmp_path_factory: pytest.TempPathFactory) -> dict[str, Any]:
if not javascript_workspace_available():
pytest.skip("JavaScript workspace dependencies are not installed")
root = tmp_path_factory.mktemp("python-headless-fixture")
subprocess.run(
[
Expand Down
2 changes: 2 additions & 0 deletions python/tests/test_bundle.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@


def test_rfc8785_bytes_match_javascript_canonical_stringify(repository_root: Path) -> None:
if not (repository_root / "node_modules").is_dir():
pytest.skip("JavaScript workspace dependencies are not installed")
value = {
"z": -0.0,
"unicode": {"😀": "astral", "é": "accent", "a": "ascii"},
Expand Down
6 changes: 5 additions & 1 deletion python/tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@
)
from cev_sim.env import CevSimEnv
from cev_sim.headless.v1 import headless_pb2 as pb
from cev_sim.sb3 import CevSimVecEnv


def capabilities() -> pb.GetCapabilitiesResponse:
Expand Down Expand Up @@ -157,6 +156,11 @@ def test_unseeded_seed_streams_are_reproducible() -> None:
assert [env._reset_seed(None) for _ in range(3)] == first
assert env._reset_seed(2**64 - 1) == 2**64 - 1


def test_unseeded_vecenv_seed_streams_are_reproducible() -> None:
pytest.importorskip("stable_baselines3")
from cev_sim.sb3 import CevSimVecEnv

vector = object.__new__(CevSimVecEnv)
vector._rngs = [np.random.default_rng(7), np.random.default_rng(8)]
sequence = [vector._next_seed(0), vector._next_seed(1)]
Expand Down
13 changes: 12 additions & 1 deletion tests/simulation-hashes.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@ import test from "node:test";

import {
canonicalEpisodeIdentity,
canonicalFiniteNumber,
canonicalSimulationStringify,
computeEpisodeHash,
computeSimulationSemanticHash,
simulationSemanticProjection,
simulationSha256,
TrajectoryHasher,
} from "../app/simulation/kernel/SimulationHashes.js";
import { createDefaultRunManifest } from "../app/simulation/RunManifest.js";
import { computeResolvedRunHash, createDefaultRunManifest } from "../app/simulation/RunManifest.js";

function resolved(overrides = {}) {
return {
Expand Down Expand Up @@ -124,3 +125,13 @@ test("canonicalSimulationStringify golden string stays stable for mixed keys", (
assert.equal(canonical, '{"a":{"m":1,"😀":2},"nested":[{"a":2,"b":1}],"z":2}');
assert.equal(simulationSha256(value), "85f52aca8ef9bdeb38e685ace34fc7a3955d8cd3b0afd0e542a7a330b01d3172");
});

test("canonical numbers absorb sub-micrometer float noise across hashers", () => {
const value = 42.123456789012;
const perturbed = value + 4e-9;
assert.notEqual(value, perturbed);
assert.equal(canonicalFiniteNumber(value), canonicalFiniteNumber(perturbed));
assert.equal(simulationSha256({ x: value }), simulationSha256({ x: perturbed }));
assert.equal(computeResolvedRunHash({ x: value }), computeResolvedRunHash({ x: perturbed }));
assert.equal(canonicalSimulationStringify({ x: Math.PI }), '{"x":3.141593}');
});
Loading