Enhance float formatting and comparison functions for disk representation

- Made `format_f32` and `format_f64` functions public to allow external access.
- Introduced new constants for disk options and buffer sizes for `f32` and `f64`.
- Added `cleanup_f32` function to clean up float values before comparison.
- Implemented `disk_eq_f32` and `disk_eq_f64` functions to compare float values based on their disk representation.
- Updated `variant_eq_disk` to utilize the new disk equality functions, improving accuracy in matching algorithms.

These changes aim to enhance the precision of float comparisons and ensure consistent behavior across different representations in the system.
This commit is contained in:
Joe 2026-02-19 00:44:16 -06:00
parent 532425df9e
commit 17d52e3edf
6 changed files with 1441 additions and 14 deletions

View file

@ -153,7 +153,12 @@ const F64_FLOAT_OPTIONS: Options = Options::builder()
const F32_BUF_SIZE: usize = F32_FLOAT_OPTIONS.buffer_size_const::<f32, STANDARD>();
const F64_BUF_SIZE: usize = F64_FLOAT_OPTIONS.buffer_size_const::<f64, STANDARD>();
fn format_f32(v: f32) -> String {
pub(crate) const F32_DISK_OPTIONS: &Options = &F32_FLOAT_OPTIONS;
pub(crate) const F64_DISK_OPTIONS: &Options = &F64_FLOAT_OPTIONS;
pub(crate) const F32_DISK_BUF_SIZE: usize = F32_BUF_SIZE;
pub(crate) const F64_DISK_BUF_SIZE: usize = F64_BUF_SIZE;
pub(crate) fn format_f32(v: f32) -> String {
let mut buffer = [0u8; F32_BUF_SIZE];
let digits = v.to_lexical_with_options::<STANDARD>(&mut buffer, &F32_FLOAT_OPTIONS);
std::str::from_utf8(digits)
@ -161,7 +166,7 @@ fn format_f32(v: f32) -> String {
.into()
}
fn format_f64(v: f64) -> String {
pub(crate) fn format_f64(v: f64) -> String {
let mut buffer = [0u8; F64_BUF_SIZE];
let digits = v.to_lexical_with_options::<STANDARD>(&mut buffer, &F64_FLOAT_OPTIONS);
std::str::from_utf8(digits)

View file

@ -417,7 +417,7 @@ impl AmbiguousValue {
}
}
fn cleanup_f32(v: f32) -> f32 {
pub(crate) fn cleanup_f32(v: f32) -> f32 {
let cleaned = format!("{:.6}", v).parse::<f32>().unwrap_or(v);
if cleaned == 0.0 {
0.0

View file

@ -16,7 +16,7 @@ use std::collections::HashMap;
use rbx_dom_weak::types::{Ref, Variant};
use rbx_dom_weak::Ustr;
use crate::variant_eq::variant_eq;
use crate::variant_eq::variant_eq_disk;
use super::{InstanceSnapshot, InstanceWithMeta, RojoTree};
@ -467,7 +467,7 @@ fn count_own_diffs(snap: &InstanceSnapshot, inst: &InstanceWithMeta, tree: &Rojo
} else {
let is_default = class_data
.and_then(|cd| cd.default_properties.get(key.as_str()))
.is_some_and(|default| variant_eq(snap_val, default));
.is_some_and(|default| variant_eq_disk(snap_val, default));
if !is_default {
cost += count_variant_one_sided(snap_val);
}
@ -478,7 +478,7 @@ fn count_own_diffs(snap: &InstanceSnapshot, inst: &InstanceWithMeta, tree: &Rojo
if !snap_props.contains_key(key) {
let is_default = class_data
.and_then(|cd| cd.default_properties.get(key.as_str()))
.is_some_and(|default| variant_eq(inst_val, default));
.is_some_and(|default| variant_eq_disk(inst_val, default));
if !is_default {
cost += count_variant_one_sided(inst_val);
}
@ -531,7 +531,7 @@ fn diff_variant_pair(a: &Variant, b: &Variant, tree: &RojoTree) -> u32 {
count_attributes_diff(attrs_a, attrs_b)
}
_ => {
if variant_eq(a, b) {
if variant_eq_disk(a, b) {
0
} else {
1
@ -578,7 +578,7 @@ fn count_attributes_diff(
for (key, a_val) in a.iter() {
match b.get(key.as_str()) {
Some(b_val) => {
if !variant_eq(a_val, b_val) {
if !variant_eq_disk(a_val, b_val) {
cost += 1;
}
}
@ -597,6 +597,7 @@ fn count_attributes_diff(
mod tests {
use super::*;
use crate::snapshot::{InstanceMetadata, InstanceSnapshot, RojoTree};
use crate::variant_eq::variant_eq;
use rbx_dom_weak::{ustr, HashMapExt as _};
use std::borrow::Cow;
@ -1587,4 +1588,262 @@ mod tests {
);
}
}
// ================================================================
// Large ambiguous group tests (50+ same-named instances)
// ================================================================
#[test]
fn fifty_same_name_parts_five_variation_groups() {
use rbx_dom_weak::types::{Color3, Variant, Vector3};
let groups: Vec<(Vector3, Color3)> = vec![
(Vector3::new(0.0, 0.0, 0.0), Color3::new(1.0, 0.0, 0.0)),
(Vector3::new(0.0, 5.0, 0.0), Color3::new(1.0, 0.0, 0.0)),
(Vector3::new(0.0, 10.0, 0.0), Color3::new(0.0, 1.0, 0.0)),
(Vector3::new(0.0, 0.0, 0.0), Color3::new(0.0, 1.0, 0.0)),
(Vector3::new(0.0, 5.0, 0.0), Color3::new(0.0, 0.0, 1.0)),
];
let mut snaps = Vec::new();
for (pos, color) in &groups {
for _ in 0..10 {
snaps.push(make_snapshot_with_props(
"Line",
"Part",
vec![
("Position", Variant::Vector3(*pos)),
("Color", Variant::Color3(*color)),
],
));
}
}
let mut tree_children_snaps: Vec<InstanceSnapshot> = Vec::new();
for (pos, color) in groups.iter().rev() {
for _ in 0..10 {
tree_children_snaps.push(make_snapshot_with_props(
"Line",
"Part",
vec![
("Position", Variant::Vector3(*pos)),
("Color", Variant::Color3(*color)),
],
));
}
}
let (tree, tree_refs) = make_tree_from_snapshots(tree_children_snaps);
let start = std::time::Instant::now();
let result = match_forward(snaps, &tree_refs, &tree, &MatchingSession::new());
let elapsed = start.elapsed();
assert_eq!(result.matched.len(), 50);
assert!(result.unmatched_snapshot.is_empty());
assert!(result.unmatched_tree.is_empty());
assert!(elapsed.as_secs() < 5, "took too long: {:?}", elapsed);
for (snap, tree_ref) in &result.matched {
let inst = tree.get_instance(*tree_ref).unwrap();
let snap_pos = snap.properties.get(&ustr("Position")).unwrap();
let tree_pos = inst.properties().get(&ustr("Position")).unwrap();
assert!(
variant_eq_disk(snap_pos, tree_pos),
"Position mismatch: snap={:?}, tree={:?}",
snap_pos,
tree_pos
);
let snap_color = snap.properties.get(&ustr("Color")).unwrap();
let tree_color = inst.properties().get(&ustr("Color")).unwrap();
assert!(
variant_eq_disk(snap_color, tree_color),
"Color mismatch: snap={:?}, tree={:?}",
snap_color,
tree_color
);
}
}
#[test]
fn fifty_parts_position_only_variations() {
use rbx_dom_weak::types::{Color3, Variant, Vector3};
let shared_color = Color3::new(0.5, 0.5, 0.5);
let positions = [
Vector3::new(0.0, 0.0, 0.0),
Vector3::new(10.0, 0.0, 0.0),
Vector3::new(0.0, 10.0, 0.0),
Vector3::new(0.0, 0.0, 10.0),
Vector3::new(5.0, 5.0, 5.0),
];
let mut snaps = Vec::new();
let mut tree_children_snaps = Vec::new();
for pos in &positions {
for _ in 0..10 {
let props = vec![
("Position", Variant::Vector3(*pos)),
("Color", Variant::Color3(shared_color)),
];
snaps.push(make_snapshot_with_props("Block", "Part", props.clone()));
tree_children_snaps.push(make_snapshot_with_props("Block", "Part", props));
}
}
tree_children_snaps.reverse();
let (tree, tree_refs) = make_tree_from_snapshots(tree_children_snaps);
let result = match_forward(snaps, &tree_refs, &tree, &MatchingSession::new());
assert_eq!(result.matched.len(), 50);
assert!(result.unmatched_snapshot.is_empty());
assert!(result.unmatched_tree.is_empty());
for (snap, tree_ref) in &result.matched {
let inst = tree.get_instance(*tree_ref).unwrap();
let snap_pos = snap.properties.get(&ustr("Position")).unwrap();
let tree_pos = inst.properties().get(&ustr("Position")).unwrap();
assert!(variant_eq_disk(snap_pos, tree_pos), "Position mismatch");
}
}
#[test]
fn fifty_parts_color_only_variations() {
use rbx_dom_weak::types::{Color3, Variant, Vector3};
let shared_pos = Vector3::new(0.0, 0.0, 0.0);
let colors = [
Color3::new(1.0, 0.0, 0.0),
Color3::new(0.0, 1.0, 0.0),
Color3::new(0.0, 0.0, 1.0),
Color3::new(1.0, 1.0, 0.0),
Color3::new(0.0, 1.0, 1.0),
];
let mut snaps = Vec::new();
let mut tree_children_snaps = Vec::new();
for color in &colors {
for _ in 0..10 {
let props = vec![
("Position", Variant::Vector3(shared_pos)),
("Color", Variant::Color3(*color)),
];
snaps.push(make_snapshot_with_props("Block", "Part", props.clone()));
tree_children_snaps.push(make_snapshot_with_props("Block", "Part", props));
}
}
tree_children_snaps.reverse();
let (tree, tree_refs) = make_tree_from_snapshots(tree_children_snaps);
let result = match_forward(snaps, &tree_refs, &tree, &MatchingSession::new());
assert_eq!(result.matched.len(), 50);
assert!(result.unmatched_snapshot.is_empty());
assert!(result.unmatched_tree.is_empty());
for (snap, tree_ref) in &result.matched {
let inst = tree.get_instance(*tree_ref).unwrap();
let snap_color = snap.properties.get(&ustr("Color")).unwrap();
let tree_color = inst.properties().get(&ustr("Color")).unwrap();
assert!(variant_eq_disk(snap_color, tree_color), "Color mismatch");
}
}
#[test]
fn sixty_parts_with_near_float_values() {
use rbx_dom_weak::types::Variant;
let groups: Vec<f32> = vec![0.1, 0.100001, 0.5, 0.500001, 0.999999];
let group_size = 12;
let mut snaps = Vec::new();
let mut tree_children_snaps = Vec::new();
for &t in &groups {
for _ in 0..group_size {
snaps.push(make_snapshot_with_props(
"Segment",
"Part",
vec![("Transparency", Variant::Float32(t))],
));
tree_children_snaps.push(make_snapshot_with_props(
"Segment",
"Part",
vec![("Transparency", Variant::Float32(t))],
));
}
}
tree_children_snaps.reverse();
let (tree, tree_refs) = make_tree_from_snapshots(tree_children_snaps);
let result = match_forward(snaps, &tree_refs, &tree, &MatchingSession::new());
assert_eq!(result.matched.len(), 60);
assert!(result.unmatched_snapshot.is_empty());
assert!(result.unmatched_tree.is_empty());
for (snap, tree_ref) in &result.matched {
let inst = tree.get_instance(*tree_ref).unwrap();
let snap_t = snap.properties.get(&ustr("Transparency")).unwrap();
let tree_t = inst.properties().get(&ustr("Transparency")).unwrap();
assert!(
variant_eq_disk(snap_t, tree_t),
"Transparency mismatch: snap={:?}, tree={:?}",
snap_t,
tree_t
);
}
}
#[test]
fn disk_representation_boundary_matching() {
use rbx_dom_weak::types::Variant;
let a_val: f32 = 1.00005;
let b_val: f32 = 1.00015;
let snaps = vec![
make_snapshot_with_props(
"Edge",
"Part",
vec![("Transparency", Variant::Float32(a_val))],
),
make_snapshot_with_props(
"Edge",
"Part",
vec![("Transparency", Variant::Float32(b_val))],
),
];
let tree_children = vec![
make_snapshot_with_props(
"Edge",
"Part",
vec![("Transparency", Variant::Float32(b_val))],
),
make_snapshot_with_props(
"Edge",
"Part",
vec![("Transparency", Variant::Float32(a_val))],
),
];
let (tree, tree_refs) = make_tree_from_snapshots(tree_children);
let result = match_forward(snaps, &tree_refs, &tree, &MatchingSession::new());
assert_eq!(result.matched.len(), 2);
assert!(result.unmatched_snapshot.is_empty());
assert!(result.unmatched_tree.is_empty());
for (snap, tree_ref) in &result.matched {
let inst = tree.get_instance(*tree_ref).unwrap();
let snap_t = snap.properties.get(&ustr("Transparency")).unwrap();
let tree_t = inst.properties().get(&ustr("Transparency")).unwrap();
assert!(
variant_eq_disk(snap_t, tree_t),
"Boundary test failed: snap={:?}, tree={:?} (disk should pair exactly)",
snap_t,
tree_t
);
}
}
}

View file

@ -18,7 +18,7 @@ use blake3::Hash;
use rbx_dom_weak::types::{Ref, Variant};
use rbx_dom_weak::{Ustr, WeakDom};
use crate::variant_eq::variant_eq;
use crate::variant_eq::variant_eq_disk;
const UNMATCHED_PENALTY: u32 = 10_000;
@ -522,7 +522,7 @@ fn count_own_diffs(new_ref: Ref, old_ref: Ref, new_dom: &WeakDom, old_dom: &Weak
} else {
let is_default = class_data
.and_then(|cd| cd.default_properties.get(key.as_str()))
.is_some_and(|default| variant_eq(new_val, default));
.is_some_and(|default| variant_eq_disk(new_val, default));
if !is_default {
cost += count_variant_one_sided(new_val);
}
@ -533,7 +533,7 @@ fn count_own_diffs(new_ref: Ref, old_ref: Ref, new_dom: &WeakDom, old_dom: &Weak
if !new_inst.properties.contains_key(key) {
let is_default = class_data
.and_then(|cd| cd.default_properties.get(key.as_str()))
.is_some_and(|default| variant_eq(old_val, default));
.is_some_and(|default| variant_eq_disk(old_val, default));
if !is_default {
cost += count_variant_one_sided(old_val);
}
@ -589,7 +589,7 @@ fn diff_variant_pair(a: &Variant, b: &Variant, a_dom: &WeakDom, b_dom: &WeakDom)
for (key, a_val) in attrs_a.iter() {
match attrs_b.get(key.as_str()) {
Some(b_val) => {
if !variant_eq(a_val, b_val) {
if !variant_eq_disk(a_val, b_val) {
cost += 1;
}
}
@ -604,7 +604,7 @@ fn diff_variant_pair(a: &Variant, b: &Variant, a_dom: &WeakDom, b_dom: &WeakDom)
cost
}
_ => {
if variant_eq(a, b) {
if variant_eq_disk(a, b) {
0
} else {
1
@ -633,7 +633,7 @@ fn count_variant_one_sided(val: &Variant) -> u32 {
#[cfg(test)]
mod tests {
use super::*;
use rbx_dom_weak::InstanceBuilder;
use rbx_dom_weak::{ustr, InstanceBuilder};
fn build_test_dom() -> (WeakDom, Ref, Ref, Ref) {
let mut dom = WeakDom::new(InstanceBuilder::new("DataModel"));
@ -956,4 +956,376 @@ mod tests {
);
}
}
// ================================================================
// Large ambiguous group tests (50+ same-named instances)
// ================================================================
#[test]
fn fifty_same_name_parts_five_groups_syncback() {
use rbx_dom_weak::types::{Color3, Variant, Vector3};
let groups: Vec<(Vector3, Color3)> = vec![
(Vector3::new(0.0, 0.0, 0.0), Color3::new(1.0, 0.0, 0.0)),
(Vector3::new(0.0, 5.0, 0.0), Color3::new(1.0, 0.0, 0.0)),
(Vector3::new(0.0, 10.0, 0.0), Color3::new(0.0, 1.0, 0.0)),
(Vector3::new(0.0, 0.0, 0.0), Color3::new(0.0, 1.0, 0.0)),
(Vector3::new(0.0, 5.0, 0.0), Color3::new(0.0, 0.0, 1.0)),
];
let mut new_dom = WeakDom::new(InstanceBuilder::new("DataModel"));
let new_root = new_dom.root_ref();
for (pos, color) in &groups {
for _ in 0..10 {
new_dom.insert(
new_root,
InstanceBuilder::new("Part")
.with_name("Line")
.with_property("Position", Variant::Vector3(*pos))
.with_property("Color", Variant::Color3(*color)),
);
}
}
let mut old_dom = WeakDom::new(InstanceBuilder::new("DataModel"));
let old_root = old_dom.root_ref();
for (pos, color) in groups.iter().rev() {
for _ in 0..10 {
old_dom.insert(
old_root,
InstanceBuilder::new("Part")
.with_name("Line")
.with_property("Position", Variant::Vector3(*pos))
.with_property("Color", Variant::Color3(*color)),
);
}
}
let new_children: Vec<Ref> = new_dom.get_by_ref(new_root).unwrap().children().to_vec();
let old_children: Vec<Ref> = old_dom.get_by_ref(old_root).unwrap().children().to_vec();
let start = std::time::Instant::now();
let result = match_children(
&new_children,
&old_children,
&new_dom,
&old_dom,
None,
None,
&MatchingSession::new(),
);
let elapsed = start.elapsed();
assert_eq!(result.matched.len(), 50);
assert!(result.unmatched_new.is_empty());
assert!(result.unmatched_old.is_empty());
assert!(elapsed.as_secs() < 5, "took too long: {:?}", elapsed);
for (new_ref, old_ref) in &result.matched {
let new_inst = new_dom.get_by_ref(*new_ref).unwrap();
let old_inst = old_dom.get_by_ref(*old_ref).unwrap();
let new_pos = new_inst.properties.get(&ustr("Position")).unwrap();
let old_pos = old_inst.properties.get(&ustr("Position")).unwrap();
assert!(
variant_eq_disk(new_pos, old_pos),
"Position mismatch: new={:?}, old={:?}",
new_pos,
old_pos
);
let new_color = new_inst.properties.get(&ustr("Color")).unwrap();
let old_color = old_inst.properties.get(&ustr("Color")).unwrap();
assert!(
variant_eq_disk(new_color, old_color),
"Color mismatch: new={:?}, old={:?}",
new_color,
old_color
);
}
}
#[test]
fn fifty_parts_position_only_syncback() {
use rbx_dom_weak::types::{Color3, Variant, Vector3};
let shared_color = Color3::new(0.5, 0.5, 0.5);
let positions = [
Vector3::new(0.0, 0.0, 0.0),
Vector3::new(10.0, 0.0, 0.0),
Vector3::new(0.0, 10.0, 0.0),
Vector3::new(0.0, 0.0, 10.0),
Vector3::new(5.0, 5.0, 5.0),
];
let mut new_dom = WeakDom::new(InstanceBuilder::new("DataModel"));
let new_root = new_dom.root_ref();
let mut old_dom = WeakDom::new(InstanceBuilder::new("DataModel"));
let old_root = old_dom.root_ref();
for pos in &positions {
for _ in 0..10 {
new_dom.insert(
new_root,
InstanceBuilder::new("Part")
.with_name("Block")
.with_property("Position", Variant::Vector3(*pos))
.with_property("Color", Variant::Color3(shared_color)),
);
}
}
for pos in positions.iter().rev() {
for _ in 0..10 {
old_dom.insert(
old_root,
InstanceBuilder::new("Part")
.with_name("Block")
.with_property("Position", Variant::Vector3(*pos))
.with_property("Color", Variant::Color3(shared_color)),
);
}
}
let new_children: Vec<Ref> = new_dom.get_by_ref(new_root).unwrap().children().to_vec();
let old_children: Vec<Ref> = old_dom.get_by_ref(old_root).unwrap().children().to_vec();
let result = match_children(
&new_children,
&old_children,
&new_dom,
&old_dom,
None,
None,
&MatchingSession::new(),
);
assert_eq!(result.matched.len(), 50);
for (new_ref, old_ref) in &result.matched {
let new_pos = new_dom
.get_by_ref(*new_ref)
.unwrap()
.properties
.get(&ustr("Position"))
.unwrap();
let old_pos = old_dom
.get_by_ref(*old_ref)
.unwrap()
.properties
.get(&ustr("Position"))
.unwrap();
assert!(variant_eq_disk(new_pos, old_pos), "Position mismatch");
}
}
#[test]
fn fifty_parts_color_only_syncback() {
use rbx_dom_weak::types::{Color3, Variant, Vector3};
let shared_pos = Vector3::new(0.0, 0.0, 0.0);
let colors = [
Color3::new(1.0, 0.0, 0.0),
Color3::new(0.0, 1.0, 0.0),
Color3::new(0.0, 0.0, 1.0),
Color3::new(1.0, 1.0, 0.0),
Color3::new(0.0, 1.0, 1.0),
];
let mut new_dom = WeakDom::new(InstanceBuilder::new("DataModel"));
let new_root = new_dom.root_ref();
let mut old_dom = WeakDom::new(InstanceBuilder::new("DataModel"));
let old_root = old_dom.root_ref();
for color in &colors {
for _ in 0..10 {
new_dom.insert(
new_root,
InstanceBuilder::new("Part")
.with_name("Block")
.with_property("Position", Variant::Vector3(shared_pos))
.with_property("Color", Variant::Color3(*color)),
);
}
}
for color in colors.iter().rev() {
for _ in 0..10 {
old_dom.insert(
old_root,
InstanceBuilder::new("Part")
.with_name("Block")
.with_property("Position", Variant::Vector3(shared_pos))
.with_property("Color", Variant::Color3(*color)),
);
}
}
let new_children: Vec<Ref> = new_dom.get_by_ref(new_root).unwrap().children().to_vec();
let old_children: Vec<Ref> = old_dom.get_by_ref(old_root).unwrap().children().to_vec();
let result = match_children(
&new_children,
&old_children,
&new_dom,
&old_dom,
None,
None,
&MatchingSession::new(),
);
assert_eq!(result.matched.len(), 50);
for (new_ref, old_ref) in &result.matched {
let new_color = new_dom
.get_by_ref(*new_ref)
.unwrap()
.properties
.get(&ustr("Color"))
.unwrap();
let old_color = old_dom
.get_by_ref(*old_ref)
.unwrap()
.properties
.get(&ustr("Color"))
.unwrap();
assert!(variant_eq_disk(new_color, old_color), "Color mismatch");
}
}
#[test]
fn sixty_parts_near_float_syncback() {
use rbx_dom_weak::types::Variant;
let groups: Vec<f32> = vec![0.1, 0.100001, 0.5, 0.500001, 0.999999];
let group_size = 12;
let mut new_dom = WeakDom::new(InstanceBuilder::new("DataModel"));
let new_root = new_dom.root_ref();
let mut old_dom = WeakDom::new(InstanceBuilder::new("DataModel"));
let old_root = old_dom.root_ref();
for &t in &groups {
for _ in 0..group_size {
new_dom.insert(
new_root,
InstanceBuilder::new("Part")
.with_name("Segment")
.with_property("Transparency", Variant::Float32(t)),
);
}
}
for &t in groups.iter().rev() {
for _ in 0..group_size {
old_dom.insert(
old_root,
InstanceBuilder::new("Part")
.with_name("Segment")
.with_property("Transparency", Variant::Float32(t)),
);
}
}
let new_children: Vec<Ref> = new_dom.get_by_ref(new_root).unwrap().children().to_vec();
let old_children: Vec<Ref> = old_dom.get_by_ref(old_root).unwrap().children().to_vec();
let result = match_children(
&new_children,
&old_children,
&new_dom,
&old_dom,
None,
None,
&MatchingSession::new(),
);
assert_eq!(result.matched.len(), 60);
assert!(result.unmatched_new.is_empty());
assert!(result.unmatched_old.is_empty());
for (new_ref, old_ref) in &result.matched {
let new_t = new_dom
.get_by_ref(*new_ref)
.unwrap()
.properties
.get(&ustr("Transparency"))
.unwrap();
let old_t = old_dom
.get_by_ref(*old_ref)
.unwrap()
.properties
.get(&ustr("Transparency"))
.unwrap();
assert!(
variant_eq_disk(new_t, old_t),
"Transparency mismatch: new={:?}, old={:?}",
new_t,
old_t
);
}
}
#[test]
fn disk_representation_boundary_syncback() {
use rbx_dom_weak::types::Variant;
let a_val: f32 = 1.00005;
let b_val: f32 = 1.00015;
let mut new_dom = WeakDom::new(InstanceBuilder::new("DataModel"));
let new_root = new_dom.root_ref();
new_dom.insert(
new_root,
InstanceBuilder::new("Part")
.with_name("Edge")
.with_property("Transparency", Variant::Float32(a_val)),
);
new_dom.insert(
new_root,
InstanceBuilder::new("Part")
.with_name("Edge")
.with_property("Transparency", Variant::Float32(b_val)),
);
let mut old_dom = WeakDom::new(InstanceBuilder::new("DataModel"));
let old_root = old_dom.root_ref();
old_dom.insert(
old_root,
InstanceBuilder::new("Part")
.with_name("Edge")
.with_property("Transparency", Variant::Float32(b_val)),
);
old_dom.insert(
old_root,
InstanceBuilder::new("Part")
.with_name("Edge")
.with_property("Transparency", Variant::Float32(a_val)),
);
let new_children: Vec<Ref> = new_dom.get_by_ref(new_root).unwrap().children().to_vec();
let old_children: Vec<Ref> = old_dom.get_by_ref(old_root).unwrap().children().to_vec();
let result = match_children(
&new_children,
&old_children,
&new_dom,
&old_dom,
None,
None,
&MatchingSession::new(),
);
assert_eq!(result.matched.len(), 2);
for (new_ref, old_ref) in &result.matched {
let new_t = new_dom
.get_by_ref(*new_ref)
.unwrap()
.properties
.get(&ustr("Transparency"))
.unwrap();
let old_t = old_dom
.get_by_ref(*old_ref)
.unwrap()
.properties
.get(&ustr("Transparency"))
.unwrap();
assert!(
variant_eq_disk(new_t, old_t),
"Boundary test failed: new={:?}, old={:?}",
new_t,
old_t
);
}
}
}

View file

@ -1,5 +1,9 @@
use lexical_write_float::{format::STANDARD, ToLexicalWithOptions};
use rbx_dom_weak::types::{PhysicalProperties, Variant, Vector3};
use crate::json::{F32_DISK_BUF_SIZE, F32_DISK_OPTIONS, F64_DISK_BUF_SIZE, F64_DISK_OPTIONS};
use crate::resolution::cleanup_f32;
const EPSILON_F32: f32 = 0.0001;
const EPSILON_F64: f64 = 0.0001;
@ -213,6 +217,228 @@ fn vector_eq(a: &Vector3, b: &Vector3) -> bool {
fuzzy_eq_f32(a.x, b.x) && fuzzy_eq_f32(a.y, b.y) && fuzzy_eq_f32(a.z, b.z)
}
// ============================================================================
// Disk-representation equality: "will these two values produce identical bytes
// when written to a .meta.json5 / .model.json5 file?"
//
// Used by the matching algorithms in src/snapshot/matching.rs and
// src/syncback/matching.rs where the cost metric is file modifications.
// ============================================================================
#[inline(always)]
fn disk_eq_f32(a: f32, b: f32) -> bool {
let a = if a == 0.0 { 0.0_f32 } else { a };
let b = if b == 0.0 { 0.0_f32 } else { b };
if a.to_bits() == b.to_bits() {
return true;
}
let mut buf_a = [0u8; F32_DISK_BUF_SIZE];
let mut buf_b = [0u8; F32_DISK_BUF_SIZE];
let repr_a = a.to_lexical_with_options::<STANDARD>(&mut buf_a, F32_DISK_OPTIONS);
let repr_b = b.to_lexical_with_options::<STANDARD>(&mut buf_b, F32_DISK_OPTIONS);
repr_a == repr_b
}
#[inline(always)]
fn disk_eq_f32_cleaned(a: f32, b: f32) -> bool {
disk_eq_f32(cleanup_f32(a), cleanup_f32(b))
}
#[inline(always)]
fn disk_eq_f64(a: f64, b: f64) -> bool {
let a = if a == 0.0 { 0.0_f64 } else { a };
let b = if b == 0.0 { 0.0_f64 } else { b };
if a.to_bits() == b.to_bits() {
return true;
}
let mut buf_a = [0u8; F64_DISK_BUF_SIZE];
let mut buf_b = [0u8; F64_DISK_BUF_SIZE];
let repr_a = a.to_lexical_with_options::<STANDARD>(&mut buf_a, F64_DISK_OPTIONS);
let repr_b = b.to_lexical_with_options::<STANDARD>(&mut buf_b, F64_DISK_OPTIONS);
repr_a == repr_b
}
#[inline(always)]
fn vector_eq_disk(a: &Vector3, b: &Vector3) -> bool {
disk_eq_f32_cleaned(a.x, b.x) && disk_eq_f32_cleaned(a.y, b.y) && disk_eq_f32_cleaned(a.z, b.z)
}
/// Compares two variants by their on-disk representation. Two values are
/// "equal" iff serializing both through resolution.rs + json.rs produces
/// identical bytes in the output file. Used by matching algorithms where
/// cost = number of file modifications.
#[inline]
pub fn variant_eq_disk(variant_a: &Variant, variant_b: &Variant) -> bool {
if variant_a.ty() != variant_b.ty() {
return false;
}
match (variant_a, variant_b) {
(Variant::Attributes(a), Variant::Attributes(b)) => {
if a.len() != b.len() {
return false;
}
for ((a_name, a_value), (b_name, b_value)) in a.iter().zip(b.iter()) {
if !(a_name == b_name && variant_eq_disk(a_value, b_value)) {
return false;
}
}
true
}
(Variant::Axes(a), Variant::Axes(b)) => a == b,
(Variant::BinaryString(a), Variant::BinaryString(b)) => a == b,
(Variant::Bool(a), Variant::Bool(b)) => a == b,
(Variant::BrickColor(a), Variant::BrickColor(b)) => a == b,
(Variant::CFrame(a), Variant::CFrame(b)) => {
vector_eq_disk(&a.position, &b.position)
&& vector_eq_disk(&a.orientation.x, &b.orientation.x)
&& vector_eq_disk(&a.orientation.y, &b.orientation.y)
&& vector_eq_disk(&a.orientation.z, &b.orientation.z)
}
(Variant::Color3(a), Variant::Color3(b)) => {
disk_eq_f32_cleaned(a.r, b.r)
&& disk_eq_f32_cleaned(a.g, b.g)
&& disk_eq_f32_cleaned(a.b, b.b)
}
(Variant::Color3uint8(a), Variant::Color3uint8(b)) => a == b,
(Variant::ColorSequence(a), Variant::ColorSequence(b)) => {
if a.keypoints.len() != b.keypoints.len() {
return false;
}
let mut a_keypoints: Vec<_> = a.keypoints.iter().collect();
let mut b_keypoints: Vec<_> = b.keypoints.iter().collect();
a_keypoints.sort_unstable_by(|k1, k2| k1.time.partial_cmp(&k2.time).unwrap());
b_keypoints.sort_unstable_by(|k1, k2| k1.time.partial_cmp(&k2.time).unwrap());
for (a_kp, b_kp) in a_keypoints.iter().zip(b_keypoints) {
if !(disk_eq_f32_cleaned(a_kp.time, b_kp.time)
&& disk_eq_f32_cleaned(a_kp.color.r, b_kp.color.r)
&& disk_eq_f32_cleaned(a_kp.color.g, b_kp.color.g)
&& disk_eq_f32_cleaned(a_kp.color.b, b_kp.color.b))
{
return false;
}
}
true
}
(Variant::Content(a), Variant::Content(b)) => a == b,
(Variant::ContentId(a), Variant::ContentId(b)) => a == b,
(Variant::Enum(a), Variant::Enum(b)) => a == b,
(Variant::EnumItem(a), Variant::EnumItem(b)) => a == b,
(Variant::Faces(a), Variant::Faces(b)) => a == b,
(Variant::Float32(a), Variant::Float32(b)) => disk_eq_f32(*a, *b),
(Variant::Float64(a), Variant::Float64(b)) => disk_eq_f64(*a, *b),
(Variant::Font(a), Variant::Font(b)) => {
a.weight == b.weight
&& a.style == b.style
&& a.family == b.family
&& a.cached_face_id == b.cached_face_id
}
(Variant::Int32(a), Variant::Int32(b)) => a == b,
(Variant::Int64(a), Variant::Int64(b)) => a == b,
(Variant::MaterialColors(a), Variant::MaterialColors(b)) => a.encode() == b.encode(),
(Variant::NetAssetRef(a), Variant::NetAssetRef(b)) => a == b,
(Variant::NumberRange(a), Variant::NumberRange(b)) => {
disk_eq_f32_cleaned(a.max, b.max) && disk_eq_f32_cleaned(a.min, b.min)
}
(Variant::NumberSequence(a), Variant::NumberSequence(b)) => {
if a.keypoints.len() != b.keypoints.len() {
return false;
}
let mut a_keypoints: Vec<_> = a.keypoints.iter().collect();
let mut b_keypoints: Vec<_> = b.keypoints.iter().collect();
a_keypoints.sort_unstable_by(|k1, k2| k1.time.partial_cmp(&k2.time).unwrap());
b_keypoints.sort_unstable_by(|k1, k2| k1.time.partial_cmp(&k2.time).unwrap());
for (a_kp, b_kp) in a_keypoints.iter().zip(b_keypoints) {
if !(disk_eq_f32_cleaned(a_kp.time, b_kp.time)
&& disk_eq_f32_cleaned(a_kp.value, b_kp.value)
&& disk_eq_f32_cleaned(a_kp.envelope, b_kp.envelope))
{
return false;
}
}
true
}
(Variant::OptionalCFrame(a), Variant::OptionalCFrame(b)) => match (a, b) {
(Some(a), Some(b)) => {
vector_eq_disk(&a.position, &b.position)
&& vector_eq_disk(&a.orientation.x, &b.orientation.x)
&& vector_eq_disk(&a.orientation.y, &b.orientation.y)
&& vector_eq_disk(&a.orientation.z, &b.orientation.z)
}
(None, None) => true,
_ => false,
},
(Variant::PhysicalProperties(a), Variant::PhysicalProperties(b)) => match (a, b) {
(PhysicalProperties::Default, PhysicalProperties::Default) => true,
(PhysicalProperties::Custom(a2), PhysicalProperties::Custom(b2)) => {
disk_eq_f32_cleaned(a2.density(), b2.density())
&& disk_eq_f32_cleaned(a2.elasticity(), b2.elasticity())
&& disk_eq_f32_cleaned(a2.friction(), b2.friction())
&& disk_eq_f32_cleaned(a2.elasticity_weight(), b2.elasticity_weight())
&& disk_eq_f32_cleaned(a2.friction_weight(), b2.friction_weight())
&& disk_eq_f32_cleaned(a2.acoustic_absorption(), b2.acoustic_absorption())
}
_ => false,
},
(Variant::Ray(a), Variant::Ray(b)) => {
vector_eq_disk(&a.direction, &b.direction) && vector_eq_disk(&a.origin, &b.origin)
}
(Variant::Rect(a), Variant::Rect(b)) => {
disk_eq_f32_cleaned(a.max.x, b.max.x)
&& disk_eq_f32_cleaned(a.max.y, b.max.y)
&& disk_eq_f32_cleaned(a.min.x, b.min.x)
&& disk_eq_f32_cleaned(a.min.y, b.min.y)
}
(Variant::Ref(a), Variant::Ref(b)) => a == b,
(Variant::Region3(a), Variant::Region3(b)) => {
vector_eq_disk(&a.max, &b.max) && vector_eq_disk(&a.min, &b.min)
}
(Variant::Region3int16(a), Variant::Region3int16(b)) => a == b,
(Variant::SecurityCapabilities(a), Variant::SecurityCapabilities(b)) => a == b,
(Variant::SharedString(a), Variant::SharedString(b)) => a == b,
(Variant::Tags(a), Variant::Tags(b)) => {
let mut a_sorted: Vec<&str> = a.iter().collect();
let mut b_sorted: Vec<&str> = b.iter().collect();
if a_sorted.len() == b_sorted.len() {
a_sorted.sort_unstable();
b_sorted.sort_unstable();
for (a_tag, b_tag) in a_sorted.into_iter().zip(b_sorted) {
if a_tag != b_tag {
return false;
}
}
true
} else {
false
}
}
(Variant::UDim(a), Variant::UDim(b)) => {
disk_eq_f32_cleaned(a.scale, b.scale) && a.offset == b.offset
}
(Variant::UDim2(a), Variant::UDim2(b)) => {
disk_eq_f32_cleaned(a.x.scale, b.x.scale)
&& a.x.offset == b.x.offset
&& disk_eq_f32_cleaned(a.y.scale, b.y.scale)
&& a.y.offset == b.y.offset
}
(Variant::UniqueId(a), Variant::UniqueId(b)) => a == b,
(Variant::String(a), Variant::String(b)) => a == b,
(Variant::Vector2(a), Variant::Vector2(b)) => {
disk_eq_f32_cleaned(a.x, b.x) && disk_eq_f32_cleaned(a.y, b.y)
}
(Variant::Vector2int16(a), Variant::Vector2int16(b)) => a == b,
(Variant::Vector3(a), Variant::Vector3(b)) => vector_eq_disk(a, b),
(Variant::Vector3int16(a), Variant::Vector3int16(b)) => a == b,
(a, b) => panic!(
"unsupported variant disk comparison: {:?} and {:?}",
a.ty(),
b.ty()
),
}
}
#[cfg(test)]
mod tests {
use super::*;
@ -259,4 +485,245 @@ mod tests {
&Variant::Float32(1.0 + 0.00011)
));
}
// ================================================================
// disk_eq / variant_eq_disk tests
// ================================================================
#[test]
fn disk_eq_f32_identical_bits() {
assert!(disk_eq_f32(1.0, 1.0));
assert!(disk_eq_f32(0.0, 0.0));
assert!(disk_eq_f32(-1.5, -1.5));
}
#[test]
fn disk_eq_f32_negative_zero() {
assert!(disk_eq_f32(0.0, -0.0));
}
#[test]
fn disk_eq_f32_nan() {
assert!(disk_eq_f32(f32::NAN, f32::NAN));
assert!(!disk_eq_f32(f32::NAN, 0.0));
assert!(!disk_eq_f32(0.0, f32::NAN));
}
#[test]
fn disk_eq_f32_infinity() {
assert!(disk_eq_f32(f32::INFINITY, f32::INFINITY));
assert!(disk_eq_f32(f32::NEG_INFINITY, f32::NEG_INFINITY));
assert!(!disk_eq_f32(f32::INFINITY, f32::NEG_INFINITY));
assert!(!disk_eq_f32(f32::INFINITY, 0.0));
}
#[test]
fn disk_eq_f32_fuzzy_says_equal_but_disk_differs() {
let a: f32 = 10000.0;
let b: f32 = 10000.5;
assert!(
fuzzy_eq_f32(a, b),
"precondition: fuzzy should say equal (diff=0.5, relative threshold=1.0)"
);
assert!(
!disk_eq_f32(a, b),
"disk representations differ: 6 sig digits gives '10000' vs '10000.5'"
);
}
#[test]
fn disk_eq_f64_basic() {
assert!(disk_eq_f64(1.0, 1.0));
assert!(disk_eq_f64(0.0, -0.0));
assert!(disk_eq_f64(f64::NAN, f64::NAN));
assert!(!disk_eq_f64(1.0, 2.0));
}
#[test]
fn disk_eq_f32_cleaned_tiny_values_zeroed() {
assert!(
disk_eq_f32_cleaned(0.0000001, 0.0),
"cleanup_f32 zeros 1e-7 to 0.0"
);
assert!(
!disk_eq_f32(0.0000001, 0.0),
"without cleanup, 1e-7 and 0 are different on disk"
);
}
#[test]
fn disk_eq_f32_cleaned_preserves_significant_values() {
assert!(!disk_eq_f32_cleaned(0.5, 0.6));
assert!(disk_eq_f32_cleaned(0.5, 0.5));
}
#[test]
fn variant_eq_disk_vector3_tiny_component() {
assert!(variant_eq_disk(
&Variant::Vector3(Vector3::new(1.0, 2.0, 0.0000001)),
&Variant::Vector3(Vector3::new(1.0, 2.0, 0.0)),
));
}
#[test]
fn variant_eq_disk_vector3_different() {
assert!(!variant_eq_disk(
&Variant::Vector3(Vector3::new(1.0, 2.0, 3.0)),
&Variant::Vector3(Vector3::new(1.0, 2.0, 4.0)),
));
}
#[test]
fn variant_eq_disk_color3() {
use rbx_dom_weak::types::Color3;
assert!(variant_eq_disk(
&Variant::Color3(Color3::new(1.0, 0.0, 0.0)),
&Variant::Color3(Color3::new(1.0, 0.0, 0.0)),
));
assert!(!variant_eq_disk(
&Variant::Color3(Color3::new(1.0, 0.0, 0.0)),
&Variant::Color3(Color3::new(0.0, 1.0, 0.0)),
));
}
#[test]
fn variant_eq_disk_float32_standalone() {
assert!(variant_eq_disk(
&Variant::Float32(0.5),
&Variant::Float32(0.5)
));
assert!(!variant_eq_disk(
&Variant::Float32(0.5),
&Variant::Float32(0.6)
));
}
#[test]
fn variant_eq_disk_float32_diverges_from_fuzzy() {
let a: f32 = 10000.0;
let b: f32 = 10000.5;
assert!(
variant_eq(&Variant::Float32(a), &Variant::Float32(b)),
"fuzzy says equal (diff=0.5, relative threshold=1.0)"
);
assert!(
!variant_eq_disk(&Variant::Float32(a), &Variant::Float32(b)),
"disk says different ('10000' vs '10000.5')"
);
}
#[test]
fn variant_eq_disk_non_float_types() {
assert!(variant_eq_disk(&Variant::Bool(true), &Variant::Bool(true)));
assert!(!variant_eq_disk(
&Variant::Bool(true),
&Variant::Bool(false)
));
assert!(variant_eq_disk(&Variant::Int32(42), &Variant::Int32(42)));
assert!(!variant_eq_disk(&Variant::Int32(42), &Variant::Int32(43)));
assert!(variant_eq_disk(
&Variant::String("hello".into()),
&Variant::String("hello".into()),
));
assert!(!variant_eq_disk(
&Variant::String("hello".into()),
&Variant::String("world".into()),
));
}
#[test]
fn variant_eq_disk_udim2_scale_cleaned() {
use rbx_dom_weak::types::{UDim, UDim2};
assert!(variant_eq_disk(
&Variant::UDim2(UDim2::new(UDim::new(0.5, 0), UDim::new(0.0000001, 100))),
&Variant::UDim2(UDim2::new(UDim::new(0.5, 0), UDim::new(0.0, 100))),
));
}
#[test]
fn variant_eq_disk_number_range() {
use rbx_dom_weak::types::NumberRange;
assert!(variant_eq_disk(
&Variant::NumberRange(NumberRange::new(0.0, 1.0)),
&Variant::NumberRange(NumberRange::new(0.0, 1.0)),
));
assert!(!variant_eq_disk(
&Variant::NumberRange(NumberRange::new(0.0, 1.0)),
&Variant::NumberRange(NumberRange::new(0.0, 2.0)),
));
}
#[test]
fn variant_eq_disk_tags_sorted() {
use rbx_dom_weak::types::Tags;
let mut tags_ab = Tags::new();
tags_ab.push("A");
tags_ab.push("B");
let mut tags_ba = Tags::new();
tags_ba.push("B");
tags_ba.push("A");
assert!(variant_eq_disk(
&Variant::Tags(tags_ab),
&Variant::Tags(tags_ba)
));
}
#[test]
fn variant_eq_disk_attributes_recursive() {
use rbx_dom_weak::types::Attributes;
let mut a = Attributes::new();
a.insert("Health".into(), Variant::Float32(100.0));
let mut b = Attributes::new();
b.insert("Health".into(), Variant::Float32(100.0));
assert!(variant_eq_disk(
&Variant::Attributes(a),
&Variant::Attributes(b),
));
}
#[test]
fn variant_eq_disk_cframe() {
use rbx_dom_weak::types::{CFrame, Matrix3};
let identity = Matrix3::new(
Vector3::new(1.0, 0.0, 0.0),
Vector3::new(0.0, 1.0, 0.0),
Vector3::new(0.0, 0.0, 1.0),
);
assert!(variant_eq_disk(
&Variant::CFrame(CFrame::new(Vector3::new(1.0, 2.0, 3.0), identity)),
&Variant::CFrame(CFrame::new(Vector3::new(1.0, 2.0, 3.0), identity)),
));
assert!(!variant_eq_disk(
&Variant::CFrame(CFrame::new(Vector3::new(1.0, 2.0, 3.0), identity)),
&Variant::CFrame(CFrame::new(Vector3::new(1.0, 2.0, 4.0), identity)),
));
}
#[test]
fn variant_eq_disk_rect() {
use rbx_dom_weak::types::{Rect, Vector2};
assert!(variant_eq_disk(
&Variant::Rect(Rect::new(
Vector2::new(0.0, 0.0),
Vector2::new(100.0, 100.0)
)),
&Variant::Rect(Rect::new(
Vector2::new(0.0, 0.0),
Vector2::new(100.0, 100.0)
)),
));
}
#[test]
fn variant_eq_disk_vector2() {
use rbx_dom_weak::types::Vector2;
assert!(variant_eq_disk(
&Variant::Vector2(Vector2::new(1.0, 2.0)),
&Variant::Vector2(Vector2::new(1.0, 2.0)),
));
assert!(!variant_eq_disk(
&Variant::Vector2(Vector2::new(1.0, 2.0)),
&Variant::Vector2(Vector2::new(1.0, 3.0)),
));
}
}