From d9f171e544479df5f6abe86fee2d739e96c6f70a Mon Sep 17 00:00:00 2001 From: ruv Date: Wed, 11 Nov 2026 19:37:54 -0500 Subject: [PATCH 1/2] fix(worldmodel): a hosted PAN 2 worker stays valid after merging PR #18 (Pan2 keeps its own validation arm) Co-Authored-By: claude-flow --- Cargo.lock | 11 ----------- wifi-densepose-worldmodel/src/runtime.rs | 4 +--- 2 files changed, 1 insertion(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ba26d17..b6fddbf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1629,17 +1629,6 @@ dependencies = [ "digest", ] -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - [[package]] name = "shlex" version = "2.0.1" diff --git a/wifi-densepose-worldmodel/src/runtime.rs b/wifi-densepose-worldmodel/src/runtime.rs index 8654508..8041efa 100644 --- a/wifi-densepose-worldmodel/src/runtime.rs +++ b/wifi-densepose-worldmodel/src/runtime.rs @@ -171,9 +171,7 @@ impl ExternalModelConfig { )); } } - ExternalModelKind::LingBotWorldV2 - | ExternalModelKind::Wan22 - | ExternalModelKind::Pan2 => { + ExternalModelKind::LingBotWorldV2 | ExternalModelKind::Wan22 => { if sidecar.hosted { return Err(WorldModelError::InvalidInput( "local weight providers must not be marked hosted".into(), From 0fcdf8e26600c5d6878a8b394b80bbbfae3ab422 Mon Sep 17 00:00:00 2001 From: ruv Date: Wed, 11 Nov 2026 19:40:45 -0500 Subject: [PATCH 2/2] feat(pan2): build physical hypotheses from a verified Spaces snapshot and publish them as labelled simulations Co-Authored-By: claude-flow --- .../208-pan2-simulated-rollout-provider.md | 10 + .../examples/physical_from_snapshot.rs | 78 ++++++ worldgraph-pan2/src/lib.rs | 1 + worldgraph-pan2/src/physical_spaces.rs | 244 ++++++++++++++++++ worldgraph-pan2/tests/physical_spaces.rs | 170 ++++++++++++ 5 files changed, 503 insertions(+) create mode 100644 worldgraph-pan2/examples/physical_from_snapshot.rs create mode 100644 worldgraph-pan2/src/physical_spaces.rs create mode 100644 worldgraph-pan2/tests/physical_spaces.rs diff --git a/docs/adr/208-pan2-simulated-rollout-provider.md b/docs/adr/208-pan2-simulated-rollout-provider.md index 5ddc79f..9b327ce 100644 --- a/docs/adr/208-pan2-simulated-rollout-provider.md +++ b/docs/adr/208-pan2-simulated-rollout-provider.md @@ -106,3 +106,13 @@ The resulting clip dramatizes a recorded sequence. It is not a recording of it. - No crate or npm package was published. - The token count of compiled captions was not measured with the Qwen3.5 tokenizer. - No comparison against other providers (Wan 2.2, Oasis) was run. + +## 9. Physical hypotheses from Spaces (ADR 207 integration) + +`worldgraph_pan2::physical_spaces` connects ADR 207's structured physical hypotheses to the Spaces API: + +- `physical_scene_from_snapshot` builds a `WorldGraph`, the selected tracks and a `PhysicalContext` from a snapshot **read from the API**. The snapshot's `snapshotHash` has been recomputed, and a snapshot loaded from a plain file is refused. Bounds come from the space's `placement`, obstacles from placed `object` entities, the speed ceiling from the most conservative zone `physics.maxSpeedMps`, and tracks from `person` or `track` entities that are `anonymous` and placed. Names never leave the process. The calibration id says the geometry is writer-declared placement, not a survey. +- `hypothesis_simulation` turns an accepted forecast into the body of `POST /v1/spatial/simulations`: type `physical.hypothesis`, confidence 0, closed provenance carrying the verified snapshot hash, trajectories as `points` objects. A fixture says so in the label and in `attributes.execution`. Nested arrays are not used, because Firestore cannot store an array inside an array. +- The forecast is never an event and never changes measured state. The Spaces server stamps `simulated: true`. + +Verified on the Firestore emulator against the real Spaces handler: a Rust-built hypothesis was accepted (201), read back, replayed on retry, and left the snapshot hash unchanged. Real PAN 2 extraction is still out of scope (ADR 207). diff --git a/worldgraph-pan2/examples/physical_from_snapshot.rs b/worldgraph-pan2/examples/physical_from_snapshot.rs new file mode 100644 index 0000000..da49249 --- /dev/null +++ b/worldgraph-pan2/examples/physical_from_snapshot.rs @@ -0,0 +1,78 @@ +//! Build a physical request from an API snapshot response and write a contract-fixture forecast +//! as a Spaces simulation body. +//! +//! cargo run -p worldgraph-pan2 --example physical_from_snapshot -- \ +//! +//! +//! The forecast is a straight walk at half the zone speed limit. It is a *fixture* (it says so in the +//! record) that exercises the protocol; no PAN 2 extractor is involved. +use wifi_densepose_worldmodel::physical::*; +use worldgraph_pan2::physical_spaces::*; +use worldgraph_pan2::spaces::{EventIdentity, SpacesTarget}; +use worldgraph_pan2::spaces_scene::SpacesSnapshot; + +fn main() -> Result<(), Box> { + let args: Vec = std::env::args().skip(1).collect(); + let [snapshot_path, site, space, out] = args.as_slice() else { + return Err( + "usage: physical_from_snapshot ".into(), + ); + }; + let snapshot = SpacesSnapshot::from_json(&std::fs::read(snapshot_path)?)?; + let options = HypothesisOptions { + source_epoch: 1, + observed_at_ms: 1_000, + action: "each person keeps walking along +x".into(), + horizon_ms: 2_000, + generation_seed: 7, + default_max_speed_mps: 1.0, + hosted_processing_approved: false, + }; + let built = physical_scene_from_snapshot(&snapshot, &options)?; + let prepared = built.prepare()?; + let step = 0.5 * built.context.max_speed_mps * 2.0; // half the limit over the 2 s horizon + let response = Pan2PhysicalResponse { + schema_version: 1, + request_sha256: prepared.request_sha256()?, + model_sha256: "0".repeat(64), + artifact_sha256: "1".repeat(64), + extractor_version: "straight-walk-contract-fixture/1".into(), + frame_id: built.context.frame_id.clone(), + calibration_id: built.context.calibration_id.clone(), + execution: PhysicalExecution::Fixture, + trajectories: prepared + .request() + .tracks + .iter() + .map(|t| PhysicalTrajectory { + track_id: t.track_id, + points: vec![PhysicalPoint { + offset_ms: 2_000, + position_m: [t.position_m[0] + step, t.position_m[1], t.position_m[2]], + }], + }) + .collect(), + }; + let branch = prepared.accept_json(&serde_json::to_vec(&response)?)?; + let body = hypothesis_simulation( + &branch, + &built, + &SpacesTarget { + site_id: site.clone(), + space_id: space.clone(), + }, + &EventIdentity { + id: format!("hyp-{space}"), + message_id: format!("msg-hyp-{space}"), + event_sequence: 1, + observed_at: "2026-11-11T12:00:00Z".into(), + }, + )?; + std::fs::write(out, serde_json::to_vec_pretty(&body)?)?; + println!( + "wrote {out}: {} tracks, snapshot {}", + built.ids.len(), + built.source_hash + ); + Ok(()) +} diff --git a/worldgraph-pan2/src/lib.rs b/worldgraph-pan2/src/lib.rs index 3ec9328..fd648c4 100644 --- a/worldgraph-pan2/src/lib.rs +++ b/worldgraph-pan2/src/lib.rs @@ -39,6 +39,7 @@ pub mod compile; pub mod error; pub mod layout; pub mod media; +pub mod physical_spaces; pub mod pipeline; pub mod plan; pub mod projection; diff --git a/worldgraph-pan2/src/physical_spaces.rs b/worldgraph-pan2/src/physical_spaces.rs new file mode 100644 index 0000000..8c2f369 --- /dev/null +++ b/worldgraph-pan2/src/physical_spaces.rs @@ -0,0 +1,244 @@ +//! Cognitum Spaces <-> structured physical hypotheses (PR #18, ADR 207). +//! +//! Reads one *verified* snapshot (`GET /v1/spatial/snapshots/{spaceId}`, ADR-107) into the +//! request WorldGraph's physical validator needs, and turns a validated forecast into a labelled +//! zero-confidence Spaces simulation (`POST /v1/spatial/simulations`). +//! +//! Nothing here updates measured state: tracks come from the snapshot's anonymous entities, and +//! the forecast is only ever published as a `simulations` record, never as an event. +use crate::spaces::{validate_simulation, EventIdentity, SpacesTarget}; +use crate::spaces_scene::{SpacesRecord, SpacesSnapshot}; +use crate::Pan2Error; +use serde_json::{json, Value}; +use wifi_densepose_worldgraph::{EnuPoint, WorldGraph, WorldId, WorldNode}; +use wifi_densepose_worldmodel::physical::{ + PhysicalBounds, PhysicalContext, PhysicalForecastBranch, PreparedPhysicalScene, +}; +use wifi_densepose_worldmodel::{PrivacyDecision, RetentionPolicy}; + +/// `simulationType` of a published forecast. +pub const HYPOTHESIS_SIMULATION_TYPE: &str = "physical.hypothesis"; + +/// Choices that are not in the Spaces data. +#[derive(Debug, Clone)] +pub struct HypothesisOptions { + /// Stream epoch of the observed state. + pub source_epoch: u64, + /// Observation time, Unix milliseconds. + pub observed_at_ms: i64, + /// Bounded text action to simulate. + pub action: String, + /// Forecast horizon, 1 to 20,000 ms. + pub horizon_ms: u32, + /// Deterministic generation seed. + pub generation_seed: u64, + /// Speed ceiling used when the snapshot's zones declare none. + pub default_max_speed_mps: f64, + /// Whether sending this projection to a remote worker was approved. + pub hosted_processing_approved: bool, +} + +/// A graph, selected tracks and context built from one verified snapshot. +#[derive(Debug)] +pub struct SpacesPhysicalScene { + /// Observed graph holding one anonymous `PersonTrack` per selected entity. + pub graph: WorldGraph, + /// The selected track nodes, in snapshot order. + pub ids: Vec, + /// Context for [`PreparedPhysicalScene::from_graph`]. + pub context: PhysicalContext, + /// The verified `snapshotHash` the scene was built from. + pub source_hash: String, + /// Entities left out because they were not anonymous or had no placement. + pub excluded: usize, +} + +impl SpacesPhysicalScene { + /// Projects the selected tracks for the physical sidecar. + pub fn prepare(&self) -> Result { + PreparedPhysicalScene::from_graph(&self.graph, &self.ids, self.context.clone()) + .map_err(|e| Pan2Error::Contract(e.to_string())) + } +} + +fn triple(record: &SpacesRecord, field: &str, key: &str) -> Option<[f64; 3]> { + let values = record.other.get(field)?.get(key)?.as_array()?; + if values.len() != 3 { + return None; + } + let mut out = [0.0; 3]; + for (slot, value) in out.iter_mut().zip(values) { + *slot = value.as_f64().filter(|v| v.is_finite())?; + } + Some(out) +} + +fn bounds_of(record: &SpacesRecord) -> Option { + let (c, s) = ( + triple(record, "placement", "centerM")?, + triple(record, "placement", "sizeM")?, + ); + Some(PhysicalBounds { + min: [c[0] - s[0] / 2.0, c[1] - s[1] / 2.0, c[2] - s[2] / 2.0], + max: [c[0] + s[0] / 2.0, c[1] + s[1] / 2.0, c[2] + s[2] / 2.0], + }) +} + +/// Builds a physical scene from a snapshot whose hash the API response vouched for. +/// +/// A snapshot read from a local file is refused: its hash was never checked against the server. +pub fn physical_scene_from_snapshot( + snapshot: &SpacesSnapshot, + options: &HypothesisOptions, +) -> Result { + let source_hash = snapshot.source_hash.clone().ok_or_else(|| { + Pan2Error::Contract( + "a physical scene needs a snapshot read from the API (its snapshotHash is verified)" + .into(), + ) + })?; + let bounds = bounds_of(&snapshot.space).ok_or_else(|| { + Pan2Error::Contract("the space needs placement.centerM and placement.sizeM".into()) + })?; + // Occupied space: solid objects with a placed footprint. Zones are areas, not obstacles. + let obstacles: Vec = snapshot + .entities + .iter() + .filter(|e| e.entity_type.as_deref() == Some("object")) + .filter_map(bounds_of) + .collect(); + // The most conservative speed limit any zone declares. + let zone_limit = snapshot + .zones + .iter() + .filter_map(|z| z.other.get("physics")?.get("maxSpeedMps")?.as_f64()) + .filter(|v| v.is_finite() && *v > 0.0) + .fold(f64::INFINITY, f64::min); + let max_speed_mps = if zone_limit.is_finite() { + zone_limit + } else { + options.default_max_speed_mps + }; + + let mut people: Vec<&SpacesRecord> = snapshot + .entities + .iter() + .filter(|e| matches!(e.entity_type.as_deref(), Some("person" | "track"))) + .collect(); + people.sort_by(|a, b| a.id.cmp(&b.id)); + let mut graph = WorldGraph::new(Default::default()); + let (mut ids, mut excluded) = (Vec::new(), 0usize); + for record in people { + let anonymous = record.identity_mode.as_deref() == Some("anonymous"); + let Some(c) = triple(record, "placement", "centerM").filter(|_| anonymous) else { + excluded += 1; + continue; + }; + ids.push(graph.upsert_node(WorldNode::PersonTrack { + id: WorldId::UNASSIGNED, + track_id: ids.len() as u64 + 1, + last_position: EnuPoint { + east_m: c[0], + north_m: c[1], + up_m: c[2], + }, + reid_embedding_ref: None, + })); + } + if ids.is_empty() { + return Err(Pan2Error::Contract( + "the snapshot has no anonymous person or track entity with a placement".into(), + )); + } + let context = PhysicalContext { + source_epoch: options.source_epoch, + observed_at_ms: options.observed_at_ms, + frame_id: format!("{}:floor-local-m", snapshot.space.id), + // Placement is writer-declared geometry, not a survey: say so in the calibration id. + calibration_id: format!( + "spaces-placement:{}", + &source_hash[..source_hash.len().min(23)] + ), + action: options.action.clone(), + horizon_ms: options.horizon_ms, + generation_seed: options.generation_seed, + bounds, + obstacles, + max_speed_mps, + privacy: PrivacyDecision { + projection_id: format!("spaces:{}", snapshot.space.id), + hosted_processing_approved: options.hosted_processing_approved, + redactions_applied: excluded as u32, + retention: RetentionPolicy::Ephemeral, + }, + }; + Ok(SpacesPhysicalScene { + graph, + ids, + context, + source_hash, + excluded, + }) +} + +/// Builds the `POST /v1/spatial/simulations` body for a validated forecast. +/// +/// The record is labelled by the server (`simulated: true`, confidence 0). `execution` says whether +/// a contract fixture or a real PAN 2 extractor produced the trajectories, and the label says so too. +pub fn hypothesis_simulation( + branch: &PhysicalForecastBranch, + scene: &SpacesPhysicalScene, + target: &SpacesTarget, + identity: &EventIdentity, +) -> Result { + let response = branch.response(); + let execution = serde_json::to_value(response.execution)?; + let fixture = execution.as_str() == Some("fixture"); + let trajectories: Vec = response + .trajectories + .iter() + .map(|t| { + json!({ + "trackId": t.track_id, + // Objects, not nested arrays: Firestore cannot store an array inside an array. + "points": t.points.iter().map(|p| json!({"offsetMs": p.offset_ms, "east": p.position_m[0], "north": p.position_m[1], "up": p.position_m[2]})).collect::>(), + }) + }) + .collect(); + let model = format!( + "pan2/{}", + &response.model_sha256[..response.model_sha256.len().min(16)] + ); + let body = json!({ + "id": identity.id, + "messageId": identity.message_id, + "eventSequence": identity.event_sequence, + "observedAt": identity.observed_at, + "privacy": "P2", + "siteId": target.site_id, + "spaceId": target.space_id, + "simulationType": HYPOTHESIS_SIMULATION_TYPE, + "confidence": 0, + "attributes": { + "label": if fixture { "Contract fixture, not PAN 2 output and not an observation" } + else { "Model prediction, not an observation" }, + "execution": execution, + "extractorVersion": response.extractor_version, + "requestSha256": response.request_sha256, + "artifactSha256": response.artifact_sha256, + "modelSha256": response.model_sha256, + "frameId": response.frame_id, + "calibrationId": response.calibration_id, + "horizonMs": scene.context.horizon_ms, + "trajectories": trajectories, + }, + "provenance": { + "provider": format!("{} ({})", response.extractor_version, execution.as_str().unwrap_or("unknown")), + "model": model, + "generationSeed": scene.context.generation_seed.to_string(), + "sourceSnapshotHash": scene.source_hash, + }, + }); + validate_simulation(&body)?; + Ok(body) +} diff --git a/worldgraph-pan2/tests/physical_spaces.rs b/worldgraph-pan2/tests/physical_spaces.rs new file mode 100644 index 0000000..d28f001 --- /dev/null +++ b/worldgraph-pan2/tests/physical_spaces.rs @@ -0,0 +1,170 @@ +//! Spaces snapshot -> physical hypothesis -> labelled simulation, end to end without a GPU. +use serde_json::{json, Value}; +use wifi_densepose_worldmodel::physical::*; +use worldgraph_pan2::canonical::snapshot_hash; +use worldgraph_pan2::physical_spaces::*; +use worldgraph_pan2::spaces::{validate_simulation, EventIdentity, SpacesTarget}; +use worldgraph_pan2::spaces_scene::SpacesSnapshot; + +fn data(person_identity: &str, person_x: f64) -> Value { + json!({ + "site": {"id": "hq", "name": "HQ"}, + "space": {"id": "hall", "name": "Hall", "attributes": {}, + "placement": {"frame": "floor-local-m", "centerM": [0.0, 0.0, 1.5], "sizeM": [20.0, 10.0, 3.0]}}, + "zones": [{"id": "lane", "name": "Lane", "attributes": {}, "physics": {"maxSpeedMps": 1.5}}, + {"id": "dock", "name": "Dock", "attributes": {}, "physics": {"maxSpeedMps": 0.8}}], + "entities": [ + {"id": "p1", "name": "Walker", "entityType": "person", "identityMode": person_identity, "attributes": {}, + "placement": {"frame": "space-local-m", "centerM": [person_x, 0.0, 1.0]}}, + {"id": "rack", "name": "Rack", "entityType": "object", "attributes": {}, + "placement": {"frame": "space-local-m", "centerM": [5.0, 0.0, 1.0], "sizeM": [1.0, 4.0, 2.0]}} + ], + "events": null + }) +} + +fn api_response(data: Value) -> Vec { + let hash = snapshot_hash(&data); + serde_json::to_vec( + &json!({"object": "snapshot", "schemaVersion": "1.0", "snapshotHash": hash, + "asOf": "2026-11-11T12:00:00.000Z", "eventsStatus": "omitted", "data": data}), + ) + .unwrap() +} + +fn options() -> HypothesisOptions { + HypothesisOptions { + source_epoch: 1, + observed_at_ms: 1000, + action: "walker crosses toward the rack".into(), + horizon_ms: 2000, + generation_seed: 99, + default_max_speed_mps: 3.0, + hosted_processing_approved: false, + } +} + +fn fixture_response(scene: &PreparedPhysicalScene, to: [f64; 3]) -> Pan2PhysicalResponse { + let ctx = &scene.request().context; + Pan2PhysicalResponse { + schema_version: 1, + request_sha256: scene.request_sha256().unwrap(), + model_sha256: "a".repeat(64), + artifact_sha256: "b".repeat(64), + extractor_version: "synthetic-contract-fixture/1".into(), + frame_id: ctx.frame_id.clone(), + calibration_id: ctx.calibration_id.clone(), + execution: PhysicalExecution::Fixture, + trajectories: scene + .request() + .tracks + .iter() + .map(|t| PhysicalTrajectory { + track_id: t.track_id, + points: vec![PhysicalPoint { + offset_ms: 2000, + position_m: to, + }], + }) + .collect(), + } +} + +#[test] +fn verified_snapshot_becomes_a_validated_forecast_and_a_labelled_simulation() { + let snapshot = SpacesSnapshot::from_json(&api_response(data("anonymous", -6.0))).unwrap(); + let built = physical_scene_from_snapshot(&snapshot, &options()).unwrap(); + // The most conservative zone limit wins, obstacles come from placed objects. + assert_eq!(built.context.max_speed_mps, 0.8); + assert_eq!(built.context.obstacles.len(), 1); + assert_eq!(built.context.bounds.max, [10.0, 5.0, 3.0]); + let prepared = built.prepare().unwrap(); + let json = serde_json::to_string(prepared.request()).unwrap(); + assert!(!json.contains("Walker"), "names never reach the worker"); + // -6.0 -> -5.0 in 2 s is 0.5 m/s: allowed and clear of the rack at x 4.5..5.5. + let branch = prepared + .accept_json(&serde_json::to_vec(&fixture_response(&prepared, [-5.0, 0.0, 1.0])).unwrap()) + .unwrap(); + assert_eq!( + branch.hypothesis_graph(&built.graph).unwrap().node_count(), + built.graph.node_count() + 1 + ); + + let body = hypothesis_simulation( + &branch, + &built, + &SpacesTarget { + site_id: "hq".into(), + space_id: "hall".into(), + }, + &EventIdentity { + id: "hyp-1".into(), + message_id: "msg-hyp-1".into(), + event_sequence: 1, + observed_at: "2026-11-11T12:00:01Z".into(), + }, + ) + .unwrap(); + validate_simulation(&body).unwrap(); + assert_eq!(body["confidence"], 0); + assert_eq!(body["simulationType"], HYPOTHESIS_SIMULATION_TYPE); + assert_eq!( + body["provenance"]["sourceSnapshotHash"], + Value::String(built.source_hash.clone()) + ); + assert!(body["attributes"]["label"] + .as_str() + .unwrap() + .contains("fixture")); + fn no_nested_array(v: &Value) -> bool { + match v { + Value::Array(items) => items.iter().all(|i| !i.is_array() && no_nested_array(i)), + Value::Object(map) => map.values().all(no_nested_array), + _ => true, + } + } + assert!( + no_nested_array(&body), + "Firestore rejects an array inside an array" + ); +} + +#[test] +fn a_forecast_faster_than_the_zone_limit_is_rejected_by_the_validator() { + let snapshot = SpacesSnapshot::from_json(&api_response(data("anonymous", -6.0))).unwrap(); + let built = physical_scene_from_snapshot(&snapshot, &options()).unwrap(); + let prepared = built.prepare().unwrap(); + // 3 m in 2 s = 1.5 m/s > the 0.8 m/s dock limit. + let too_fast = fixture_response(&prepared, [-3.0, 0.0, 1.0]); + assert!(prepared + .accept_json(&serde_json::to_vec(&too_fast).unwrap()) + .is_err()); +} + +#[test] +fn a_local_snapshot_file_cannot_build_a_scene() { + let mut file = SpacesSnapshot::from_json(&api_response(data("anonymous", -6.0))).unwrap(); + file.source_hash = None; // what a snapshot loaded from a plain file looks like + assert!(physical_scene_from_snapshot(&file, &options()).is_err()); +} + +#[test] +fn a_tampered_response_is_refused_before_any_scene_exists() { + let mut value: Value = serde_json::from_slice(&api_response(data("anonymous", -6.0))).unwrap(); + value["data"]["entities"][0]["placement"]["centerM"][0] = json!(-1.0); + assert!(SpacesSnapshot::from_json(&serde_json::to_vec(&value).unwrap()).is_err()); +} + +#[test] +fn people_who_are_not_anonymous_are_left_out_and_an_empty_scene_is_an_error() { + let snapshot = SpacesSnapshot::from_json(&api_response(data("named", -6.0))).unwrap(); + // The only person is not anonymous, so nothing can be projected. + assert!(physical_scene_from_snapshot(&snapshot, &options()).is_err()); +} + +#[test] +fn a_person_inside_an_object_is_refused_at_projection() { + let snapshot = SpacesSnapshot::from_json(&api_response(data("anonymous", 5.0))).unwrap(); + let built = physical_scene_from_snapshot(&snapshot, &options()).unwrap(); + assert!(built.prepare().is_err()); +}