SPEC §3.1 defines the pin a manifest carries (conditioning.prior, and a WAI2 rendition's prior): id, sha256, determinism, and the optional form, tier and supersedes. The digest covers the whole parameter set. A set of several files is pinned through its listing, the new registered prior form wai.prior.set. A sink verifies the pin before it decodes, reports a refusal with one of an ordered set of snake_case reasons, and decodes only from the set it verified. Tier is a closed vocabulary; supersedes is informative. A pin that does not resolve makes its capability unsupported for that envelope: SPEC §4 dispatch continues at the fallback, §7.1 selection at the next rendition, and an inert envelope's error names the reasons. A rendition entry may carry id, sha256 and, on a companion, companion_of_id. SPEC §7 gains a Digests subsection, and §8 says how a MINOR revision adds an optional member. In wai-rs: the manifest types are #[non_exhaustive] with builders; a new pin module verifies pins in memory and on disk; Wai::dispatch_verified, WaiMulti::pick and primary check pins and component digests; every neural decoder reads through ParamSource; a codec-free `ffi` feature carries the C envelope surface with wai_envelope_select and wai_neural_decode_ex. wai-web wraps wai::container. New vectors: the int_mlicpp set-form twin and the pin-conformance corpus with an independent Python verifier, plus sha256.json sidecars for the vectors of capabilities that decode against a parameter set. Squashed from wai/stream-a-parameter-set-identity onto link 12. Conflicts are resolved to keep both sides; the integration commits that follow make the two streams agree. Co-Authored-By: Claude Opus 5.5 <[email protected]>
929 lines
38 KiB
Rust
929 lines
38 KiB
Rust
//! Interactive-worlds conformance behaviors (extensions/interactive-worlds).
|
|
//!
|
|
//! The load-bearing assertions are the convergence ones: same op set,
|
|
//! any arrival order, identical state hash at every checkpoint — with
|
|
//! no tolerance parameter anywhere.
|
|
|
|
#![cfg(feature = "world")]
|
|
|
|
use ed25519_dalek::SigningKey;
|
|
use wai::world::sim::{kin_mutation, pack_fx, CAP_SIM_KINEMATIC0};
|
|
use wai::world::{
|
|
mint_node_id, CausalOp, Fx, Kinematic0, LiveWorld, Replayer, SimContract, WorldContainer,
|
|
WorldError, WorldState, Xoshiro256pp, NUMERIC_FIXED64, RESOLVE_LWW,
|
|
};
|
|
|
|
fn key(seed: u8) -> SigningKey {
|
|
SigningKey::from_bytes(&[seed; 32])
|
|
}
|
|
|
|
fn contract() -> SimContract {
|
|
SimContract {
|
|
tick_rate: 60,
|
|
numeric: NUMERIC_FIXED64.into(),
|
|
rng_alg: "xoshiro256++".into(),
|
|
rng_seed: [0x42; 32],
|
|
sim: CAP_SIM_KINEMATIC0.into(),
|
|
resolve_default: RESOLVE_LWW.into(),
|
|
checkpoint_every: 100,
|
|
}
|
|
}
|
|
|
|
fn empty_container() -> WorldContainer {
|
|
let c = contract();
|
|
let graph = WorldState::<wai::world::sim::KinNode>::new().canonical_bytes(&Kinematic0);
|
|
WorldContainer {
|
|
contract_bytes: c.to_canonical_json(),
|
|
contract: c,
|
|
graph_bytes: graph,
|
|
assets: vec![],
|
|
ops: vec![],
|
|
seal: None,
|
|
}
|
|
}
|
|
|
|
fn create_op(k: &SigningKey, lamport: u64, tick: u64, vel: [i32; 3]) -> CausalOp {
|
|
let target = mint_node_id(&k.verifying_key().to_bytes(), lamport);
|
|
let payload = pack_fx(&[
|
|
Fx::ZERO, Fx::ZERO, Fx::ZERO,
|
|
Fx::from_int(vel[0]), Fx::from_int(vel[1]), Fx::from_int(vel[2]),
|
|
Fx::ZERO,
|
|
]);
|
|
CausalOp::sign(k, lamport, tick, vec![], target, kin_mutation::CREATE, payload)
|
|
}
|
|
|
|
/// A two-actor session: creates, impulses, scalar sets, a delete.
|
|
fn session_ops() -> (Vec<CausalOp>, [u8; 16], [u8; 16]) {
|
|
let a = key(1);
|
|
let b = key(2);
|
|
let n1 = mint_node_id(&a.verifying_key().to_bytes(), 1);
|
|
let n2 = mint_node_id(&b.verifying_key().to_bytes(), 1);
|
|
let ops = vec![
|
|
create_op(&a, 1, 1, [1, 0, 0]),
|
|
create_op(&b, 1, 1, [0, 1, 0]),
|
|
CausalOp::sign(&a, 2, 50, vec![], n2, kin_mutation::IMPULSE, pack_fx(&[Fx::from_int(2), Fx::ZERO, Fx::ZERO])),
|
|
CausalOp::sign(&b, 2, 50, vec![], n1, kin_mutation::SET_SCALAR, pack_fx(&[Fx::from_int(7)])),
|
|
CausalOp::sign(&a, 3, 120, vec![], n1, kin_mutation::SET_POS, pack_fx(&[Fx::ZERO, Fx::ZERO, Fx::from_int(5)])),
|
|
CausalOp::sign(&b, 3, 180, vec![], n2, kin_mutation::DELETE, vec![]),
|
|
];
|
|
(ops, n1, n2)
|
|
}
|
|
|
|
fn sealed_container(ops: Vec<CausalOp>, final_tick: u64) -> WorldContainer {
|
|
let mut c = empty_container();
|
|
let mut live = LiveWorld::open(Kinematic0, c.clone()).unwrap();
|
|
for op in &ops {
|
|
live.ingest(op.clone()).unwrap();
|
|
}
|
|
let final_hash = live.advance_to(final_tick).unwrap();
|
|
c.ops = live.canonical_ops();
|
|
c.seal(&key(9), final_tick, final_hash);
|
|
c
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn op_wire_round_trip_and_tamper_detection() {
|
|
let (ops, ..) = session_ops();
|
|
for op in &ops {
|
|
let bytes = op.to_bytes();
|
|
let (back, used) = CausalOp::from_bytes(&bytes).unwrap();
|
|
assert_eq!(used, bytes.len());
|
|
assert_eq!(&back, op);
|
|
back.verify().unwrap();
|
|
|
|
// Flip one mutation byte (or the tick for empty mutations):
|
|
// signature must fail.
|
|
let mut tampered = back.clone();
|
|
if tampered.mutation.is_empty() {
|
|
tampered.effect_tick += 1;
|
|
} else {
|
|
tampered.mutation[0] ^= 1;
|
|
}
|
|
assert!(matches!(tampered.verify(), Err(WorldError::BadSignature)));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn node_minting_is_deterministic_and_actor_scoped() {
|
|
let a = key(1).verifying_key().to_bytes();
|
|
let b = key(2).verifying_key().to_bytes();
|
|
assert_eq!(mint_node_id(&a, 1), mint_node_id(&a, 1));
|
|
assert_ne!(mint_node_id(&a, 1), mint_node_id(&a, 2));
|
|
assert_ne!(mint_node_id(&a, 1), mint_node_id(&b, 1));
|
|
}
|
|
|
|
#[test]
|
|
fn container_round_trips_and_ignores_unknown_sections() {
|
|
let c = sealed_container(session_ops().0, 200);
|
|
let bytes = c.to_bytes();
|
|
let back = WorldContainer::from_bytes(&bytes).unwrap();
|
|
assert_eq!(back.contract, c.contract);
|
|
assert_eq!(back.graph_bytes, c.graph_bytes);
|
|
assert_eq!(back.ops, c.ops);
|
|
assert_eq!(back.seal, c.seal);
|
|
|
|
// Append an unknown section kind to the table: reader must ignore it.
|
|
let mut with_unknown = Vec::new();
|
|
with_unknown.extend_from_slice(&bytes[..4]);
|
|
let n = u16::from_le_bytes(bytes[4..6].try_into().unwrap());
|
|
with_unknown.extend_from_slice(&(n + 1).to_le_bytes());
|
|
let table_end = 6 + n as usize * 9;
|
|
with_unknown.extend_from_slice(&bytes[6..table_end]);
|
|
let payloads_len = bytes.len() - table_end;
|
|
with_unknown.push(0x7f); // unknown kind
|
|
with_unknown.extend_from_slice(&(payloads_len as u32).to_le_bytes());
|
|
with_unknown.extend_from_slice(&8u32.to_le_bytes());
|
|
with_unknown.extend_from_slice(&bytes[table_end..]);
|
|
with_unknown.extend_from_slice(&[0u8; 8]);
|
|
let back2 = WorldContainer::from_bytes(&with_unknown).unwrap();
|
|
assert_eq!(back2.ops, c.ops);
|
|
}
|
|
|
|
#[test]
|
|
fn sealed_replay_is_deterministic() {
|
|
let c = sealed_container(session_ops().0, 200);
|
|
let r = Replayer::new(Kinematic0);
|
|
let cp1 = r.run_sealed(&c).unwrap();
|
|
let cp2 = r.run_sealed(&c).unwrap();
|
|
assert_eq!(cp1, cp2);
|
|
// checkpoints at 100, 200 (cadence 100, final 200)
|
|
assert_eq!(cp1.iter().map(|c| c.tick).collect::<Vec<_>>(), vec![100, 200]);
|
|
}
|
|
|
|
#[test]
|
|
fn replay_equivalence_under_arrival_order() {
|
|
let (ops, ..) = session_ops();
|
|
let sealed = sealed_container(ops.clone(), 200);
|
|
let reference = Replayer::new(Kinematic0).run_sealed(&sealed).unwrap();
|
|
|
|
// Deterministic shuffles via the registered rng — no test-order
|
|
// nondeterminism allowed even in tests.
|
|
let mut rng = Xoshiro256pp::from_seed([5u8; 32]);
|
|
for _ in 0..16 {
|
|
let mut shuffled = ops.clone();
|
|
for i in (1..shuffled.len()).rev() {
|
|
let j = (rng.next_u64() % (i as u64 + 1)) as usize;
|
|
shuffled.swap(i, j);
|
|
}
|
|
let mut live = LiveWorld::open(Kinematic0, empty_container()).unwrap();
|
|
for op in shuffled {
|
|
live.ingest(op).unwrap();
|
|
}
|
|
for cp in &reference {
|
|
assert_eq!(live.advance_to(cp.tick).unwrap(), cp.hash, "arrival order changed state");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn lww_conflict_resolves_by_canonical_order_not_arrival() {
|
|
let a = key(1);
|
|
let b = key(2);
|
|
let n1 = mint_node_id(&a.verifying_key().to_bytes(), 1);
|
|
let create = create_op(&a, 1, 1, [0, 0, 0]);
|
|
// Same tick, same node, same kind: canonical order is
|
|
// (tick, lamport, actor) — b's op has higher lamport, so b wins
|
|
// regardless of which arrives first.
|
|
let set_a = CausalOp::sign(&a, 2, 10, vec![], n1, kin_mutation::SET_SCALAR, pack_fx(&[Fx::from_int(1)]));
|
|
let set_b = CausalOp::sign(&b, 3, 10, vec![], n1, kin_mutation::SET_SCALAR, pack_fx(&[Fx::from_int(2)]));
|
|
|
|
let mut h = Vec::new();
|
|
for order in [[&create, &set_a, &set_b], [&create, &set_b, &set_a]] {
|
|
let mut live = LiveWorld::open(Kinematic0, empty_container()).unwrap();
|
|
for op in order {
|
|
live.ingest(op.clone()).unwrap();
|
|
}
|
|
h.push(live.advance_to(10).unwrap());
|
|
}
|
|
assert_eq!(h[0], h[1]);
|
|
|
|
// And the winner is b (lamport 3 > 2): replay sequentially and check.
|
|
let mut live = LiveWorld::open(Kinematic0, empty_container()).unwrap();
|
|
for op in [&create, &set_b] {
|
|
live.ingest(op.clone()).unwrap();
|
|
}
|
|
let only_b = live.advance_to(10).unwrap();
|
|
assert_eq!(h[0], only_b, "LWW winner must be the canonical-order last writer");
|
|
}
|
|
|
|
#[test]
|
|
fn sum_conflict_is_order_independent_and_both_act() {
|
|
let a = key(1);
|
|
let b = key(2);
|
|
let n1 = mint_node_id(&a.verifying_key().to_bytes(), 1);
|
|
let create = create_op(&a, 1, 1, [0, 0, 0]);
|
|
// Equal opposite impulses at the same tick: both act, net zero.
|
|
let push = CausalOp::sign(&a, 2, 10, vec![], n1, kin_mutation::IMPULSE, pack_fx(&[Fx::from_int(3), Fx::ZERO, Fx::ZERO]));
|
|
let pull = CausalOp::sign(&b, 2, 10, vec![], n1, kin_mutation::IMPULSE, pack_fx(&[Fx::from_int(-3), Fx::ZERO, Fx::ZERO]));
|
|
|
|
let mut hashes = Vec::new();
|
|
for order in [[&create, &push, &pull], [&create, &pull, &push]] {
|
|
let mut live = LiveWorld::open(Kinematic0, empty_container()).unwrap();
|
|
for op in order {
|
|
live.ingest(op.clone()).unwrap();
|
|
}
|
|
hashes.push(live.advance_to(60).unwrap());
|
|
}
|
|
assert_eq!(hashes[0], hashes[1]);
|
|
|
|
// Net zero: equals the no-impulse world.
|
|
let mut still = LiveWorld::open(Kinematic0, empty_container()).unwrap();
|
|
still.ingest(create.clone()).unwrap();
|
|
assert_eq!(hashes[0], still.advance_to(60).unwrap(), "opposite impulses must cancel");
|
|
}
|
|
|
|
#[test]
|
|
fn dep_gating_buffers_until_dep_arrives() {
|
|
let a = key(1);
|
|
let create = create_op(&a, 1, 1, [0, 0, 0]);
|
|
let n1 = mint_node_id(&a.verifying_key().to_bytes(), 1);
|
|
let dependent = CausalOp::sign(
|
|
&a, 2, 5, vec![create.id()], n1, kin_mutation::SET_SCALAR, pack_fx(&[Fx::from_int(9)]),
|
|
);
|
|
|
|
let mut live = LiveWorld::open(Kinematic0, empty_container()).unwrap();
|
|
assert_eq!(live.ingest(dependent.clone()).unwrap(), wai::world::replay::Ingest::Buffered);
|
|
assert_eq!(live.pending_len(), 1);
|
|
assert_eq!(live.ingest(create.clone()).unwrap(), wai::world::replay::Ingest::Applied);
|
|
assert_eq!(live.pending_len(), 0, "buffered op must drain once dep admitted");
|
|
|
|
// Equals the in-order session.
|
|
let mut inorder = LiveWorld::open(Kinematic0, empty_container()).unwrap();
|
|
inorder.ingest(create).unwrap();
|
|
inorder.ingest(dependent).unwrap();
|
|
assert_eq!(live.advance_to(10).unwrap(), inorder.advance_to(10).unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn past_tick_arrival_rederives() {
|
|
let a = key(1);
|
|
let create = create_op(&a, 1, 1, [1, 0, 0]);
|
|
let n1 = mint_node_id(&a.verifying_key().to_bytes(), 1);
|
|
let late = CausalOp::sign(&a, 2, 30, vec![], n1, kin_mutation::IMPULSE, pack_fx(&[Fx::from_int(1), Fx::ZERO, Fx::ZERO]));
|
|
|
|
let mut live = LiveWorld::open(Kinematic0, empty_container()).unwrap();
|
|
live.ingest(create.clone()).unwrap();
|
|
let _ = live.advance_to(100).unwrap(); // engine is already past tick 30
|
|
live.ingest(late.clone()).unwrap(); // late op for tick 30
|
|
let rederived = live.advance_to(100).unwrap();
|
|
|
|
let mut inorder = LiveWorld::open(Kinematic0, empty_container()).unwrap();
|
|
inorder.ingest(create).unwrap();
|
|
inorder.ingest(late).unwrap();
|
|
assert_eq!(rederived, inorder.advance_to(100).unwrap(), "late arrival must converge");
|
|
}
|
|
|
|
#[test]
|
|
fn receipted_checkpoint_seals_history() {
|
|
let a = key(1);
|
|
let create = create_op(&a, 1, 1, [0, 0, 0]);
|
|
let n1 = mint_node_id(&a.verifying_key().to_bytes(), 1);
|
|
let mut live = LiveWorld::open(Kinematic0, empty_container()).unwrap();
|
|
live.ingest(create).unwrap();
|
|
live.advance_to(100).unwrap();
|
|
live.seal_checkpoint(100);
|
|
|
|
let intruder = CausalOp::sign(&a, 2, 50, vec![], n1, kin_mutation::SET_SCALAR, pack_fx(&[Fx::ONE]));
|
|
assert!(matches!(
|
|
live.ingest(intruder),
|
|
Err(WorldError::SealedHistory { effect_tick: 50, sealed_tick: 100 })
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn duplicate_is_idempotent_equivocation_is_rejected() {
|
|
let a = key(1);
|
|
let create = create_op(&a, 1, 1, [0, 0, 0]);
|
|
let mut live = LiveWorld::open(Kinematic0, empty_container()).unwrap();
|
|
live.ingest(create.clone()).unwrap();
|
|
assert_eq!(live.ingest(create.clone()).unwrap(), wai::world::replay::Ingest::Duplicate);
|
|
|
|
// Same (actor, lamport), different content: equivocation.
|
|
let n1 = mint_node_id(&a.verifying_key().to_bytes(), 1);
|
|
let evil = CausalOp::sign(&a, 1, 2, vec![], n1, kin_mutation::SET_SCALAR, pack_fx(&[Fx::ONE]));
|
|
assert!(matches!(live.ingest(evil), Err(WorldError::DuplicateOp(_))));
|
|
}
|
|
|
|
#[test]
|
|
fn seal_verifies_and_tamper_fails() {
|
|
let c = sealed_container(session_ops().0, 200);
|
|
let publisher = key(9).verifying_key();
|
|
c.verify_seal(&publisher).unwrap();
|
|
|
|
// Wrong publisher.
|
|
assert!(matches!(c.verify_seal(&key(8).verifying_key()), Err(WorldError::BadSignature)));
|
|
|
|
// Tampered final hash: signature breaks; and even with a re-signed
|
|
// seal under a different key, the replayer catches the hash itself.
|
|
let mut tampered = c.clone();
|
|
if let Some(seal) = &mut tampered.seal {
|
|
seal.final_hash[0] ^= 1;
|
|
}
|
|
assert!(matches!(tampered.verify_seal(&publisher), Err(WorldError::BadSignature)));
|
|
assert!(matches!(
|
|
Replayer::new(Kinematic0).run_sealed(&tampered),
|
|
Err(WorldError::HashMismatch { .. })
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn replay_rejects_non_canonical_or_dep_incomplete_logs() {
|
|
let mut c = sealed_container(session_ops().0, 200);
|
|
c.ops.swap(0, 1);
|
|
assert!(matches!(
|
|
Replayer::new(Kinematic0).run_sealed(&c),
|
|
Err(WorldError::NotCanonicalOrder)
|
|
));
|
|
|
|
// Dep-incomplete: dependent present, dep removed.
|
|
let a = key(1);
|
|
let create = create_op(&a, 1, 1, [0, 0, 0]);
|
|
let n1 = mint_node_id(&a.verifying_key().to_bytes(), 1);
|
|
let dependent = CausalOp::sign(
|
|
&a, 2, 5, vec![create.id()], n1, kin_mutation::SET_SCALAR, pack_fx(&[Fx::ONE]),
|
|
);
|
|
let mut live = LiveWorld::open(Kinematic0, empty_container()).unwrap();
|
|
live.ingest(create).unwrap();
|
|
live.ingest(dependent.clone()).unwrap();
|
|
let final_hash = live.advance_to(10).unwrap();
|
|
let mut broken = empty_container();
|
|
broken.ops = vec![dependent]; // dep stripped
|
|
broken.seal(&key(9), 10, final_hash);
|
|
assert!(matches!(
|
|
Replayer::new(Kinematic0).run_sealed(&broken),
|
|
Err(WorldError::DepMissing(_))
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn create_with_unminted_target_is_rejected() {
|
|
let a = key(1);
|
|
let bogus_target = [0xee; 16];
|
|
let op = CausalOp::sign(
|
|
&a, 1, 1, vec![], bogus_target, kin_mutation::CREATE,
|
|
pack_fx(&[Fx::ZERO; 7]),
|
|
);
|
|
let mut live = LiveWorld::open(Kinematic0, empty_container()).unwrap();
|
|
live.ingest(op).unwrap(); // admission is fine; application fails
|
|
assert!(matches!(live.advance_to(1), Err(WorldError::BadNodeMint)));
|
|
}
|
|
|
|
#[test]
|
|
fn sim_dispatch_routes_by_contract_and_reports_inert() {
|
|
use wai::world::run_sealed_dispatch;
|
|
use wai::world::sim::{bal_mutation, BalNode, CAP_SIM_BALLISTIC0};
|
|
use wai::world::{Ballistic0, WorldState};
|
|
|
|
// A ballistic world: ball dropped from y=10, restitution ½, 300 ticks.
|
|
let mut c = empty_container();
|
|
c.contract.sim = CAP_SIM_BALLISTIC0.into();
|
|
c.contract_bytes = c.contract.to_canonical_json();
|
|
c.graph_bytes = WorldState::<BalNode>::new().canonical_bytes(&Ballistic0);
|
|
let a = key(1);
|
|
let target = mint_node_id(&a.verifying_key().to_bytes(), 1);
|
|
let half = Fx::ONE / Fx::from_int(2);
|
|
let drop = CausalOp::sign(
|
|
&a, 1, 1, vec![], target, bal_mutation::CREATE,
|
|
pack_fx(&[Fx::ZERO, Fx::from_int(10), Fx::ZERO, Fx::ZERO, Fx::ZERO, Fx::ZERO, Fx::ONE, half]),
|
|
);
|
|
let mut live = LiveWorld::open(Ballistic0, c.clone()).unwrap();
|
|
live.ingest(drop).unwrap();
|
|
let final_hash = live.advance_to(300).unwrap();
|
|
c.ops = live.canonical_ops();
|
|
c.seal(&key(9), 300, final_hash);
|
|
|
|
// Dispatch picks ballistic0 from the contract and replays to the
|
|
// same checkpoints, twice (bounces are bit-exact, not approximate).
|
|
let cp1 = run_sealed_dispatch(&c).unwrap();
|
|
let cp2 = run_sealed_dispatch(&c).unwrap();
|
|
assert_eq!(cp1, cp2);
|
|
assert_eq!(cp1.last().unwrap().hash, final_hash);
|
|
|
|
// The kinematic engine must NOT accept a ballistic log: its CREATE
|
|
// payload is 8 Fx, kinematic0's is 7 — a different registration.
|
|
assert!(Replayer::new(Kinematic0).run_sealed(&c).is_err());
|
|
|
|
// An unregistered sim capability renders the world inert, cleanly.
|
|
let mut alien = empty_container();
|
|
alien.contract.sim = "wai.world.sim.does_not_exist".into();
|
|
alien.contract_bytes = alien.contract.to_canonical_json();
|
|
alien.seal(&key(9), 1, [0u8; 32]);
|
|
assert!(matches!(
|
|
run_sealed_dispatch(&alien),
|
|
Err(WorldError::UnknownSimCapability(_))
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn ballistic_ball_settles_on_the_ground_plane() {
|
|
use wai::world::sim::{bal_mutation, BalNode, CAP_SIM_BALLISTIC0};
|
|
use wai::world::{Ballistic0, WorldState};
|
|
|
|
let mut c = empty_container();
|
|
c.contract.sim = CAP_SIM_BALLISTIC0.into();
|
|
c.contract_bytes = c.contract.to_canonical_json();
|
|
c.graph_bytes = WorldState::<BalNode>::new().canonical_bytes(&Ballistic0);
|
|
let a = key(1);
|
|
let target = mint_node_id(&a.verifying_key().to_bytes(), 1);
|
|
let drop = CausalOp::sign(
|
|
&a, 1, 1, vec![], target, bal_mutation::CREATE,
|
|
pack_fx(&[
|
|
Fx::ZERO, Fx::from_int(10), Fx::ZERO,
|
|
Fx::ZERO, Fx::ZERO, Fx::ZERO,
|
|
Fx::ONE, Fx::ONE / Fx::from_int(2),
|
|
]),
|
|
);
|
|
let mut live = LiveWorld::open(Ballistic0, c).unwrap();
|
|
live.ingest(drop).unwrap();
|
|
// After 20 simulated seconds a restitution-½ ball has long settled:
|
|
// height ≈ radius, vertical speed ≈ 0. We assert physical bounds,
|
|
// not exact bits, here — the corpus pins the exact bits.
|
|
live.advance_to(1200).unwrap();
|
|
let ops = live.canonical_ops();
|
|
assert_eq!(ops.len(), 1);
|
|
// Re-derive state to inspect: replay through a fresh engine.
|
|
let mut fresh = LiveWorld::open(Ballistic0, {
|
|
let mut c2 = empty_container();
|
|
c2.contract.sim = CAP_SIM_BALLISTIC0.into();
|
|
c2.contract_bytes = c2.contract.to_canonical_json();
|
|
c2.graph_bytes = WorldState::<BalNode>::new().canonical_bytes(&Ballistic0);
|
|
c2
|
|
})
|
|
.unwrap();
|
|
for op in ops {
|
|
fresh.ingest(op).unwrap();
|
|
}
|
|
let h1 = fresh.advance_to(1200).unwrap();
|
|
let h2 = live.advance_to(1200).unwrap();
|
|
assert_eq!(h1, h2, "settled state must be identical across engines");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// truss0 — the solver-backed registrant
|
|
// ---------------------------------------------------------------------------
|
|
|
|
mod truss {
|
|
use super::*;
|
|
use wai::world::truss::{pack_member, truss_mutation, CAP_SIM_TRUSS0};
|
|
use wai::world::{Sim, Truss0, TrussNode, WorldState, Xoshiro256pp};
|
|
|
|
fn truss_container() -> WorldContainer {
|
|
let mut c = empty_container();
|
|
c.contract.sim = CAP_SIM_TRUSS0.into();
|
|
c.contract_bytes = c.contract.to_canonical_json();
|
|
c.graph_bytes = WorldState::<TrussNode>::new().canonical_bytes(&Truss0);
|
|
c
|
|
}
|
|
|
|
fn particle(k: &SigningKey, lamport: u64, tick: u64, pos: [i32; 3], inv_mass: i32) -> CausalOp {
|
|
let target = mint_node_id(&k.verifying_key().to_bytes(), lamport);
|
|
CausalOp::sign(
|
|
k, lamport, tick, vec![], target, truss_mutation::CREATE_PARTICLE,
|
|
pack_fx(&[
|
|
Fx::from_int(pos[0]), Fx::from_int(pos[1]), Fx::from_int(pos[2]),
|
|
Fx::ZERO, Fx::ZERO, Fx::ZERO,
|
|
Fx::from_int(inv_mass),
|
|
]),
|
|
)
|
|
}
|
|
|
|
/// Two free particles 2 units apart, joined by a yield-10% member;
|
|
/// two actors apply equal-opposite impulses to the two ends at the
|
|
/// same tick (the SUM rule feeding the solver).
|
|
fn tug_of_war_ops() -> Vec<CausalOp> {
|
|
let a = key(1);
|
|
let b = key(2);
|
|
let pa = mint_node_id(&a.verifying_key().to_bytes(), 1);
|
|
let pb = mint_node_id(&b.verifying_key().to_bytes(), 1);
|
|
let tenth = Fx::ONE / Fx::from_int(10);
|
|
vec![
|
|
particle(&a, 1, 1, [-1, 5, 0], 1),
|
|
particle(&b, 1, 1, [1, 5, 0], 1),
|
|
CausalOp::sign(
|
|
&a, 2, 2, vec![], mint_node_id(&a.verifying_key().to_bytes(), 2),
|
|
truss_mutation::CREATE_MEMBER, pack_member(&pa, &pb, tenth),
|
|
),
|
|
CausalOp::sign(&a, 3, 10, vec![], pa, truss_mutation::IMPULSE, pack_fx(&[Fx::from_int(-20), Fx::ZERO, Fx::ZERO])),
|
|
CausalOp::sign(&b, 3, 10, vec![], pb, truss_mutation::IMPULSE, pack_fx(&[Fx::from_int(20), Fx::ZERO, Fx::ZERO])),
|
|
]
|
|
}
|
|
|
|
#[test]
|
|
fn member_constrains_within_yield_and_gravity_acts() {
|
|
// Anchor at (0,5,0); free mass at (2,5,0); generous yield. The
|
|
// mass swings under gravity but the solver holds it at rest
|
|
// distance from the anchor; the member survives.
|
|
let a = key(1);
|
|
let anchor = particle(&a, 1, 1, [0, 5, 0], 0);
|
|
let mass = particle(&a, 2, 1, [2, 5, 0], 1);
|
|
let anchor_id = mint_node_id(&a.verifying_key().to_bytes(), 1);
|
|
let mass_id = mint_node_id(&a.verifying_key().to_bytes(), 2);
|
|
let member = CausalOp::sign(
|
|
&a, 3, 1, vec![], mint_node_id(&a.verifying_key().to_bytes(), 3),
|
|
truss_mutation::CREATE_MEMBER,
|
|
pack_member(&anchor_id, &mass_id, Fx::ONE / Fx::from_int(2)),
|
|
);
|
|
|
|
let sim = Truss0;
|
|
let c = truss_container();
|
|
let mut world = WorldState::<TrussNode>::new();
|
|
let mut rng = Xoshiro256pp::from_seed(c.contract.rng_seed);
|
|
for op in [&anchor, &mass, &member] {
|
|
sim.apply(&mut world, op).unwrap();
|
|
}
|
|
for t in 1..=300u64 {
|
|
sim.step(&mut world, &c.contract, t, &mut rng);
|
|
}
|
|
|
|
let member_count = world.nodes.values().filter(|n| matches!(n, TrussNode::Member { .. })).count();
|
|
assert_eq!(member_count, 1, "within-yield member must survive the swing");
|
|
let (apos, mpos) = match (&world.nodes[&anchor_id], &world.nodes[&mass_id]) {
|
|
(TrussNode::Particle { pos: ap, .. }, TrussNode::Particle { pos: mp, .. }) => (*ap, *mp),
|
|
_ => panic!("particles missing"),
|
|
};
|
|
assert_eq!(apos[1], Fx::from_int(5), "anchor (inv_mass 0) must not move");
|
|
assert!(mpos[1] < Fx::from_int(5), "gravity must pull the mass below its start");
|
|
// Constraint held: distance from anchor stays within yield of rest=2.
|
|
let d = [mpos[0] - apos[0], mpos[1] - apos[1], mpos[2] - apos[2]];
|
|
let dist = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
|
|
assert!(dist > Fx::ONE && dist < Fx::from_int(3), "solver must hold the member near rest length");
|
|
}
|
|
|
|
#[test]
|
|
fn tug_of_war_fractures_the_member_deterministically() {
|
|
// Direct-state check: after the opposing impulses, the member
|
|
// is GONE — overload is an emergent solver consequence.
|
|
let sim = Truss0;
|
|
let c = truss_container();
|
|
let mut world = WorldState::<TrussNode>::new();
|
|
let mut rng = Xoshiro256pp::from_seed(c.contract.rng_seed);
|
|
let ops = tug_of_war_ops();
|
|
for t in 1..=20u64 {
|
|
for op in ops.iter().filter(|o| o.effect_tick == t) {
|
|
sim.apply(&mut world, op).unwrap();
|
|
}
|
|
sim.step(&mut world, &c.contract, t, &mut rng);
|
|
}
|
|
let member_count = world.nodes.values().filter(|n| matches!(n, TrussNode::Member { .. })).count();
|
|
assert_eq!(member_count, 0, "overloaded member must fracture");
|
|
|
|
// Convergence: both arrival orders of the conflicting impulses
|
|
// reach the identical state hash, fracture included.
|
|
let mut hashes = Vec::new();
|
|
for rev in [false, true] {
|
|
let mut order = tug_of_war_ops();
|
|
if rev {
|
|
order.swap(3, 4);
|
|
}
|
|
let mut live = LiveWorld::open(Truss0, truss_container()).unwrap();
|
|
for op in order {
|
|
live.ingest(op).unwrap();
|
|
}
|
|
hashes.push(live.advance_to(120).unwrap());
|
|
}
|
|
assert_eq!(hashes[0], hashes[1], "fracture must be arrival-order independent");
|
|
|
|
// And it differs from the unloaded world (the impulses mattered).
|
|
let mut calm = LiveWorld::open(Truss0, truss_container()).unwrap();
|
|
for op in tug_of_war_ops().into_iter().take(3) {
|
|
calm.ingest(op).unwrap();
|
|
}
|
|
assert_ne!(hashes[0], calm.advance_to(120).unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn truss_dispatches_and_replays_sealed() {
|
|
let mut c = truss_container();
|
|
let mut live = LiveWorld::open(Truss0, c.clone()).unwrap();
|
|
for op in tug_of_war_ops() {
|
|
live.ingest(op).unwrap();
|
|
}
|
|
let final_hash = live.advance_to(120).unwrap();
|
|
c.ops = live.canonical_ops();
|
|
c.seal(&key(9), 120, final_hash);
|
|
|
|
use wai::world::run_sealed_dispatch;
|
|
let cp1 = run_sealed_dispatch(&c).unwrap();
|
|
let cp2 = run_sealed_dispatch(&c).unwrap();
|
|
assert_eq!(cp1, cp2);
|
|
assert_eq!(cp1.last().unwrap().hash, final_hash);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn effect_tick_zero_is_rejected() {
|
|
let a = key(1);
|
|
let op = create_op(&a, 1, 0, [0, 0, 0]);
|
|
let mut live = LiveWorld::open(Kinematic0, empty_container()).unwrap();
|
|
assert!(matches!(live.ingest(op), Err(WorldError::EffectTickZero)));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// truss0 — the solver-backed registrant
|
|
// ---------------------------------------------------------------------------
|
|
|
|
mod truss0 {
|
|
use super::*;
|
|
use wai::world::run_sealed_dispatch;
|
|
use wai::world::sim::pack_fx;
|
|
use wai::world::truss::{pack_member, truss_mutation, Truss0, TrussNode, CAP_SIM_TRUSS0};
|
|
|
|
fn truss_container() -> WorldContainer {
|
|
let mut c = contract();
|
|
c.sim = CAP_SIM_TRUSS0.into();
|
|
WorldContainer {
|
|
contract_bytes: c.to_canonical_json(),
|
|
contract: c,
|
|
graph_bytes: WorldState::<TrussNode>::new().canonical_bytes(&Truss0),
|
|
assets: vec![],
|
|
ops: vec![],
|
|
seal: None,
|
|
}
|
|
}
|
|
|
|
fn particle(k: &SigningKey, lamport: u64, tick: u64, pos: [i32; 3], inv_mass: Fx) -> CausalOp {
|
|
let target = mint_node_id(&k.verifying_key().to_bytes(), lamport);
|
|
CausalOp::sign(
|
|
k, lamport, tick, vec![], target, truss_mutation::CREATE_PARTICLE,
|
|
pack_fx(&[
|
|
Fx::from_int(pos[0]), Fx::from_int(pos[1]), Fx::from_int(pos[2]),
|
|
Fx::ZERO, Fx::ZERO, Fx::ZERO,
|
|
inv_mass,
|
|
]),
|
|
)
|
|
}
|
|
|
|
fn member(k: &SigningKey, lamport: u64, tick: u64, a: [u8; 16], b: [u8; 16], yield_strain: Fx) -> CausalOp {
|
|
let target = mint_node_id(&k.verifying_key().to_bytes(), lamport);
|
|
CausalOp::sign(
|
|
k, lamport, tick, vec![], target, truss_mutation::CREATE_MEMBER,
|
|
pack_member(&a, &b, yield_strain),
|
|
)
|
|
}
|
|
|
|
fn pos_of(state: &WorldState<TrussNode>, id: &[u8; 16]) -> Option<[f64; 3]> {
|
|
match state.nodes.get(id) {
|
|
Some(TrussNode::Particle { pos, .. }) => {
|
|
Some([pos[0].approx_f64(), pos[1].approx_f64(), pos[2].approx_f64()])
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn pendulum_constraint_holds_through_the_swing() {
|
|
// Two solver guarantees, sampled mid-swing and at rest: the
|
|
// CONSTRAINT (the mass stays at rest-length from the anchor at
|
|
// every sampled tick, the member never fractures under a
|
|
// static load) and, thanks to the registration's 1/64-per-tick
|
|
// damping, EQUILIBRIUM — by 15 simulated seconds the mass
|
|
// hangs directly below the anchor. Trajectory bit-reproducible
|
|
// throughout.
|
|
let a = key(1);
|
|
let anchor_id = mint_node_id(&a.verifying_key().to_bytes(), 1);
|
|
let mass_id = mint_node_id(&a.verifying_key().to_bytes(), 2);
|
|
let ops = vec![
|
|
particle(&a, 1, 1, [0, 5, 0], Fx::ZERO), // anchor
|
|
particle(&a, 2, 1, [2, 5, 0], Fx::ONE), // free mass, 2 to the side
|
|
member(&a, 3, 1, anchor_id, mass_id, Fx::from_int(10)), // rest = 2, generous yield
|
|
];
|
|
let mut live = LiveWorld::open(Truss0, truss_container()).unwrap();
|
|
for op in &ops {
|
|
live.ingest(op.clone()).unwrap();
|
|
}
|
|
let mut h_final = [0u8; 32];
|
|
for t in [60u64, 240, 600, 900] {
|
|
h_final = live.advance_to(t).unwrap();
|
|
assert_eq!(live.state().nodes.len(), 3, "member must survive a swing");
|
|
let p = pos_of(live.state(), &mass_id).unwrap();
|
|
let dist = (p[0] * p[0] + (p[1] - 5.0) * (p[1] - 5.0) + p[2] * p[2]).sqrt();
|
|
assert!((dist - 2.0).abs() < 0.05, "constraint must hold at tick {t}: |r|={dist}");
|
|
assert!(p[1] <= 5.001, "the mass cannot rise above its anchor height, y={}", p[1]);
|
|
}
|
|
// Damped equilibrium: hanging straight down by t=900.
|
|
let p = pos_of(live.state(), &mass_id).unwrap();
|
|
assert!(p[0].abs() < 0.1, "damping must settle x under the anchor, got {}", p[0]);
|
|
assert!((p[1] - 3.0).abs() < 0.1, "must hang rest-length below the anchor, got y={}", p[1]);
|
|
|
|
// Determinism: a fresh engine reaches the identical hash.
|
|
let mut fresh = LiveWorld::open(Truss0, truss_container()).unwrap();
|
|
for op in &ops {
|
|
fresh.ingest(op.clone()).unwrap();
|
|
}
|
|
assert_eq!(fresh.advance_to(900).unwrap(), h_final);
|
|
}
|
|
|
|
#[test]
|
|
fn tug_of_war_fractures_the_member_and_converges() {
|
|
// The cnotes scenario, made conformance: two actors pull the same
|
|
// member in opposite directions. Both impulses act (SUM rule),
|
|
// the member sees real tension, exceeds its yield strain, snaps.
|
|
let a = key(1);
|
|
let b = key(2);
|
|
let p1 = mint_node_id(&a.verifying_key().to_bytes(), 1);
|
|
let p2 = mint_node_id(&b.verifying_key().to_bytes(), 1);
|
|
let quarter = Fx::ONE / Fx::from_int(4);
|
|
let setup = vec![
|
|
particle(&a, 1, 1, [-1, 5, 0], Fx::ONE),
|
|
particle(&b, 1, 1, [1, 5, 0], Fx::ONE),
|
|
member(&a, 2, 1, p1, p2, quarter), // rest = 2, yields at 25% strain
|
|
];
|
|
let pull_a = CausalOp::sign(&a, 3, 10, vec![], p1, truss_mutation::IMPULSE,
|
|
pack_fx(&[Fx::from_int(-20), Fx::ZERO, Fx::ZERO]));
|
|
let pull_b = CausalOp::sign(&b, 3, 10, vec![], p2, truss_mutation::IMPULSE,
|
|
pack_fx(&[Fx::from_int(20), Fx::ZERO, Fx::ZERO]));
|
|
|
|
let mut hashes = Vec::new();
|
|
for order in [[&pull_a, &pull_b], [&pull_b, &pull_a]] {
|
|
let mut live = LiveWorld::open(Truss0, truss_container()).unwrap();
|
|
for op in &setup {
|
|
live.ingest(op.clone()).unwrap();
|
|
}
|
|
for op in order {
|
|
live.ingest((*op).clone()).unwrap();
|
|
}
|
|
let h = live.advance_to(120).unwrap();
|
|
// The member is gone; the two particles remain.
|
|
assert_eq!(live.state().nodes.len(), 2, "overloaded member must fracture");
|
|
hashes.push(h);
|
|
}
|
|
assert_eq!(hashes[0], hashes[1], "fracture must be arrival-order independent");
|
|
|
|
// Control: gentler opposing pulls stay under yield — member holds.
|
|
let soft_a = CausalOp::sign(&a, 3, 10, vec![], p1, truss_mutation::IMPULSE,
|
|
pack_fx(&[Fx::from_int(-5), Fx::ZERO, Fx::ZERO]));
|
|
let soft_b = CausalOp::sign(&b, 3, 10, vec![], p2, truss_mutation::IMPULSE,
|
|
pack_fx(&[Fx::from_int(5), Fx::ZERO, Fx::ZERO]));
|
|
let mut live = LiveWorld::open(Truss0, truss_container()).unwrap();
|
|
for op in &setup {
|
|
live.ingest(op.clone()).unwrap();
|
|
}
|
|
live.ingest(soft_a).unwrap();
|
|
live.ingest(soft_b).unwrap();
|
|
live.advance_to(120).unwrap();
|
|
assert_eq!(live.state().nodes.len(), 3, "a load under yield must NOT fracture");
|
|
}
|
|
|
|
#[test]
|
|
fn truss_dispatches_and_seals() {
|
|
let a = key(1);
|
|
let anchor_id = mint_node_id(&a.verifying_key().to_bytes(), 1);
|
|
let mass_id = mint_node_id(&a.verifying_key().to_bytes(), 2);
|
|
let ops = vec![
|
|
particle(&a, 1, 1, [0, 5, 0], Fx::ZERO),
|
|
particle(&a, 2, 1, [2, 5, 0], Fx::ONE),
|
|
member(&a, 3, 1, anchor_id, mass_id, Fx::from_int(10)),
|
|
];
|
|
let mut c = truss_container();
|
|
let mut live = LiveWorld::open(Truss0, c.clone()).unwrap();
|
|
for op in &ops {
|
|
live.ingest(op.clone()).unwrap();
|
|
}
|
|
let final_hash = live.advance_to(200).unwrap();
|
|
c.ops = live.canonical_ops();
|
|
c.seal(&key(9), 200, final_hash);
|
|
|
|
// Wire round-trip, then dispatch by contract.sim.
|
|
let back = WorldContainer::from_bytes(&c.to_bytes()).unwrap();
|
|
let cps = run_sealed_dispatch(&back).unwrap();
|
|
assert_eq!(cps.last().unwrap().hash, final_hash);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Session receipts (extension §8, JWP profile)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
mod session_receipt {
|
|
use super::*;
|
|
use wai::world::{collect_checkpoints, world_hash, SessionReceipt};
|
|
|
|
#[test]
|
|
fn live_session_seals_to_replay_and_a_verifiable_receipt() {
|
|
// Play a real two-actor session, seal it to a wai.world.replay,
|
|
// and build the §8 receipt over the same ops + checkpoints.
|
|
let (ops, ..) = session_ops();
|
|
let final_tick = 200u64;
|
|
let mut c = empty_container();
|
|
let mut live = LiveWorld::open(Kinematic0, c.clone()).unwrap();
|
|
for op in &ops {
|
|
live.ingest(op.clone()).unwrap();
|
|
}
|
|
let final_hash = live.advance_to(final_tick).unwrap();
|
|
let canon = live.canonical_ops();
|
|
c.ops = canon.clone();
|
|
c.seal(&key(9), final_tick, final_hash);
|
|
|
|
// Replay artifact verifies as before.
|
|
let r = Replayer::new(Kinematic0).run_sealed(&c).unwrap();
|
|
|
|
// Receipt commits to the SAME ops + checkpoints the replay produced.
|
|
let wh = world_hash(&c.contract_bytes, &c.graph_bytes);
|
|
let (checkpoints, work) =
|
|
wai::world::collect_checkpoints_and_work(&Kinematic0, &c, final_tick).unwrap();
|
|
assert_eq!(checkpoints, r, "collect_checkpoints must match run_sealed");
|
|
assert!(work > 0, "a session that ran ticks has non-zero node-step work");
|
|
let receipt = SessionReceipt::build(
|
|
&key(9), "did:key:publisher", wh, 0, &canon, &checkpoints, 87_900_000, work, None,
|
|
);
|
|
assert!(receipt.verify());
|
|
assert!(receipt.matches_ops(&canon));
|
|
assert_eq!(receipt.work_steps, work);
|
|
|
|
// The receipt's last checkpoint hash IS the sealed final hash —
|
|
// receipt and replay agree on the world's end state.
|
|
assert_eq!(receipt.checkpoints.last().unwrap().hash, final_hash);
|
|
|
|
// A receipt over a DIFFERENT op log fails the binding.
|
|
let mut tampered = canon.clone();
|
|
tampered.pop();
|
|
assert!(!receipt.matches_ops(&tampered));
|
|
}
|
|
|
|
#[test]
|
|
fn world_hash_is_scene_identity_not_session() {
|
|
// Same scene, two different op logs → same world_hash (it's the
|
|
// shareable world identity, §7.4), but different receipts.
|
|
let c = empty_container();
|
|
let wh1 = world_hash(&c.contract_bytes, &c.graph_bytes);
|
|
let wh2 = world_hash(&c.contract_bytes, &c.graph_bytes);
|
|
assert_eq!(wh1, wh2);
|
|
// A truss scene has a different identity.
|
|
let tc = {
|
|
let mut k = contract();
|
|
k.sim = wai::world::sim::CAP_SIM_BALLISTIC0.into();
|
|
k.to_canonical_json()
|
|
};
|
|
assert_ne!(wh1, world_hash(&tc, &c.graph_bytes));
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Assets (extension §4.1) — content-addressed WAI objects, presentation
|
|
// only, EXCLUDED from the state hash.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
mod assets {
|
|
use super::*;
|
|
use wai::world::{asset_content_hash, world_hash, AssetEntry};
|
|
use wai::{Conditioning, Manifest, ModelRequirement, Wai};
|
|
|
|
/// A WAI-wrapped asset envelope (here a stand-in payload; the
|
|
/// gallery generator wraps a real .glb the same way).
|
|
fn wrap_asset(payload: &[u8]) -> Vec<u8> {
|
|
let manifest = Manifest::new("mesh", "replicate", ModelRequirement::new("wai.mesh.glb"), Conditioning::new("glb"))
|
|
.with_target(serde_json::json!({}));
|
|
Wai::new(manifest, payload.to_vec()).to_bytes().unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn assets_do_not_affect_state_or_world_hash() {
|
|
let ops = session_ops().0;
|
|
let final_tick = 200u64;
|
|
|
|
// Bare scene.
|
|
let bare = sealed_container(ops.clone(), final_tick);
|
|
let bare_hashes = Replayer::new(Kinematic0).run_sealed(&bare).unwrap();
|
|
let bare_world = world_hash(&bare.contract_bytes, &bare.graph_bytes);
|
|
|
|
// Same scene + an asset table.
|
|
let mut with_asset = sealed_container(ops, final_tick);
|
|
let env = wrap_asset(b"pretend-mesh-bytes");
|
|
with_asset.assets.push(AssetEntry {
|
|
hash: asset_content_hash(&env),
|
|
role: 0,
|
|
name: "cube.glb".into(),
|
|
});
|
|
// Re-seal: the seal binds contract/graph/oplog, NOT the asset
|
|
// table, so the same seal is valid — assets are out of band.
|
|
let with_hashes = Replayer::new(Kinematic0).run_sealed(&with_asset).unwrap();
|
|
|
|
assert_eq!(bare_hashes, with_hashes, "assets must not change any state hash");
|
|
assert_eq!(
|
|
bare_world,
|
|
world_hash(&with_asset.contract_bytes, &with_asset.graph_bytes),
|
|
"assets must not change the world identity"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn asset_table_round_trips_and_hash_binds_the_envelope() {
|
|
let env = wrap_asset(b"glb-payload-v1");
|
|
let h = asset_content_hash(&env);
|
|
|
|
let mut c = sealed_container(session_ops().0, 200);
|
|
c.assets.push(AssetEntry { hash: h, role: 2, name: "tower.glb".into() });
|
|
let back = WorldContainer::from_bytes(&c.to_bytes()).unwrap();
|
|
assert_eq!(back.assets.len(), 1);
|
|
assert_eq!(back.assets[0].hash, h);
|
|
assert_eq!(back.assets[0].name, "tower.glb");
|
|
assert_eq!(back.assets[0].role, 2);
|
|
|
|
// The hash binds the exact envelope bytes: any mutation breaks it.
|
|
let mut tampered = env.clone();
|
|
*tampered.last_mut().unwrap() ^= 1;
|
|
assert_ne!(asset_content_hash(&tampered), h);
|
|
}
|
|
}
|