mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-10-23 05:51:39 +00:00
Implement new recursive change-count matching algorithm for instance reconciliation
- Introduced a comprehensive rewrite of the matching algorithm, replacing the previous multi-pass approach with a recursive change-count scoring function and greedy assignment strategy. - Updated the `hydrate.lua` and `matching.lua` files to utilize the new algorithm, enhancing the efficiency of instance matching during hydration. - Documented the new algorithm's structure, including fast-paths for Ref pins and (Name, ClassName) grouping, which significantly improve matching accuracy and performance. - Ensured that the new implementation maintains compatibility with existing systems while providing a more robust solution for handling ambiguous instances. These changes enhance the clarity and efficiency of the reconciliation process, paving the way for improved project management and user experience.
This commit is contained in:
parent
4cb45cd607
commit
1a1d9ce886
14 changed files with 1047 additions and 1108 deletions
|
|
@ -197,10 +197,7 @@ mod tests {
|
|||
build_dedup_name("Foo", Some(1), Some("server.luau")),
|
||||
"Foo~1.server.luau"
|
||||
);
|
||||
assert_eq!(
|
||||
build_dedup_name("Foo", Some(2), Some("luau")),
|
||||
"Foo~2.luau"
|
||||
);
|
||||
assert_eq!(build_dedup_name("Foo", Some(2), Some("luau")), "Foo~2.luau");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -75,8 +75,7 @@ pub fn name_for_inst<'a>(
|
|||
Some(extension_for_middleware(middleware))
|
||||
};
|
||||
|
||||
let (deduped_slug, full_fs_name) =
|
||||
deduplicate_name_with_ext(&base, extension, taken_names);
|
||||
let (deduped_slug, full_fs_name) = deduplicate_name_with_ext(&base, extension, taken_names);
|
||||
let needs_meta = needs_slugify || deduped_slug != base;
|
||||
|
||||
let filename = full_fs_name.clone();
|
||||
|
|
|
|||
|
|
@ -1,42 +1,34 @@
|
|||
//! 3-pass instance matching algorithm for syncback.
|
||||
//! Instance matching algorithm for syncback.
|
||||
//!
|
||||
//! Pairs new children (from Roblox file) to old children (from filesystem)
|
||||
//! using progressively more expensive matching signals:
|
||||
//! by minimizing total reconciler changes.
|
||||
//!
|
||||
//! - **Pass 1** (O(n)): unique name matching + ClassName narrowing within
|
||||
//! same-name groups.
|
||||
//! - **Pass 2**: Ref property discriminators. If a Ref target is already
|
||||
//! matched in Pass 1, use it to differentiate candidates.
|
||||
//! - **Pass 3**: pairwise similarity scoring using precomputed subtree
|
||||
//! hashes. Greedy best-match-first with DOM child order as tiebreaker.
|
||||
//! Algorithm per parent:
|
||||
//! 1. Group by (Name, ClassName) -- 1:1 groups instant-match
|
||||
//! 2. Ambiguous groups: change-count scoring + greedy assignment
|
||||
//! (with hash fast-path: hash equal = 0 cost)
|
||||
//!
|
||||
//! The algorithm is designed for the syncback context where both "new" and
|
||||
//! "old" sides are WeakDom instances.
|
||||
//! The change count = how many things the reconciler would need to touch
|
||||
//! to turn instance A into instance B.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use blake3::Hash;
|
||||
use rbx_dom_weak::{
|
||||
types::{Ref, Variant},
|
||||
ustr, WeakDom,
|
||||
};
|
||||
use rbx_dom_weak::{types::Ref, WeakDom};
|
||||
|
||||
use crate::variant_eq::variant_eq;
|
||||
|
||||
const UNMATCHED_PENALTY: u32 = 10_000;
|
||||
|
||||
/// Result of the matching algorithm.
|
||||
#[derive(Debug)]
|
||||
pub struct MatchResult {
|
||||
/// Pairs of (new_ref, old_ref) that were matched.
|
||||
pub matched: Vec<(Ref, Ref)>,
|
||||
/// New children with no match on the old side (to be created).
|
||||
pub unmatched_new: Vec<Ref>,
|
||||
/// Old children with no match on the new side (to be deleted).
|
||||
pub unmatched_old: Vec<Ref>,
|
||||
}
|
||||
|
||||
/// Run the 3-pass matching algorithm.
|
||||
///
|
||||
/// `new_children` and `old_children` are the Refs of children under the same
|
||||
/// parent in their respective WeakDoms. `new_hashes` and `old_hashes` are
|
||||
/// optional precomputed subtree hashes (from `hash_tree()`).
|
||||
/// Match new children to old children, minimizing total changes.
|
||||
pub fn match_children(
|
||||
new_children: &[Ref],
|
||||
old_children: &[Ref],
|
||||
|
|
@ -57,35 +49,53 @@ pub fn match_children(
|
|||
let mut remaining_new: Vec<Ref> = new_children.to_vec();
|
||||
let mut remaining_old: Vec<Ref> = old_children.to_vec();
|
||||
|
||||
// ---- Pass 1: unique name matching + ClassName narrowing ----
|
||||
pass1_name_and_class(
|
||||
&mut remaining_new,
|
||||
&mut remaining_old,
|
||||
new_dom,
|
||||
old_dom,
|
||||
&mut matched,
|
||||
);
|
||||
|
||||
// Fast path: if all matched, skip Passes 2 and 3.
|
||||
if remaining_new.is_empty() || remaining_old.is_empty() {
|
||||
return MatchResult {
|
||||
matched,
|
||||
unmatched_new: remaining_new,
|
||||
unmatched_old: remaining_old,
|
||||
};
|
||||
// ================================================================
|
||||
// Fast-path: Group by (Name, ClassName) -- 1:1 groups instant-match
|
||||
// ================================================================
|
||||
let mut new_by_key: HashMap<(String, String), Vec<Ref>> = HashMap::new();
|
||||
for &r in &remaining_new {
|
||||
if let Some(inst) = new_dom.get_by_ref(r) {
|
||||
new_by_key
|
||||
.entry((inst.name.clone(), inst.class.to_string()))
|
||||
.or_default()
|
||||
.push(r);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Pass 2: Ref property discriminators ----
|
||||
// Snapshot the already-matched pairs so we can borrow matched mutably.
|
||||
let matched_snapshot: Vec<(Ref, Ref)> = matched.clone();
|
||||
pass2_ref_discriminators(
|
||||
&mut remaining_new,
|
||||
&mut remaining_old,
|
||||
new_dom,
|
||||
old_dom,
|
||||
&matched_snapshot,
|
||||
&mut matched,
|
||||
);
|
||||
let mut old_by_key: HashMap<(String, String), Vec<Ref>> = HashMap::new();
|
||||
for &r in &remaining_old {
|
||||
if let Some(inst) = old_dom.get_by_ref(r) {
|
||||
old_by_key
|
||||
.entry((inst.name.clone(), inst.class.to_string()))
|
||||
.or_default()
|
||||
.push(r);
|
||||
}
|
||||
}
|
||||
|
||||
let mut matched_new: HashSet<Ref> = HashSet::new();
|
||||
let mut matched_old: HashSet<Ref> = HashSet::new();
|
||||
|
||||
// 1:1 groups: instant match
|
||||
// Sort keys for deterministic iteration
|
||||
let mut sorted_keys: Vec<_> = new_by_key.keys().cloned().collect();
|
||||
sorted_keys.sort();
|
||||
|
||||
for key in &sorted_keys {
|
||||
let Some(new_refs) = new_by_key.get(key) else {
|
||||
continue;
|
||||
};
|
||||
let Some(old_refs) = old_by_key.get(key) else {
|
||||
continue;
|
||||
};
|
||||
if new_refs.len() == 1 && old_refs.len() == 1 {
|
||||
matched.push((new_refs[0], old_refs[0]));
|
||||
matched_new.insert(new_refs[0]);
|
||||
matched_old.insert(old_refs[0]);
|
||||
}
|
||||
}
|
||||
|
||||
remaining_new.retain(|r| !matched_new.contains(r));
|
||||
remaining_old.retain(|r| !matched_old.contains(r));
|
||||
|
||||
if remaining_new.is_empty() || remaining_old.is_empty() {
|
||||
return MatchResult {
|
||||
|
|
@ -95,18 +105,72 @@ pub fn match_children(
|
|||
};
|
||||
}
|
||||
|
||||
// ---- Pass 3: similarity scoring ----
|
||||
pass3_similarity(
|
||||
&mut remaining_new,
|
||||
&mut remaining_old,
|
||||
new_dom,
|
||||
old_dom,
|
||||
new_hashes,
|
||||
old_hashes,
|
||||
new_children,
|
||||
old_children,
|
||||
&mut matched,
|
||||
);
|
||||
// ================================================================
|
||||
// Ambiguous groups: change-count scoring + greedy assignment
|
||||
// ================================================================
|
||||
for key in &sorted_keys {
|
||||
let Some(new_refs) = new_by_key.get(key) else {
|
||||
continue;
|
||||
};
|
||||
let Some(old_refs) = old_by_key.get(key) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let avail_new: Vec<Ref> = new_refs
|
||||
.iter()
|
||||
.filter(|r| !matched_new.contains(r))
|
||||
.copied()
|
||||
.collect();
|
||||
let avail_old: Vec<Ref> = old_refs
|
||||
.iter()
|
||||
.filter(|r| !matched_old.contains(r))
|
||||
.copied()
|
||||
.collect();
|
||||
|
||||
if avail_new.is_empty() || avail_old.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle remaining 1:1 (from groups that had multiple but some were
|
||||
// already matched, leaving 1 on each side)
|
||||
if avail_new.len() == 1 && avail_old.len() == 1 {
|
||||
matched.push((avail_new[0], avail_old[0]));
|
||||
matched_new.insert(avail_new[0]);
|
||||
matched_old.insert(avail_old[0]);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Score all (A, B) pairs
|
||||
let mut pairs: Vec<(u32, Ref, Ref)> = Vec::new();
|
||||
let mut best_so_far = u32::MAX;
|
||||
|
||||
for &new_ref in &avail_new {
|
||||
for &old_ref in &avail_old {
|
||||
let cost =
|
||||
count_own_diffs(new_ref, old_ref, new_dom, old_dom, new_hashes, old_hashes);
|
||||
pairs.push((cost, new_ref, old_ref));
|
||||
if cost < best_so_far {
|
||||
best_so_far = cost;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stable sort by cost ascending
|
||||
pairs.sort_by_key(|&(cost, _, _)| cost);
|
||||
|
||||
// Greedy assign
|
||||
for &(_, new_ref, old_ref) in &pairs {
|
||||
if matched_new.contains(&new_ref) || matched_old.contains(&old_ref) {
|
||||
continue;
|
||||
}
|
||||
matched.push((new_ref, old_ref));
|
||||
matched_new.insert(new_ref);
|
||||
matched_old.insert(old_ref);
|
||||
}
|
||||
}
|
||||
|
||||
remaining_new.retain(|r| !matched_new.contains(r));
|
||||
remaining_old.retain(|r| !matched_old.contains(r));
|
||||
|
||||
MatchResult {
|
||||
matched,
|
||||
|
|
@ -115,325 +179,10 @@ pub fn match_children(
|
|||
}
|
||||
}
|
||||
|
||||
/// Pass 1: Match instances with unique names, then narrow same-name groups by
|
||||
/// ClassName.
|
||||
fn pass1_name_and_class(
|
||||
remaining_new: &mut Vec<Ref>,
|
||||
remaining_old: &mut Vec<Ref>,
|
||||
new_dom: &WeakDom,
|
||||
old_dom: &WeakDom,
|
||||
matched: &mut Vec<(Ref, Ref)>,
|
||||
) {
|
||||
// Build name→refs maps for both sides.
|
||||
let mut new_by_name: HashMap<String, Vec<Ref>> = HashMap::new();
|
||||
for &r in remaining_new.iter() {
|
||||
if let Some(inst) = new_dom.get_by_ref(r) {
|
||||
new_by_name
|
||||
.entry(inst.name.clone())
|
||||
.or_default()
|
||||
.push(r);
|
||||
}
|
||||
}
|
||||
let mut old_by_name: HashMap<String, Vec<Ref>> = HashMap::new();
|
||||
for &r in remaining_old.iter() {
|
||||
if let Some(inst) = old_dom.get_by_ref(r) {
|
||||
old_by_name
|
||||
.entry(inst.name.to_string())
|
||||
.or_default()
|
||||
.push(r);
|
||||
}
|
||||
}
|
||||
|
||||
let mut matched_new: HashSet<Ref> = HashSet::new();
|
||||
let mut matched_old: HashSet<Ref> = HashSet::new();
|
||||
|
||||
// Sort names for deterministic iteration order.
|
||||
let mut sorted_names: Vec<&String> = new_by_name.keys().collect();
|
||||
sorted_names.sort();
|
||||
|
||||
for name in sorted_names {
|
||||
let new_refs = &new_by_name[name];
|
||||
let Some(old_refs) = old_by_name.get(name.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Case 1: exactly one on each side → instant match.
|
||||
if new_refs.len() == 1 && old_refs.len() == 1 {
|
||||
matched.push((new_refs[0], old_refs[0]));
|
||||
matched_new.insert(new_refs[0]);
|
||||
matched_old.insert(old_refs[0]);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Case 2: multiple on at least one side → try ClassName narrowing.
|
||||
let mut new_by_class: HashMap<&str, Vec<Ref>> = HashMap::new();
|
||||
for &r in new_refs {
|
||||
if let Some(inst) = new_dom.get_by_ref(r) {
|
||||
new_by_class.entry(&inst.class).or_default().push(r);
|
||||
}
|
||||
}
|
||||
let mut old_by_class: HashMap<&str, Vec<Ref>> = HashMap::new();
|
||||
for &r in old_refs {
|
||||
if let Some(inst) = old_dom.get_by_ref(r) {
|
||||
old_by_class.entry(&inst.class).or_default().push(r);
|
||||
}
|
||||
}
|
||||
|
||||
for (class_name, new_class_refs) in &new_by_class {
|
||||
if let Some(old_class_refs) = old_by_class.get(class_name) {
|
||||
if new_class_refs.len() == 1 && old_class_refs.len() == 1 {
|
||||
matched.push((new_class_refs[0], old_class_refs[0]));
|
||||
matched_new.insert(new_class_refs[0]);
|
||||
matched_old.insert(old_class_refs[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
remaining_new.retain(|r| !matched_new.contains(r));
|
||||
remaining_old.retain(|r| !matched_old.contains(r));
|
||||
}
|
||||
|
||||
/// Pass 2: Use pre-resolved Ref properties to differentiate instances within
|
||||
/// same-name groups.
|
||||
fn pass2_ref_discriminators(
|
||||
remaining_new: &mut Vec<Ref>,
|
||||
remaining_old: &mut Vec<Ref>,
|
||||
new_dom: &WeakDom,
|
||||
old_dom: &WeakDom,
|
||||
already_matched: &[(Ref, Ref)],
|
||||
matched: &mut Vec<(Ref, Ref)>,
|
||||
) {
|
||||
if remaining_new.is_empty() || remaining_old.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Build bidirectional match maps: new→old and old→new.
|
||||
let new_to_old: HashMap<Ref, Ref> = already_matched.iter().copied().collect();
|
||||
let old_to_new: HashMap<Ref, Ref> = already_matched
|
||||
.iter()
|
||||
.map(|&(n, o)| (o, n))
|
||||
.collect();
|
||||
|
||||
// Collect Ref property targets for each remaining instance.
|
||||
let new_ref_targets: HashMap<Ref, Vec<Ref>> = remaining_new
|
||||
.iter()
|
||||
.map(|&r| (r, collect_ref_property_targets(new_dom, r)))
|
||||
.collect();
|
||||
let old_ref_targets: HashMap<Ref, Vec<Ref>> = remaining_old
|
||||
.iter()
|
||||
.map(|&r| (r, collect_ref_property_targets(old_dom, r)))
|
||||
.collect();
|
||||
|
||||
// Group remaining by name for matching within groups.
|
||||
let mut new_by_name: HashMap<String, Vec<Ref>> = HashMap::new();
|
||||
for &r in remaining_new.iter() {
|
||||
if let Some(inst) = new_dom.get_by_ref(r) {
|
||||
new_by_name
|
||||
.entry(inst.name.clone())
|
||||
.or_default()
|
||||
.push(r);
|
||||
}
|
||||
}
|
||||
let mut old_by_name: HashMap<String, Vec<Ref>> = HashMap::new();
|
||||
for &r in remaining_old.iter() {
|
||||
if let Some(inst) = old_dom.get_by_ref(r) {
|
||||
old_by_name
|
||||
.entry(inst.name.to_string())
|
||||
.or_default()
|
||||
.push(r);
|
||||
}
|
||||
}
|
||||
|
||||
let mut matched_new: HashSet<Ref> = HashSet::new();
|
||||
let mut matched_old: HashSet<Ref> = HashSet::new();
|
||||
|
||||
for (name, new_refs) in &new_by_name {
|
||||
let Some(old_refs) = old_by_name.get(name) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
for &new_ref in new_refs {
|
||||
if matched_new.contains(&new_ref) {
|
||||
continue;
|
||||
}
|
||||
let new_targets = &new_ref_targets[&new_ref];
|
||||
if new_targets.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let translated: Vec<Ref> = new_targets
|
||||
.iter()
|
||||
.filter_map(|t| new_to_old.get(t).copied())
|
||||
.collect();
|
||||
if translated.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut candidates: Vec<Ref> = Vec::new();
|
||||
for &old_ref in old_refs {
|
||||
if matched_old.contains(&old_ref) {
|
||||
continue;
|
||||
}
|
||||
let old_targets = &old_ref_targets[&old_ref];
|
||||
let old_translated: Vec<Ref> = old_targets
|
||||
.iter()
|
||||
.filter_map(|t| old_to_new.get(t).copied())
|
||||
.collect();
|
||||
|
||||
if translated.iter().any(|t| old_targets.contains(t))
|
||||
|| old_translated.iter().any(|t| new_targets.contains(t))
|
||||
{
|
||||
candidates.push(old_ref);
|
||||
}
|
||||
}
|
||||
|
||||
if candidates.len() == 1 {
|
||||
matched.push((new_ref, candidates[0]));
|
||||
matched_new.insert(new_ref);
|
||||
matched_old.insert(candidates[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
remaining_new.retain(|r| !matched_new.contains(r));
|
||||
remaining_old.retain(|r| !matched_old.contains(r));
|
||||
}
|
||||
|
||||
/// Pass 3: Similarity scoring using subtree hashes and greedy assignment.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn pass3_similarity(
|
||||
remaining_new: &mut Vec<Ref>,
|
||||
remaining_old: &mut Vec<Ref>,
|
||||
new_dom: &WeakDom,
|
||||
old_dom: &WeakDom,
|
||||
new_hashes: Option<&HashMap<Ref, Hash>>,
|
||||
old_hashes: Option<&HashMap<Ref, Hash>>,
|
||||
original_new_children: &[Ref],
|
||||
original_old_children: &[Ref],
|
||||
matched: &mut Vec<(Ref, Ref)>,
|
||||
) {
|
||||
if remaining_new.is_empty() || remaining_old.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Group remaining by name.
|
||||
let mut new_by_name: HashMap<String, Vec<Ref>> = HashMap::new();
|
||||
for &r in remaining_new.iter() {
|
||||
if let Some(inst) = new_dom.get_by_ref(r) {
|
||||
new_by_name
|
||||
.entry(inst.name.clone())
|
||||
.or_default()
|
||||
.push(r);
|
||||
}
|
||||
}
|
||||
let mut old_by_name: HashMap<String, Vec<Ref>> = HashMap::new();
|
||||
for &r in remaining_old.iter() {
|
||||
if let Some(inst) = old_dom.get_by_ref(r) {
|
||||
old_by_name
|
||||
.entry(inst.name.to_string())
|
||||
.or_default()
|
||||
.push(r);
|
||||
}
|
||||
}
|
||||
|
||||
let mut matched_new: HashSet<Ref> = HashSet::new();
|
||||
let mut matched_old: HashSet<Ref> = HashSet::new();
|
||||
|
||||
for (name, new_refs) in &new_by_name {
|
||||
let Some(old_refs) = old_by_name.get(name) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Narrow by ClassName within this name group.
|
||||
let mut new_by_class: HashMap<&str, Vec<Ref>> = HashMap::new();
|
||||
for &r in new_refs {
|
||||
if matched_new.contains(&r) {
|
||||
continue;
|
||||
}
|
||||
if let Some(inst) = new_dom.get_by_ref(r) {
|
||||
new_by_class.entry(&inst.class).or_default().push(r);
|
||||
}
|
||||
}
|
||||
let mut old_by_class: HashMap<&str, Vec<Ref>> = HashMap::new();
|
||||
for &r in old_refs {
|
||||
if matched_old.contains(&r) {
|
||||
continue;
|
||||
}
|
||||
if let Some(inst) = old_dom.get_by_ref(r) {
|
||||
old_by_class.entry(&inst.class).or_default().push(r);
|
||||
}
|
||||
}
|
||||
|
||||
// Within same-name+class sub-groups: try hash-based matching first,
|
||||
// then fall back to positional matching.
|
||||
for (class, new_class_refs) in &new_by_class {
|
||||
let Some(old_class_refs) = old_by_class.get(class) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// First pass: match by hash equality (strong signal).
|
||||
let mut hash_matched_new: HashSet<Ref> = HashSet::new();
|
||||
let mut hash_matched_old: HashSet<Ref> = HashSet::new();
|
||||
if let (Some(nh), Some(oh)) = (new_hashes, old_hashes) {
|
||||
for &new_ref in new_class_refs {
|
||||
if matched_new.contains(&new_ref) || hash_matched_new.contains(&new_ref) {
|
||||
continue;
|
||||
}
|
||||
if let Some(new_hash) = nh.get(&new_ref) {
|
||||
for &old_ref in old_class_refs {
|
||||
if matched_old.contains(&old_ref) || hash_matched_old.contains(&old_ref)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if let Some(old_hash) = oh.get(&old_ref) {
|
||||
if new_hash == old_hash {
|
||||
matched.push((new_ref, old_ref));
|
||||
matched_new.insert(new_ref);
|
||||
matched_old.insert(old_ref);
|
||||
hash_matched_new.insert(new_ref);
|
||||
hash_matched_old.insert(old_ref);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: positional matching for remaining unmatched.
|
||||
// The Nth unmatched new ref matches the Nth unmatched old ref.
|
||||
let remaining_new_class: Vec<Ref> = new_class_refs
|
||||
.iter()
|
||||
.filter(|r| !matched_new.contains(r))
|
||||
.copied()
|
||||
.collect();
|
||||
let remaining_old_class: Vec<Ref> = old_class_refs
|
||||
.iter()
|
||||
.filter(|r| !matched_old.contains(r))
|
||||
.copied()
|
||||
.collect();
|
||||
let match_count = remaining_new_class.len().min(remaining_old_class.len());
|
||||
for idx in 0..match_count {
|
||||
let new_ref = remaining_new_class[idx];
|
||||
let old_ref = remaining_old_class[idx];
|
||||
if !matched_new.contains(&new_ref) && !matched_old.contains(&old_ref) {
|
||||
matched.push((new_ref, old_ref));
|
||||
matched_new.insert(new_ref);
|
||||
matched_old.insert(old_ref);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
remaining_new.retain(|r| !matched_new.contains(r));
|
||||
remaining_old.retain(|r| !matched_old.contains(r));
|
||||
}
|
||||
|
||||
/// Compute a similarity score between two instances.
|
||||
/// Higher = more similar. Signals checked in priority order:
|
||||
/// ClassName (100), Tags (50), Attributes (30), Hash equality (1000),
|
||||
/// Properties overlap (10 per match).
|
||||
fn compute_similarity(
|
||||
/// Count own property diffs between two WeakDom instances.
|
||||
/// Hash fast-path: if hashes match, return 0 (identical, no property check needed).
|
||||
/// Each differing property = +1. Children count diff = +1.
|
||||
fn count_own_diffs(
|
||||
new_ref: Ref,
|
||||
old_ref: Ref,
|
||||
new_dom: &WeakDom,
|
||||
|
|
@ -441,76 +190,50 @@ fn compute_similarity(
|
|||
new_hashes: Option<&HashMap<Ref, Hash>>,
|
||||
old_hashes: Option<&HashMap<Ref, Hash>>,
|
||||
) -> u32 {
|
||||
let new_inst = match new_dom.get_by_ref(new_ref) {
|
||||
Some(i) => i,
|
||||
None => return 0,
|
||||
};
|
||||
let old_inst = match old_dom.get_by_ref(old_ref) {
|
||||
Some(i) => i,
|
||||
None => return 0,
|
||||
};
|
||||
|
||||
let mut score: u32 = 0;
|
||||
|
||||
// ClassName match is a strong signal.
|
||||
if new_inst.class == old_inst.class {
|
||||
score += 100;
|
||||
}
|
||||
|
||||
// Hash equality: very likely identical content.
|
||||
// Hash fast-path: identical subtree = 0 cost
|
||||
if let (Some(nh), Some(oh)) = (new_hashes, old_hashes) {
|
||||
if let (Some(new_hash), Some(old_hash)) = (nh.get(&new_ref), oh.get(&old_ref)) {
|
||||
if new_hash == old_hash {
|
||||
score += 1000;
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tags comparison.
|
||||
if let (Some(Variant::Tags(new_tags)), Some(Variant::Tags(old_tags))) = (
|
||||
new_inst.properties.get(&ustr("Tags")),
|
||||
old_inst.properties.get(&ustr("Tags")),
|
||||
) {
|
||||
if new_tags == old_tags {
|
||||
score += 50;
|
||||
}
|
||||
}
|
||||
|
||||
// Attributes comparison.
|
||||
if let (Some(Variant::Attributes(new_attrs)), Some(Variant::Attributes(old_attrs))) = (
|
||||
new_inst.properties.get(&ustr("Attributes")),
|
||||
old_inst.properties.get(&ustr("Attributes")),
|
||||
) {
|
||||
if new_attrs == old_attrs {
|
||||
score += 30;
|
||||
}
|
||||
}
|
||||
|
||||
// Children count similarity (cheap structural signal).
|
||||
let new_child_count = new_inst.children().len();
|
||||
let old_child_count = old_inst.children().len();
|
||||
if new_child_count == old_child_count {
|
||||
score += 20;
|
||||
}
|
||||
|
||||
score
|
||||
}
|
||||
|
||||
/// Collect all Ref-valued property targets for an instance.
|
||||
fn collect_ref_property_targets(dom: &WeakDom, inst_ref: Ref) -> Vec<Ref> {
|
||||
let inst = match dom.get_by_ref(inst_ref) {
|
||||
let new_inst = match new_dom.get_by_ref(new_ref) {
|
||||
Some(i) => i,
|
||||
None => return Vec::new(),
|
||||
None => return UNMATCHED_PENALTY,
|
||||
};
|
||||
let mut targets = Vec::new();
|
||||
for value in inst.properties.values() {
|
||||
if let Variant::Ref(target) = value {
|
||||
if !target.is_none() {
|
||||
targets.push(*target);
|
||||
let old_inst = match old_dom.get_by_ref(old_ref) {
|
||||
Some(i) => i,
|
||||
None => return UNMATCHED_PENALTY,
|
||||
};
|
||||
|
||||
let mut cost: u32 = 0;
|
||||
|
||||
// Properties present on new side
|
||||
for (key, new_val) in new_inst.properties.iter() {
|
||||
if let Some(old_val) = old_inst.properties.get(key) {
|
||||
if !variant_eq(new_val, old_val) {
|
||||
cost += 1;
|
||||
}
|
||||
} else {
|
||||
cost += 1;
|
||||
}
|
||||
}
|
||||
targets
|
||||
|
||||
// Properties present only on old side
|
||||
for key in old_inst.properties.keys() {
|
||||
if !new_inst.properties.contains_key(key) {
|
||||
cost += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Children count diff
|
||||
if new_inst.children().len() != old_inst.children().len() {
|
||||
cost += 1;
|
||||
}
|
||||
|
||||
cost
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -528,19 +251,11 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn unique_names_match_instantly() {
|
||||
let (new_dom, new_root, new_a, new_b) = build_test_dom();
|
||||
let (old_dom, old_root, old_a, old_b) = build_test_dom();
|
||||
let (new_dom, new_root, _, _) = build_test_dom();
|
||||
let (old_dom, old_root, _, _) = build_test_dom();
|
||||
|
||||
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 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);
|
||||
|
||||
|
|
@ -548,7 +263,6 @@ mod tests {
|
|||
assert!(result.unmatched_new.is_empty());
|
||||
assert!(result.unmatched_old.is_empty());
|
||||
|
||||
// Alpha matched to Alpha, Beta to Beta
|
||||
for (new_ref, old_ref) in &result.matched {
|
||||
let new_name = &new_dom.get_by_ref(*new_ref).unwrap().name;
|
||||
let old_name = &old_dom.get_by_ref(*old_ref).unwrap().name;
|
||||
|
|
@ -557,48 +271,35 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn class_name_narrowing() {
|
||||
// Two instances named "Handler" but different classes
|
||||
fn class_name_grouping() {
|
||||
let mut new_dom = WeakDom::new(InstanceBuilder::new("DataModel"));
|
||||
let new_root = new_dom.root_ref();
|
||||
let new_a = new_dom.insert(
|
||||
new_dom.insert(
|
||||
new_root,
|
||||
InstanceBuilder::new("Script").with_name("Handler"),
|
||||
);
|
||||
let new_b = new_dom.insert(
|
||||
new_dom.insert(
|
||||
new_root,
|
||||
InstanceBuilder::new("ModuleScript").with_name("Handler"),
|
||||
);
|
||||
|
||||
let mut old_dom = WeakDom::new(InstanceBuilder::new("DataModel"));
|
||||
let old_root = old_dom.root_ref();
|
||||
let old_a = old_dom.insert(
|
||||
old_dom.insert(
|
||||
old_root,
|
||||
InstanceBuilder::new("ModuleScript").with_name("Handler"),
|
||||
);
|
||||
let old_b = old_dom.insert(
|
||||
old_dom.insert(
|
||||
old_root,
|
||||
InstanceBuilder::new("Script").with_name("Handler"),
|
||||
);
|
||||
|
||||
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 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);
|
||||
|
||||
assert_eq!(result.matched.len(), 2);
|
||||
assert!(result.unmatched_new.is_empty());
|
||||
assert!(result.unmatched_old.is_empty());
|
||||
|
||||
// Verify Script matched to Script, ModuleScript to ModuleScript.
|
||||
for (new_ref, old_ref) in &result.matched {
|
||||
let new_class = &new_dom.get_by_ref(*new_ref).unwrap().class;
|
||||
let old_class = &old_dom.get_by_ref(*old_ref).unwrap().class;
|
||||
|
|
@ -610,10 +311,7 @@ mod tests {
|
|||
fn unmatched_new_and_old() {
|
||||
let mut new_dom = WeakDom::new(InstanceBuilder::new("DataModel"));
|
||||
let new_root = new_dom.root_ref();
|
||||
new_dom.insert(
|
||||
new_root,
|
||||
InstanceBuilder::new("Folder").with_name("Alpha"),
|
||||
);
|
||||
new_dom.insert(new_root, InstanceBuilder::new("Folder").with_name("Alpha"));
|
||||
new_dom.insert(
|
||||
new_root,
|
||||
InstanceBuilder::new("Folder").with_name("NewOnly"),
|
||||
|
|
@ -621,85 +319,38 @@ mod tests {
|
|||
|
||||
let mut old_dom = WeakDom::new(InstanceBuilder::new("DataModel"));
|
||||
let old_root = old_dom.root_ref();
|
||||
old_dom.insert(
|
||||
old_root,
|
||||
InstanceBuilder::new("Folder").with_name("Alpha"),
|
||||
);
|
||||
old_dom.insert(old_root, InstanceBuilder::new("Folder").with_name("Alpha"));
|
||||
old_dom.insert(
|
||||
old_root,
|
||||
InstanceBuilder::new("Folder").with_name("OldOnly"),
|
||||
);
|
||||
|
||||
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 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);
|
||||
|
||||
assert_eq!(result.matched.len(), 1);
|
||||
assert_eq!(result.unmatched_new.len(), 1);
|
||||
assert_eq!(result.unmatched_old.len(), 1);
|
||||
|
||||
let unmatched_new_name = &new_dom
|
||||
.get_by_ref(result.unmatched_new[0])
|
||||
.unwrap()
|
||||
.name;
|
||||
assert_eq!(unmatched_new_name, "NewOnly");
|
||||
|
||||
let unmatched_old_name = &old_dom
|
||||
.get_by_ref(result.unmatched_old[0])
|
||||
.unwrap()
|
||||
.name;
|
||||
assert_eq!(unmatched_old_name, "OldOnly");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_names_fall_to_pass3() {
|
||||
// Two Folders named "Data" on each side, same class.
|
||||
// Pass 1 can't distinguish, Pass 3 uses greedy assignment.
|
||||
fn duplicate_names_matched() {
|
||||
let mut new_dom = WeakDom::new(InstanceBuilder::new("DataModel"));
|
||||
let new_root = new_dom.root_ref();
|
||||
new_dom.insert(
|
||||
new_root,
|
||||
InstanceBuilder::new("Folder").with_name("Data"),
|
||||
);
|
||||
new_dom.insert(
|
||||
new_root,
|
||||
InstanceBuilder::new("Folder").with_name("Data"),
|
||||
);
|
||||
new_dom.insert(new_root, InstanceBuilder::new("Folder").with_name("Data"));
|
||||
new_dom.insert(new_root, InstanceBuilder::new("Folder").with_name("Data"));
|
||||
|
||||
let mut old_dom = WeakDom::new(InstanceBuilder::new("DataModel"));
|
||||
let old_root = old_dom.root_ref();
|
||||
old_dom.insert(
|
||||
old_root,
|
||||
InstanceBuilder::new("Folder").with_name("Data"),
|
||||
);
|
||||
old_dom.insert(
|
||||
old_root,
|
||||
InstanceBuilder::new("Folder").with_name("Data"),
|
||||
);
|
||||
old_dom.insert(old_root, InstanceBuilder::new("Folder").with_name("Data"));
|
||||
old_dom.insert(old_root, InstanceBuilder::new("Folder").with_name("Data"));
|
||||
|
||||
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 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);
|
||||
|
||||
// Both should be matched (greedy assignment).
|
||||
assert_eq!(result.matched.len(), 2);
|
||||
assert!(result.unmatched_new.is_empty());
|
||||
assert!(result.unmatched_old.is_empty());
|
||||
|
|
@ -709,73 +360,24 @@ mod tests {
|
|||
fn empty_children() {
|
||||
let new_dom = WeakDom::new(InstanceBuilder::new("DataModel"));
|
||||
let old_dom = WeakDom::new(InstanceBuilder::new("DataModel"));
|
||||
|
||||
let result = match_children(&[], &[], &new_dom, &old_dom, None, None);
|
||||
|
||||
assert!(result.matched.is_empty());
|
||||
assert!(result.unmatched_new.is_empty());
|
||||
assert!(result.unmatched_old.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_new_no_old() {
|
||||
let mut new_dom = WeakDom::new(InstanceBuilder::new("DataModel"));
|
||||
let new_root = new_dom.root_ref();
|
||||
new_dom.insert(
|
||||
new_root,
|
||||
InstanceBuilder::new("Folder").with_name("A"),
|
||||
);
|
||||
|
||||
let old_dom = WeakDom::new(InstanceBuilder::new("DataModel"));
|
||||
|
||||
let new_children: Vec<Ref> = new_dom
|
||||
.get_by_ref(new_root)
|
||||
.unwrap()
|
||||
.children()
|
||||
.to_vec();
|
||||
|
||||
let result = match_children(&new_children, &[], &new_dom, &old_dom, None, None);
|
||||
|
||||
assert!(result.matched.is_empty());
|
||||
assert_eq!(result.unmatched_new.len(), 1);
|
||||
assert!(result.unmatched_old.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matching_stability() {
|
||||
// Run the same matching twice; verify identical results.
|
||||
let mut new_dom = WeakDom::new(InstanceBuilder::new("DataModel"));
|
||||
let new_root = new_dom.root_ref();
|
||||
new_dom.insert(
|
||||
new_root,
|
||||
InstanceBuilder::new("Folder").with_name("X"),
|
||||
);
|
||||
new_dom.insert(
|
||||
new_root,
|
||||
InstanceBuilder::new("Script").with_name("Y"),
|
||||
);
|
||||
new_dom.insert(new_root, InstanceBuilder::new("Folder").with_name("X"));
|
||||
new_dom.insert(new_root, InstanceBuilder::new("Script").with_name("Y"));
|
||||
|
||||
let mut old_dom = WeakDom::new(InstanceBuilder::new("DataModel"));
|
||||
let old_root = old_dom.root_ref();
|
||||
old_dom.insert(
|
||||
old_root,
|
||||
InstanceBuilder::new("Folder").with_name("X"),
|
||||
);
|
||||
old_dom.insert(
|
||||
old_root,
|
||||
InstanceBuilder::new("Script").with_name("Y"),
|
||||
);
|
||||
old_dom.insert(old_root, InstanceBuilder::new("Folder").with_name("X"));
|
||||
old_dom.insert(old_root, InstanceBuilder::new("Script").with_name("Y"));
|
||||
|
||||
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 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 r1 = match_children(&new_children, &old_children, &new_dom, &old_dom, None, None);
|
||||
let r2 = match_children(&new_children, &old_children, &new_dom, &old_dom, None, None);
|
||||
|
|
|
|||
|
|
@ -66,13 +66,7 @@ fn tentative_fs_name(inst: &Instance) -> String {
|
|||
// Directory-style classes never get extensions
|
||||
let is_container = matches!(
|
||||
inst.class.as_str(),
|
||||
"Folder"
|
||||
| "Configuration"
|
||||
| "Tool"
|
||||
| "ScreenGui"
|
||||
| "SurfaceGui"
|
||||
| "BillboardGui"
|
||||
| "AdGui"
|
||||
"Folder" | "Configuration" | "Tool" | "ScreenGui" | "SurfaceGui" | "BillboardGui" | "AdGui"
|
||||
);
|
||||
|
||||
if is_container || (has_children && is_script_class(inst.class.as_str())) {
|
||||
|
|
@ -132,10 +126,7 @@ fn tentative_fs_name(inst: &Instance) -> String {
|
|||
}
|
||||
|
||||
fn is_script_class(class: &str) -> bool {
|
||||
matches!(
|
||||
class,
|
||||
"Script" | "LocalScript" | "ModuleScript"
|
||||
)
|
||||
matches!(class, "Script" | "LocalScript" | "ModuleScript")
|
||||
}
|
||||
|
||||
pub struct RefLinks {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue