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
327 changes: 327 additions & 0 deletions crates/rmg-core/tests/engine_motion_negative_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,327 @@
#![allow(missing_docs)]
//! Negative/edge-case tests for the motion rule.
//!
//! These tests document behavior when payloads contain non-finite values
//! (NaN/Infinity) and when payload length is invalid. The runtime does not
//! sanitize non-finite inputs; NaN propagates and Infinity is preserved. An
//! invalid payload size results in `ApplyResult::NoMatch` at the apply boundary.
use bytes::Bytes;
use rmg_core::{
decode_motion_payload, encode_motion_payload, make_node_id, make_type_id, ApplyResult, Engine,
GraphStore, NodeRecord, MOTION_RULE_NAME,
};

fn run_motion_once(pos: [f32; 3], vel: [f32; 3]) -> ([f32; 3], [f32; 3]) {
let ent = make_node_id("case");
let ty = make_type_id("entity");
let mut store = GraphStore::default();
store.insert_node(
ent,
NodeRecord {
ty,
payload: Some(encode_motion_payload(pos, vel)),
},
);
let mut engine = Engine::new(store, ent);
engine
.register_rule(rmg_core::motion_rule())
.expect("register motion rule");
let tx = engine.begin();
let _ = engine.apply(tx, MOTION_RULE_NAME, &ent).expect("apply");
engine.commit(tx).expect("commit");
let node = engine.node(&ent).expect("node exists");
decode_motion_payload(node.payload.as_ref().expect("payload")).expect("decode")
}

#[test]
fn motion_nan_propagates_and_rule_applies() {
let ent = make_node_id("nan-case");
let ty = make_type_id("entity");
let pos = [f32::NAN, 0.0, 1.0];
let vel = [0.0, f32::NAN, 2.0];

let mut store = GraphStore::default();
store.insert_node(
ent,
NodeRecord {
ty,
payload: Some(encode_motion_payload(pos, vel)),
},
);

let mut engine = Engine::new(store, ent);
engine
.register_rule(rmg_core::motion_rule())
.expect("register motion rule");

let tx = engine.begin();
let res = engine.apply(tx, MOTION_RULE_NAME, &ent).expect("apply");
assert!(matches!(res, ApplyResult::Applied));
engine.commit(tx).expect("commit");

let node = engine.node(&ent).expect("node exists");
let (new_pos, new_vel) =
decode_motion_payload(node.payload.as_ref().expect("payload")).expect("decode");

// NaN arithmetic propagates; check using is_nan rather than bitwise.
assert!(new_pos[0].is_nan(), "pos.x should be NaN after update");
assert!(new_pos[1].is_nan(), "pos.y should be NaN after update");
assert_eq!(new_pos[2].to_bits(), (1.0f32 + 2.0f32).to_bits());

// Velocity preserved; NaN stays NaN; finite components equal bitwise.
assert!(new_vel[1].is_nan(), "vel.y should remain NaN");
assert_eq!(new_vel[0].to_bits(), 0.0f32.to_bits());
assert_eq!(new_vel[2].to_bits(), 2.0f32.to_bits());
}

#[test]
fn motion_infinity_preserves_infinite_values() {
let ent = make_node_id("inf-case");
let ty = make_type_id("entity");
let pos = [f32::INFINITY, 1.0, f32::NEG_INFINITY];
let vel = [1.0, 2.0, 3.0];

let mut store = GraphStore::default();
store.insert_node(
ent,
NodeRecord {
ty,
payload: Some(encode_motion_payload(pos, vel)),
},
);

let mut engine = Engine::new(store, ent);
engine
.register_rule(rmg_core::motion_rule())
.expect("register motion rule");

let tx = engine.begin();
let res = engine.apply(tx, MOTION_RULE_NAME, &ent).expect("apply");
assert!(matches!(res, ApplyResult::Applied));
engine.commit(tx).expect("commit");

let node = engine.node(&ent).expect("node exists");
let (new_pos, new_vel) =
decode_motion_payload(node.payload.as_ref().expect("payload")).expect("decode");

assert!(new_pos[0].is_infinite() && new_pos[0].is_sign_positive());
assert_eq!(new_pos[1].to_bits(), 3.0f32.to_bits());
assert!(new_pos[2].is_infinite() && new_pos[2].is_sign_negative());

for i in 0..3 {
assert_eq!(new_vel[i].to_bits(), vel[i].to_bits());
}
}

#[test]
fn motion_invalid_payload_size_returns_nomatch() {
let ent = make_node_id("bad-payload-size");
let ty = make_type_id("entity");
let mut store = GraphStore::default();
store.insert_node(
ent,
NodeRecord {
ty,
payload: Some(Bytes::from(vec![0u8; 10])),
},
);
let mut engine = Engine::new(store, ent);
engine
.register_rule(rmg_core::motion_rule())
.expect("register motion rule");
let tx = engine.begin();
let res = engine.apply(tx, MOTION_RULE_NAME, &ent).expect("apply");
assert!(matches!(res, ApplyResult::NoMatch));
}

#[test]
fn motion_all_position_components_nan_stay_nan() {
let (new_pos, new_vel) = run_motion_once([f32::NAN, f32::NAN, f32::NAN], [0.0, 0.0, 0.0]);
assert!(new_pos[0].is_nan());
assert!(new_pos[1].is_nan());
assert!(new_pos[2].is_nan());
// Velocity preserved
assert_eq!(new_vel, [0.0, 0.0, 0.0]);
}

#[test]
fn motion_all_velocity_components_nan_propagate_to_position_nan() {
let (new_pos, new_vel) = run_motion_once([1.0, 2.0, 3.0], [f32::NAN, f32::NAN, f32::NAN]);
assert!(new_pos[0].is_nan());
assert!(new_pos[1].is_nan());
assert!(new_pos[2].is_nan());
assert!(new_vel[0].is_nan());
assert!(new_vel[1].is_nan());
assert!(new_vel[2].is_nan());
}

#[test]
fn motion_infinity_plus_infinity_remains_infinite() {
let (new_pos, new_vel) = run_motion_once(
[f32::INFINITY, f32::NEG_INFINITY, 0.0],
[f32::INFINITY, f32::NEG_INFINITY, 0.0],
);
assert!(new_pos[0].is_infinite() && new_pos[0].is_sign_positive());
assert!(new_pos[1].is_infinite() && new_pos[1].is_sign_negative());
assert_eq!(new_pos[2].to_bits(), 0.0f32.to_bits());
for i in 0..3 {
assert_eq!(
new_vel[i].to_bits(),
[f32::INFINITY, f32::NEG_INFINITY, 0.0][i].to_bits()
);
}
}

#[test]
fn motion_infinity_minus_infinity_results_nan() {
// +inf + (-inf) → NaN, and -inf + (+inf) → NaN
let (new_pos, _) = run_motion_once(
[f32::INFINITY, f32::NEG_INFINITY, 0.0],
[f32::NEG_INFINITY, f32::INFINITY, 0.0],
);
assert!(new_pos[0].is_nan());
assert!(new_pos[1].is_nan());
}

#[test]
fn motion_mixed_nan_and_infinity_behaves_as_expected() {
// NaN dominates arithmetic; Infinity preserves sign where finite partner exists;
// Infinity + (-Infinity) becomes NaN per IEEE-754.
let (new_pos, new_vel) = run_motion_once(
[f32::NAN, f32::INFINITY, 1.0],
[2.0, f32::NEG_INFINITY, f32::NAN],
);
assert!(new_pos[0].is_nan());
assert!(new_pos[2].is_nan());
assert!(new_pos[1].is_nan());
assert_eq!(new_vel[0].to_bits(), 2.0f32.to_bits());
assert!(new_vel[1].is_infinite() && new_vel[1].is_sign_negative());
assert!(new_vel[2].is_nan());
}

#[test]
fn motion_signed_zero_preservation_against_expected_math() {
// Compare to direct arithmetic to avoid making assumptions about zero sign rules.
let pos = [0.0f32, -0.0, 0.0];
let vel = [-0.0f32, 0.0, -0.0];
let (new_pos, new_vel) = run_motion_once(pos, vel);
for i in 0..3 {
assert_eq!(new_pos[i].to_bits(), (pos[i] + vel[i]).to_bits());
assert_eq!(new_vel[i].to_bits(), vel[i].to_bits());
}
}

#[test]
fn motion_subnormal_and_extreme_values_follow_ieee_math() {
let sub = f32::from_bits(1); // smallest positive subnormal
let pos = [f32::MAX, -f32::MAX, sub];
let vel = [sub, sub, sub];
let (new_pos, new_vel) = run_motion_once(pos, vel);
for i in 0..3 {
assert_eq!(new_pos[i].to_bits(), (pos[i] + vel[i]).to_bits());
assert_eq!(new_vel[i].to_bits(), vel[i].to_bits());
}
}

#[test]
fn motion_zero_length_payload_returns_nomatch() {
let ent = make_node_id("bad-size-0");
let ty = make_type_id("entity");
let mut store = GraphStore::default();
store.insert_node(
ent,
NodeRecord {
ty,
payload: Some(Bytes::from(vec![])),
},
);
let mut engine = Engine::new(store, ent);
engine.register_rule(rmg_core::motion_rule()).unwrap();
let tx = engine.begin();
let res = engine.apply(tx, MOTION_RULE_NAME, &ent).expect("apply");
assert!(matches!(res, ApplyResult::NoMatch));
}

#[test]
fn motion_boundary_payload_sizes() {
for &len in &[1usize, 23, 25, 32, 4096] {
let ent = make_node_id(&format!("bad-size-{}", len));
let ty = make_type_id("entity");
let mut store = GraphStore::default();
store.insert_node(
ent,
NodeRecord {
ty,
payload: Some(Bytes::from(vec![0u8; len])),
},
);
let mut engine = Engine::new(store, ent);
engine.register_rule(rmg_core::motion_rule()).unwrap();
let tx = engine.begin();
let res = engine.apply(tx, MOTION_RULE_NAME, &ent).expect("apply");
assert!(
matches!(res, ApplyResult::NoMatch),
"len={} should be NoMatch",
len
);
}
}

#[test]
fn motion_exact_24_bytes_with_weird_bits_is_accepted_and_propagates() {
// 24 bytes of 0xFF -> three NaNs for pos, three NaNs for vel
let weird = Bytes::from(vec![0xFFu8; 24]);
let ent = make_node_id("weird-24");
let ty = make_type_id("entity");
let mut store = GraphStore::default();
store.insert_node(
ent,
NodeRecord {
ty,
payload: Some(weird),
},
);
let mut engine = Engine::new(store, ent);
engine.register_rule(rmg_core::motion_rule()).unwrap();
let tx = engine.begin();
let res = engine.apply(tx, MOTION_RULE_NAME, &ent).expect("apply");
assert!(matches!(res, ApplyResult::Applied));
engine.commit(tx).unwrap();
let (pos, vel) = {
let node = engine.node(&ent).unwrap();
decode_motion_payload(node.payload.as_ref().unwrap()).unwrap()
};
assert!(pos.iter().all(|v| v.is_nan()));
assert!(vel.iter().all(|v| v.is_nan()));
}

#[test]
fn motion_nan_idempotency_applies_twice_stays_nan() {
let ent = make_node_id("nan-twice");
let ty = make_type_id("entity");
let mut store = GraphStore::default();
store.insert_node(
ent,
NodeRecord {
ty,
payload: Some(encode_motion_payload(
[f32::NAN, f32::NAN, f32::NAN],
[0.0, 0.0, 0.0],
)),
},
);
let mut engine = Engine::new(store, ent);
engine.register_rule(rmg_core::motion_rule()).unwrap();
for _ in 0..2 {
let tx = engine.begin();
let res = engine.apply(tx, MOTION_RULE_NAME, &ent).unwrap();
assert!(matches!(res, ApplyResult::Applied));
engine.commit(tx).unwrap();
}
let (pos, vel) = {
let node = engine.node(&ent).unwrap();
decode_motion_payload(node.payload.as_ref().unwrap()).unwrap()
};
assert!(pos.iter().all(|v| v.is_nan()));
assert_eq!(vel, [0.0, 0.0, 0.0]);
}
1 change: 1 addition & 0 deletions docs/decision-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ The following entries use a heading + bullets format for richer context.
| 2025-10-30 | CI matrix | Add musl tests job (rmg-core; x86_64-unknown-linux-musl) and a manual macOS workflow for local runs | Cover glibc + musl in CI while keeping macOS optional to control costs | Determinism coverage improves; CI footprint remains lean |
| 2025-10-30 | Docs rollup | Add generator script and `docs/echo-total.md` rollup of all top‑level docs | Single-file reference for reviewers and readers; preserves source of truth in individual docs | Keep rollup refreshed via script when docs change |
| 2025-10-30 | Docs rollup review (PR-05) | Add MUSL job intent comment (rmg-core only) and fix generator script to use portable newlines (`printf`/`echo`) | Clarify CI intent and ensure rollup emits correct formatting | Merged `main` into branch (no rebase/force) |
| 2025-10-30 | Motion negative tests (PR-06) | Add tests documenting NaN/Infinity propagation and invalid payload size NoMatch in motion rule | Clarify expected behavior without changing runtime; improves determinism docs via tests | Tests-only; no runtime impact |
| 2025-10-28 | PR #7 merged | Reachability-only snapshot hashing; ports demo registers rule; guarded ports footprint; scheduler `finalize_tx()` clears `pending`; `PortKey` u30 mask; hooks+CI hardened (toolchain pin, rustdoc fixes). | Determinism + memory hygiene; remove test footguns; pass CI with stable toolchain while keeping rmg-core MSRV=1.68. | Queued follow-ups: #13 (Mat4 canonical zero + MulAssign), #14 (geom train), #15 (devcontainer). |
| 2025-10-27 | MWMR reserve gate | Engine calls `scheduler.finalize_tx()` at commit; compact rule id used on execute path; per‑tx telemetry summary behind feature. | Enforce independence and clear active frontier deterministically; keep ordering stable with `(scope_hash, family_id)`. | Toolchain pinned to Rust 1.68; add design note for telemetry graph snapshot replay. |

Expand Down
4 changes: 4 additions & 0 deletions docs/execution-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,10 @@ This is Codex’s working map for building Echo. Update it relentlessly—each s
- Script portability: replaced echo with `printf` (and a plain `echo '---'`) to emit real newlines in `scripts/gen-echo-total.sh`; removed non-portable `\n` echo usage.
- Synced with `origin/main` via merge (no rebase/force).

> 2025-10-30 — PR-06: Motion negative tests (opened)

- Added tests in `rmg-core` covering NaN/Infinity propagation and invalid payload size returning `NoMatch`. Tests-only; documents expected behavior; no runtime changes.


> 2025-10-29 — Geom fat AABB midpoint sampling (merge-train)

Expand Down