mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-07-12 03:28:13 -07:00
global: snapshot
This commit is contained in:
@@ -1,201 +1,17 @@
|
||||
//! Vec name deconstruction and reconstruction logic.
|
||||
//! Common prefix/suffix detection for metric names.
|
||||
//!
|
||||
//! This module analyzes vec names bottom-up to detect common denominators
|
||||
//! (prefixes or suffixes) and field positions for pattern instances.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::FieldNamePosition;
|
||||
|
||||
/// Common denominator found across children's effective names.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum CommonDenominator {
|
||||
/// Children share this prefix. Fields append their unique suffix.
|
||||
/// Example: children are ["addrs_0sats", "addrs_1sats"], common = "addrs_"
|
||||
Prefix(String),
|
||||
/// Children share this suffix. Fields prepend their unique prefix.
|
||||
/// Example: children are ["cumulative_supply", "net_supply"], common = "_supply"
|
||||
Suffix(String),
|
||||
/// No common part found. Fields use Identity (field = base).
|
||||
None,
|
||||
}
|
||||
|
||||
/// Result of analyzing a pattern level.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PatternAnalysis {
|
||||
/// The common prefix/suffix found across all children.
|
||||
pub common: CommonDenominator,
|
||||
/// What's left after stripping the common part (passed to parent).
|
||||
pub base: String,
|
||||
/// How each field modifies the accumulated name.
|
||||
pub field_positions: HashMap<String, FieldNamePosition>,
|
||||
}
|
||||
|
||||
/// Analyze a pattern level using child effective names.
|
||||
///
|
||||
/// This is the core algorithm that detects common prefix/suffix and
|
||||
/// determines field positions for each child.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `child_names` - Vec of (field_name, effective_name) pairs
|
||||
/// where effective_name is either:
|
||||
/// - For leaves: the leaf's vec name
|
||||
/// - For branches: the base returned by analyzing that branch
|
||||
pub fn analyze_pattern_level(child_names: &[(String, String)]) -> PatternAnalysis {
|
||||
if child_names.is_empty() {
|
||||
return PatternAnalysis {
|
||||
common: CommonDenominator::None,
|
||||
base: String::new(),
|
||||
field_positions: HashMap::new(),
|
||||
};
|
||||
}
|
||||
|
||||
if child_names.len() == 1 {
|
||||
let (field_name, effective) = &child_names[0];
|
||||
let mut positions = HashMap::new();
|
||||
|
||||
// Try suffix match: effective ends with "_fieldname"
|
||||
let suffix_pattern = format!("_{}", field_name);
|
||||
if let Some(base) = effective.strip_suffix(&suffix_pattern) {
|
||||
positions.insert(
|
||||
field_name.clone(),
|
||||
FieldNamePosition::Append(suffix_pattern),
|
||||
);
|
||||
return PatternAnalysis {
|
||||
common: CommonDenominator::None,
|
||||
base: base.to_string(),
|
||||
field_positions: positions,
|
||||
};
|
||||
}
|
||||
|
||||
// Try prefix match: effective starts with "fieldname_"
|
||||
let prefix_pattern = format!("{}_", field_name);
|
||||
if let Some(base) = effective.strip_prefix(&prefix_pattern) {
|
||||
positions.insert(
|
||||
field_name.clone(),
|
||||
FieldNamePosition::Prepend(prefix_pattern),
|
||||
);
|
||||
return PatternAnalysis {
|
||||
common: CommonDenominator::None,
|
||||
base: base.to_string(),
|
||||
field_positions: positions,
|
||||
};
|
||||
}
|
||||
|
||||
// Field equals effective OR field doesn't appear → Identity
|
||||
// Root-level instances where field == effective are handled by
|
||||
// passing empty `acc` and conditional position expressions
|
||||
positions.insert(field_name.clone(), FieldNamePosition::Identity);
|
||||
return PatternAnalysis {
|
||||
common: CommonDenominator::None,
|
||||
base: effective.clone(),
|
||||
field_positions: positions,
|
||||
};
|
||||
}
|
||||
|
||||
let effective_names: Vec<&str> = child_names.iter().map(|(_, n)| n.as_str()).collect();
|
||||
|
||||
// Try to find common prefix first
|
||||
if let Some(prefix) = find_common_prefix(&effective_names)
|
||||
&& !prefix.is_empty()
|
||||
{
|
||||
let base = prefix.trim_end_matches('_').to_string();
|
||||
let mut positions = HashMap::new();
|
||||
for (field_name, effective) in child_names {
|
||||
// If effective equals the base (prefix without underscore), use Identity
|
||||
if effective == &base {
|
||||
positions.insert(field_name.clone(), FieldNamePosition::Identity);
|
||||
} else if let Some(suffix) = effective.strip_prefix(&prefix) {
|
||||
// Normal case: effective has the full prefix
|
||||
let suffix_with_underscore = if suffix.starts_with('_') {
|
||||
suffix.to_string()
|
||||
} else {
|
||||
format!("_{}", suffix)
|
||||
};
|
||||
positions.insert(
|
||||
field_name.clone(),
|
||||
FieldNamePosition::Append(suffix_with_underscore),
|
||||
);
|
||||
} else {
|
||||
// Fallback: use Identity if strip_prefix fails unexpectedly
|
||||
positions.insert(field_name.clone(), FieldNamePosition::Identity);
|
||||
}
|
||||
}
|
||||
return PatternAnalysis {
|
||||
common: CommonDenominator::Prefix(prefix),
|
||||
base,
|
||||
field_positions: positions,
|
||||
};
|
||||
}
|
||||
|
||||
// Try to find common suffix
|
||||
if let Some(suffix) = find_common_suffix(&effective_names)
|
||||
&& !suffix.is_empty()
|
||||
{
|
||||
let mut positions = HashMap::new();
|
||||
for (field_name, effective) in child_names {
|
||||
let prefix = effective
|
||||
.strip_suffix(&suffix)
|
||||
.unwrap_or(effective)
|
||||
.to_string();
|
||||
let prefix_with_underscore = if prefix.ends_with('_') {
|
||||
prefix
|
||||
} else {
|
||||
format!("{}_", prefix)
|
||||
};
|
||||
positions.insert(
|
||||
field_name.clone(),
|
||||
FieldNamePosition::Prepend(prefix_with_underscore),
|
||||
);
|
||||
}
|
||||
let base = suffix.trim_start_matches('_').to_string();
|
||||
return PatternAnalysis {
|
||||
common: CommonDenominator::Suffix(suffix),
|
||||
base,
|
||||
field_positions: positions,
|
||||
};
|
||||
}
|
||||
|
||||
// No common part - use Identity for all fields
|
||||
let mut positions = HashMap::new();
|
||||
for (field_name, _) in child_names {
|
||||
positions.insert(field_name.clone(), FieldNamePosition::Identity);
|
||||
}
|
||||
|
||||
// Check if all fields are "true Identity" (field_name == effective_name)
|
||||
// In that case, the base should be empty since metrics are accessed directly by field name
|
||||
let all_true_identity = child_names
|
||||
.iter()
|
||||
.all(|(field_name, effective)| field_name == effective);
|
||||
|
||||
let base = if all_true_identity {
|
||||
String::new()
|
||||
} else {
|
||||
// Use the first name as base (they're all independent but have different names)
|
||||
child_names
|
||||
.first()
|
||||
.map(|(_, n)| n.clone())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
PatternAnalysis {
|
||||
common: CommonDenominator::None,
|
||||
base,
|
||||
field_positions: positions,
|
||||
}
|
||||
}
|
||||
//! This module provides utilities to find common prefixes and suffixes
|
||||
//! among metric names, which is used to detect pattern mode (suffix vs prefix).
|
||||
|
||||
/// Find the longest common prefix among all strings.
|
||||
/// The prefix must end at an underscore boundary for semantic coherence.
|
||||
fn find_common_prefix(names: &[&str]) -> Option<String> {
|
||||
if names.is_empty() {
|
||||
/// Returns the prefix WITH trailing underscore if found at word boundary.
|
||||
/// Returns None if no common prefix exists.
|
||||
pub fn find_common_prefix(names: &[&str]) -> Option<String> {
|
||||
if names.is_empty() || names.iter().any(|n| n.is_empty()) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let first = names[0];
|
||||
if first.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Find character-by-character common prefix
|
||||
let mut prefix_len = 0;
|
||||
@@ -213,48 +29,41 @@ fn find_common_prefix(names: &[&str]) -> Option<String> {
|
||||
|
||||
let raw_prefix = &first[..prefix_len];
|
||||
|
||||
// If raw_prefix exactly matches one of the names, it's a complete metric name.
|
||||
// In this case, return it with trailing underscore to preserve the full name.
|
||||
// Must end at underscore boundary for semantic coherence
|
||||
if raw_prefix.ends_with('_') {
|
||||
return Some(raw_prefix.to_string());
|
||||
}
|
||||
|
||||
// If raw_prefix equals one of the full names (one name is a prefix of all others),
|
||||
// return it with trailing underscore for proper base detection
|
||||
if names.contains(&raw_prefix) {
|
||||
return Some(format!("{}_", raw_prefix));
|
||||
}
|
||||
|
||||
// Find the last underscore position to get a clean boundary
|
||||
// Prefer ending at an underscore for semantic coherence
|
||||
if let Some(last_underscore) = raw_prefix.rfind('_')
|
||||
&& last_underscore > 0
|
||||
{
|
||||
// Find the last underscore position
|
||||
if let Some(last_underscore) = raw_prefix.rfind('_') {
|
||||
let clean_prefix = &first[..=last_underscore];
|
||||
// Verify this still works for all names
|
||||
if names.iter().all(|n| n.starts_with(clean_prefix)) {
|
||||
return Some(clean_prefix.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// If no underscore boundary works, the full prefix must end at an underscore
|
||||
if raw_prefix.ends_with('_') {
|
||||
return Some(raw_prefix.to_string());
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Find the longest common suffix among all strings.
|
||||
/// The suffix must start at an underscore boundary for semantic coherence.
|
||||
fn find_common_suffix(names: &[&str]) -> Option<String> {
|
||||
if names.is_empty() {
|
||||
/// Returns the suffix WITH leading underscore if found at word boundary.
|
||||
/// Returns None if no common suffix exists.
|
||||
pub fn find_common_suffix(names: &[&str]) -> Option<String> {
|
||||
if names.is_empty() || names.iter().any(|n| n.is_empty()) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let first = names[0];
|
||||
if first.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let first_chars: Vec<char> = first.chars().collect();
|
||||
|
||||
// Find character-by-character common suffix (from the end)
|
||||
let first_chars: Vec<char> = first.chars().collect();
|
||||
let mut suffix_len = 0;
|
||||
|
||||
for i in 0..first_chars.len() {
|
||||
let idx_from_end = first_chars.len() - 1 - i;
|
||||
let ch = first_chars[idx_from_end];
|
||||
@@ -280,22 +89,34 @@ fn find_common_suffix(names: &[&str]) -> Option<String> {
|
||||
|
||||
let raw_suffix = &first[first.len() - suffix_len..];
|
||||
|
||||
// Find the first underscore position to get a clean boundary
|
||||
if let Some(first_underscore) = raw_suffix.find('_')
|
||||
&& first_underscore < raw_suffix.len() - 1
|
||||
{
|
||||
// Must start at underscore boundary for semantic coherence
|
||||
if raw_suffix.starts_with('_') {
|
||||
return Some(raw_suffix.to_string());
|
||||
}
|
||||
|
||||
// Check if preceded by underscore in all names (word boundary)
|
||||
let at_word_boundary = names.iter().all(|n| {
|
||||
if *n == raw_suffix {
|
||||
true // Suffix is the whole string
|
||||
} else if let Some(prefix) = n.strip_suffix(raw_suffix) {
|
||||
prefix.ends_with('_')
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
|
||||
if at_word_boundary {
|
||||
return Some(format!("_{}", raw_suffix));
|
||||
}
|
||||
|
||||
// Find the first underscore position in suffix
|
||||
if let Some(first_underscore) = raw_suffix.find('_') {
|
||||
let clean_suffix = &raw_suffix[first_underscore..];
|
||||
// Verify this still works for all names
|
||||
if names.iter().all(|n| n.ends_with(clean_suffix)) {
|
||||
return Some(clean_suffix.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// If no underscore boundary works, the full suffix must start with underscore
|
||||
if raw_suffix.starts_with('_') {
|
||||
return Some(raw_suffix.to_string());
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
@@ -304,187 +125,59 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_common_prefix() {
|
||||
fn test_common_prefix_basic() {
|
||||
let names = vec!["addrs_0sats", "addrs_1sats", "addrs_2sats"];
|
||||
assert_eq!(find_common_prefix(&names), Some("addrs_".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_common_suffix() {
|
||||
fn test_common_prefix_none() {
|
||||
let names = vec!["foo", "bar", "baz"];
|
||||
assert_eq!(find_common_prefix(&names), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_common_prefix_lth() {
|
||||
let names = vec!["lth_cost_basis_max", "lth_cost_basis_min", "lth_cost_basis"];
|
||||
assert_eq!(find_common_prefix(&names), Some("lth_cost_basis_".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_common_suffix_basic() {
|
||||
let names = vec!["cumulative_supply", "net_supply", "total_supply"];
|
||||
assert_eq!(find_common_suffix(&names), Some("_supply".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_common() {
|
||||
fn test_common_prefix_cost_basis() {
|
||||
// With suffix naming convention, cost_basis variants share a common prefix
|
||||
let names = vec!["cost_basis_max", "cost_basis_min", "cost_basis"];
|
||||
assert_eq!(find_common_prefix(&names), Some("cost_basis_".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_common_suffix_none() {
|
||||
let names = vec!["foo", "bar", "baz"];
|
||||
assert_eq!(find_common_prefix(&names), None);
|
||||
assert_eq!(find_common_suffix(&names), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_analyze_pattern_level_prefix() {
|
||||
let children = vec![
|
||||
("_0sats".to_string(), "addrs_0sats".to_string()),
|
||||
("_1sats".to_string(), "addrs_1sats".to_string()),
|
||||
fn test_common_prefix_one_is_prefix_of_other() {
|
||||
// When one name is a prefix of another (block_count vs block_count_cumulative)
|
||||
let names = vec!["block_count_cumulative", "block_count"];
|
||||
assert_eq!(find_common_prefix(&names), Some("block_count_".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_common_suffix_realized_loss() {
|
||||
let names = vec![
|
||||
"cumulative_realized_loss",
|
||||
"net_realized_loss",
|
||||
"realized_loss",
|
||||
];
|
||||
let analysis = analyze_pattern_level(&children);
|
||||
|
||||
assert!(matches!(analysis.common, CommonDenominator::Prefix(_)));
|
||||
assert_eq!(analysis.base, "addrs");
|
||||
assert!(matches!(
|
||||
analysis.field_positions.get("_0sats"),
|
||||
Some(FieldNamePosition::Append(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_analyze_pattern_level_suffix() {
|
||||
let children = vec![
|
||||
("cumulative".to_string(), "cumulative_supply".to_string()),
|
||||
("net".to_string(), "net_supply".to_string()),
|
||||
];
|
||||
let analysis = analyze_pattern_level(&children);
|
||||
|
||||
assert!(matches!(analysis.common, CommonDenominator::Suffix(_)));
|
||||
assert_eq!(analysis.base, "supply");
|
||||
assert!(matches!(
|
||||
analysis.field_positions.get("cumulative"),
|
||||
Some(FieldNamePosition::Prepend(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_single_child_suffix() {
|
||||
// Field "count" appears as suffix "_count" in "activity_count"
|
||||
let children = vec![("count".to_string(), "activity_count".to_string())];
|
||||
let analysis = analyze_pattern_level(&children);
|
||||
|
||||
assert!(matches!(analysis.common, CommonDenominator::None));
|
||||
assert_eq!(analysis.base, "activity");
|
||||
assert_eq!(
|
||||
analysis.field_positions.get("count"),
|
||||
Some(&FieldNamePosition::Append("_count".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_single_child_prefix() {
|
||||
// Field "cumulative" appears as prefix "cumulative_" in "cumulative_supply"
|
||||
let children = vec![("cumulative".to_string(), "cumulative_supply".to_string())];
|
||||
let analysis = analyze_pattern_level(&children);
|
||||
|
||||
assert!(matches!(analysis.common, CommonDenominator::None));
|
||||
assert_eq!(analysis.base, "supply");
|
||||
assert_eq!(
|
||||
analysis.field_positions.get("cumulative"),
|
||||
Some(&FieldNamePosition::Prepend("cumulative_".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_single_child_identity_equal() {
|
||||
// Field "supply" equals effective "supply" → Identity
|
||||
// (root-level handling is done via empty acc and conditional expressions)
|
||||
let children = vec![("supply".to_string(), "supply".to_string())];
|
||||
let analysis = analyze_pattern_level(&children);
|
||||
|
||||
assert!(matches!(analysis.common, CommonDenominator::None));
|
||||
assert_eq!(analysis.base, "supply");
|
||||
assert_eq!(
|
||||
analysis.field_positions.get("supply"),
|
||||
Some(&FieldNamePosition::Identity)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_single_child_identity_structural() {
|
||||
// Field "x" doesn't appear in "a_b" - it's structural grouping
|
||||
let children = vec![("x".to_string(), "a_b".to_string())];
|
||||
let analysis = analyze_pattern_level(&children);
|
||||
|
||||
assert!(matches!(analysis.common, CommonDenominator::None));
|
||||
assert_eq!(analysis.base, "a_b"); // passes through unchanged
|
||||
assert_eq!(
|
||||
analysis.field_positions.get("x"),
|
||||
Some(&FieldNamePosition::Identity)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_common_prefix_exact_match() {
|
||||
// When one name exactly matches the common prefix, preserve the full name
|
||||
// This fixes the realized_loss vs realized_count bug
|
||||
let names = vec!["realized_loss", "realized_loss_cumulative"];
|
||||
assert_eq!(
|
||||
find_common_prefix(&names),
|
||||
Some("realized_loss_".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_common_prefix_exact_match_multiple() {
|
||||
// Multiple children with same base name
|
||||
let names = vec!["realized_loss", "realized_loss", "realized_loss_cumulative"];
|
||||
assert_eq!(
|
||||
find_common_prefix(&names),
|
||||
Some("realized_loss_".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_analyze_pattern_level_full_base() {
|
||||
// When names are like [realized_loss, realized_loss_cumulative],
|
||||
// base should be "realized_loss" not "realized"
|
||||
let children = vec![
|
||||
("sum".to_string(), "realized_loss".to_string()),
|
||||
(
|
||||
"cumulative".to_string(),
|
||||
"realized_loss_cumulative".to_string(),
|
||||
),
|
||||
];
|
||||
let analysis = analyze_pattern_level(&children);
|
||||
|
||||
assert!(matches!(analysis.common, CommonDenominator::Prefix(_)));
|
||||
assert_eq!(analysis.base, "realized_loss");
|
||||
// sum effective equals base, so position is Identity
|
||||
assert_eq!(
|
||||
analysis.field_positions.get("sum"),
|
||||
Some(&FieldNamePosition::Identity)
|
||||
);
|
||||
// cumulative has suffix "_cumulative" after the base
|
||||
assert_eq!(
|
||||
analysis.field_positions.get("cumulative"),
|
||||
Some(&FieldNamePosition::Append("_cumulative".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_analyze_pattern_level_no_base_field() {
|
||||
// When there's no base field (like block_weight which has no block_weight metric),
|
||||
// only suffixed metrics like block_weight_average, block_weight_sum, etc.
|
||||
// Base should still be "block_weight"
|
||||
let children = vec![
|
||||
("average".to_string(), "block_weight_average".to_string()),
|
||||
("sum".to_string(), "block_weight_sum".to_string()),
|
||||
(
|
||||
"cumulative".to_string(),
|
||||
"block_weight_cumulative".to_string(),
|
||||
),
|
||||
("max".to_string(), "block_weight_max".to_string()),
|
||||
("min".to_string(), "block_weight_min".to_string()),
|
||||
];
|
||||
let analysis = analyze_pattern_level(&children);
|
||||
|
||||
assert!(matches!(analysis.common, CommonDenominator::Prefix(_)));
|
||||
assert_eq!(analysis.base, "block_weight");
|
||||
assert_eq!(
|
||||
analysis.field_positions.get("average"),
|
||||
Some(&FieldNamePosition::Append("_average".to_string()))
|
||||
);
|
||||
assert_eq!(
|
||||
analysis.field_positions.get("sum"),
|
||||
Some(&FieldNamePosition::Append("_sum".to_string()))
|
||||
find_common_suffix(&names),
|
||||
Some("_realized_loss".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ use std::collections::{BTreeSet, HashMap};
|
||||
|
||||
use brk_types::{TreeNode, extract_json_type};
|
||||
|
||||
use super::analyze_all_field_positions;
|
||||
use super::analyze_pattern_modes;
|
||||
use crate::{PatternField, StructuralPattern, to_pascal_case};
|
||||
|
||||
/// Context for pattern detection, holding all intermediate state.
|
||||
@@ -39,6 +39,7 @@ impl PatternContext {
|
||||
/// Detect structural patterns in the tree using a bottom-up approach.
|
||||
///
|
||||
/// Returns (patterns, concrete_to_pattern, concrete_to_type_param).
|
||||
/// Each pattern has its `mode` set based on analysis of all instances.
|
||||
pub fn detect_structural_patterns(
|
||||
tree: &TreeNode,
|
||||
) -> (
|
||||
@@ -52,7 +53,9 @@ pub fn detect_structural_patterns(
|
||||
let (generic_patterns, generic_mappings, type_mappings) =
|
||||
detect_generic_patterns(&ctx.signature_to_pattern);
|
||||
|
||||
let mut patterns: Vec<StructuralPattern> = ctx.signature_to_pattern
|
||||
// Only include patterns that appear 2+ times for the patterns list
|
||||
let mut patterns: Vec<StructuralPattern> = ctx
|
||||
.signature_to_pattern
|
||||
.iter()
|
||||
.filter(|(sig, _)| {
|
||||
ctx.signature_counts.get(*sig).copied().unwrap_or(0) >= 2
|
||||
@@ -76,7 +79,7 @@ pub fn detect_structural_patterns(
|
||||
StructuralPattern {
|
||||
name: name.clone(),
|
||||
fields: fields_with_type_params,
|
||||
field_positions: HashMap::new(),
|
||||
mode: None, // Will be determined by analyze_pattern_modes
|
||||
is_generic: false,
|
||||
}
|
||||
})
|
||||
@@ -84,6 +87,7 @@ pub fn detect_structural_patterns(
|
||||
|
||||
patterns.extend(generic_patterns);
|
||||
|
||||
// Build pattern lookup for mode analysis (patterns appearing 2+ times)
|
||||
let mut pattern_lookup: HashMap<Vec<PatternField>, String> = HashMap::new();
|
||||
for (sig, name) in &ctx.signature_to_pattern {
|
||||
if ctx.signature_counts.get(sig).copied().unwrap_or(0) >= 2 {
|
||||
@@ -94,8 +98,8 @@ pub fn detect_structural_patterns(
|
||||
|
||||
let concrete_to_pattern = pattern_lookup.clone();
|
||||
|
||||
// Use the new bottom-up field position analysis
|
||||
analyze_all_field_positions(tree, &mut patterns, &pattern_lookup);
|
||||
// Analyze pattern modes (suffix vs prefix) from all instances
|
||||
analyze_pattern_modes(tree, &mut patterns, &pattern_lookup);
|
||||
|
||||
patterns.sort_by(|a, b| b.fields.len().cmp(&a.fields.len()));
|
||||
(patterns, concrete_to_pattern, type_mappings)
|
||||
@@ -137,7 +141,7 @@ fn detect_generic_patterns(
|
||||
patterns.push(StructuralPattern {
|
||||
name: generic_name,
|
||||
fields: normalized_fields,
|
||||
field_positions: HashMap::new(),
|
||||
mode: None, // Will be determined by analyze_pattern_modes
|
||||
is_generic: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,149 +1,440 @@
|
||||
//! Field position detection for pattern instances.
|
||||
//! Pattern mode detection and field part extraction.
|
||||
//!
|
||||
//! This module bridges the name analysis with pattern field positions,
|
||||
//! processing patterns bottom-up to determine how each field modifies
|
||||
//! the accumulated metric name.
|
||||
//! This module analyzes pattern instances to detect whether they use
|
||||
//! suffix mode (fields append to acc) or prefix mode (fields prepend to acc),
|
||||
//! and extracts the field parts (relatives or prefixes) for code generation.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use brk_types::TreeNode;
|
||||
|
||||
use super::{analyze_pattern_level, get_node_fields};
|
||||
use crate::{FieldNamePosition, PatternField, StructuralPattern};
|
||||
use super::{find_common_prefix, find_common_suffix, get_node_fields};
|
||||
use crate::{PatternField, PatternMode, StructuralPattern};
|
||||
|
||||
/// Analyze field positions for all patterns using bottom-up tree traversal.
|
||||
/// Result of analyzing a single pattern instance.
|
||||
#[derive(Debug, Clone)]
|
||||
struct InstanceAnalysis {
|
||||
/// The base to return to parent (used for nesting)
|
||||
base: String,
|
||||
/// For suffix mode: field -> relative name
|
||||
/// For prefix mode: field -> prefix
|
||||
field_parts: HashMap<String, String>,
|
||||
/// Whether this instance appears to be suffix mode
|
||||
is_suffix_mode: bool,
|
||||
}
|
||||
|
||||
/// Analyze all pattern instances and determine their modes.
|
||||
///
|
||||
/// This is the main entry point for field position detection. It processes
|
||||
/// the tree bottom-up, analyzing each pattern instance and aggregating
|
||||
/// the positions across all instances.
|
||||
pub fn analyze_all_field_positions(
|
||||
/// This is the main entry point for mode detection. It processes
|
||||
/// the tree bottom-up, collecting analysis for each pattern instance,
|
||||
/// then determines the consistent mode for each pattern.
|
||||
pub fn analyze_pattern_modes(
|
||||
tree: &TreeNode,
|
||||
patterns: &mut [StructuralPattern],
|
||||
pattern_lookup: &HashMap<Vec<PatternField>, String>,
|
||||
) {
|
||||
let mut all_positions: HashMap<String, HashMap<String, Vec<FieldNamePosition>>> =
|
||||
HashMap::new();
|
||||
// Collect analyses from all instances, keyed by pattern name
|
||||
let mut all_analyses: HashMap<String, Vec<InstanceAnalysis>> = HashMap::new();
|
||||
|
||||
// Collect positions from all instances bottom-up
|
||||
collect_positions_bottom_up(tree, pattern_lookup, &mut all_positions);
|
||||
// Bottom-up traversal
|
||||
collect_instance_analyses(tree, pattern_lookup, &mut all_analyses);
|
||||
|
||||
// Merge positions into patterns
|
||||
// For each pattern, determine mode from collected instances
|
||||
for pattern in patterns.iter_mut() {
|
||||
if let Some(field_positions) = all_positions.get(&pattern.name) {
|
||||
pattern.field_positions = merge_field_positions(field_positions);
|
||||
if let Some(analyses) = all_analyses.get(&pattern.name) {
|
||||
pattern.mode = determine_pattern_mode(analyses, &pattern.fields);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Recursively collect field positions bottom-up.
|
||||
/// Returns the effective base for this node (used by parent level).
|
||||
fn collect_positions_bottom_up(
|
||||
/// Recursively collect instance analyses bottom-up.
|
||||
/// Returns the "base" for this node (used by parent for its analysis).
|
||||
fn collect_instance_analyses(
|
||||
node: &TreeNode,
|
||||
pattern_lookup: &HashMap<Vec<PatternField>, String>,
|
||||
all_positions: &mut HashMap<String, HashMap<String, Vec<FieldNamePosition>>>,
|
||||
all_analyses: &mut HashMap<String, Vec<InstanceAnalysis>>,
|
||||
) -> Option<String> {
|
||||
match node {
|
||||
TreeNode::Leaf(leaf) => {
|
||||
// Leaves return their vec name as the effective base
|
||||
// Leaves return their metric name as the base
|
||||
Some(leaf.name().to_string())
|
||||
}
|
||||
TreeNode::Branch(children) => {
|
||||
// First, process all children recursively (bottom-up)
|
||||
let mut child_bases: HashMap<String, String> = HashMap::new();
|
||||
for (field_name, child_node) in children {
|
||||
if let Some(base) = collect_positions_bottom_up(child_node, pattern_lookup, all_positions) {
|
||||
if let Some(base) =
|
||||
collect_instance_analyses(child_node, pattern_lookup, all_analyses)
|
||||
{
|
||||
child_bases.insert(field_name.clone(), base);
|
||||
}
|
||||
}
|
||||
|
||||
// Build child names for this level's analysis
|
||||
let child_names: Vec<(String, String)> = children
|
||||
.keys()
|
||||
.filter_map(|field_name| {
|
||||
child_bases
|
||||
.get(field_name)
|
||||
.map(|base| (field_name.clone(), base.clone()))
|
||||
})
|
||||
.collect();
|
||||
|
||||
if child_names.is_empty() {
|
||||
if child_bases.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Analyze this level
|
||||
let analysis = analyze_pattern_level(&child_names);
|
||||
// Analyze this instance
|
||||
let analysis = analyze_instance(&child_bases);
|
||||
|
||||
// Get the pattern name for this node (if any)
|
||||
let fields = get_node_fields(children, pattern_lookup);
|
||||
if let Some(pattern_name) = pattern_lookup.get(&fields) {
|
||||
// Record field positions for this pattern instance
|
||||
for (field_name, position) in &analysis.field_positions {
|
||||
all_positions
|
||||
.entry(pattern_name.clone())
|
||||
.or_default()
|
||||
.entry(field_name.clone())
|
||||
.or_default()
|
||||
.push(position.clone());
|
||||
}
|
||||
all_analyses
|
||||
.entry(pattern_name.clone())
|
||||
.or_default()
|
||||
.push(analysis.clone());
|
||||
}
|
||||
|
||||
// Return our base for the parent level
|
||||
// Return the base for parent
|
||||
Some(analysis.base)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a list of positions contains incompatible values.
|
||||
///
|
||||
/// Positions are incompatible if there are multiple different non-Identity positions,
|
||||
/// meaning different pattern instances use different naming conventions.
|
||||
fn has_incompatible_positions(positions: &[FieldNamePosition]) -> bool {
|
||||
let non_identity: Vec<_> = positions
|
||||
.iter()
|
||||
.filter(|p| !matches!(p, FieldNamePosition::Identity))
|
||||
.collect();
|
||||
/// Analyze a single pattern instance from its child bases.
|
||||
fn analyze_instance(child_bases: &HashMap<String, String>) -> InstanceAnalysis {
|
||||
let bases: Vec<&str> = child_bases.values().map(|s| s.as_str()).collect();
|
||||
|
||||
if non_identity.len() <= 1 {
|
||||
return false;
|
||||
// Try suffix mode first: look for common prefix among children
|
||||
if let Some(common_prefix) = find_common_prefix(&bases) {
|
||||
let base = common_prefix.trim_end_matches('_').to_string();
|
||||
let mut field_parts = HashMap::new();
|
||||
|
||||
for (field_name, child_base) in child_bases {
|
||||
// Relative = child_base with common prefix stripped
|
||||
// If child_base equals base, relative is empty (identity field)
|
||||
let relative = if child_base == &base {
|
||||
String::new()
|
||||
} else {
|
||||
child_base
|
||||
.strip_prefix(&common_prefix)
|
||||
.unwrap_or(child_base)
|
||||
.to_string()
|
||||
};
|
||||
field_parts.insert(field_name.clone(), relative);
|
||||
}
|
||||
|
||||
return InstanceAnalysis {
|
||||
base,
|
||||
field_parts,
|
||||
is_suffix_mode: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if all non-identity positions are the same
|
||||
let first = &non_identity[0];
|
||||
non_identity.iter().skip(1).any(|p| p != first)
|
||||
// Try prefix mode: look for common suffix among children
|
||||
if let Some(common_suffix) = find_common_suffix(&bases) {
|
||||
let base = common_suffix.trim_start_matches('_').to_string();
|
||||
let mut field_parts = HashMap::new();
|
||||
|
||||
for (field_name, child_base) in child_bases {
|
||||
// Prefix = child_base with common suffix stripped
|
||||
let prefix = child_base
|
||||
.strip_suffix(&common_suffix)
|
||||
.map(|s| {
|
||||
// Ensure prefix ends with underscore if non-empty
|
||||
if s.is_empty() {
|
||||
String::new()
|
||||
} else if s.ends_with('_') {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}_", s)
|
||||
}
|
||||
})
|
||||
.unwrap_or_default();
|
||||
field_parts.insert(field_name.clone(), prefix);
|
||||
}
|
||||
|
||||
return InstanceAnalysis {
|
||||
base,
|
||||
field_parts,
|
||||
is_suffix_mode: false,
|
||||
};
|
||||
}
|
||||
|
||||
// No common prefix or suffix - use first child's base and treat as suffix mode
|
||||
// with full metric names as relatives
|
||||
let base = child_bases.values().next().cloned().unwrap_or_default();
|
||||
let field_parts = child_bases
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect();
|
||||
|
||||
InstanceAnalysis {
|
||||
base,
|
||||
field_parts,
|
||||
is_suffix_mode: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge multiple observed positions for each field into a single position.
|
||||
///
|
||||
/// Returns an empty map if any field has incompatible positions across instances,
|
||||
/// which will cause `is_parameterizable()` to return false for the pattern.
|
||||
fn merge_field_positions(
|
||||
field_positions: &HashMap<String, Vec<FieldNamePosition>>,
|
||||
) -> HashMap<String, FieldNamePosition> {
|
||||
// First check for incompatible positions
|
||||
for positions in field_positions.values() {
|
||||
if has_incompatible_positions(positions) {
|
||||
// Incompatible positions found - pattern cannot be parameterized
|
||||
return HashMap::new();
|
||||
/// Determine the consistent mode for a pattern from all its instances.
|
||||
/// Uses majority voting: if most instances agree on mode and field_parts,
|
||||
/// use those. Minority instances will be inlined at usage sites.
|
||||
fn determine_pattern_mode(
|
||||
analyses: &[InstanceAnalysis],
|
||||
fields: &[PatternField],
|
||||
) -> Option<PatternMode> {
|
||||
if analyses.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Group instances by (mode, field_parts) signature
|
||||
let suffix_instances: Vec<_> = analyses.iter().filter(|a| a.is_suffix_mode).collect();
|
||||
let prefix_instances: Vec<_> = analyses.iter().filter(|a| !a.is_suffix_mode).collect();
|
||||
|
||||
// Pick the majority mode group
|
||||
let (majority_instances, is_suffix) = if suffix_instances.len() >= prefix_instances.len() {
|
||||
(suffix_instances, true)
|
||||
} else {
|
||||
(prefix_instances, false)
|
||||
};
|
||||
|
||||
if majority_instances.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Find the most common field_parts within the majority group
|
||||
// Convert to sorted Vec for comparison since HashMap isn't hashable
|
||||
let mut parts_counts: HashMap<Vec<(String, String)>, usize> = HashMap::new();
|
||||
for analysis in &majority_instances {
|
||||
let mut sorted: Vec<_> = analysis.field_parts.iter()
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect();
|
||||
sorted.sort();
|
||||
*parts_counts.entry(sorted).or_insert(0) += 1;
|
||||
}
|
||||
|
||||
let (best_parts_vec, _count) = parts_counts.into_iter().max_by_key(|(_, count)| *count)?;
|
||||
let best_parts: HashMap<String, String> = best_parts_vec.into_iter().collect();
|
||||
|
||||
// Verify all required fields have parts
|
||||
for field in fields {
|
||||
if !best_parts.contains_key(&field.name) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
// All positions are compatible, proceed with merge
|
||||
field_positions
|
||||
.iter()
|
||||
.filter_map(|(field_name, positions)| {
|
||||
if positions.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let field_parts = best_parts;
|
||||
|
||||
// Prefer Append/Prepend over Identity, as Identity at root-level
|
||||
// is handled by empty acc and conditional position expressions
|
||||
let preferred = positions
|
||||
.iter()
|
||||
.find(|p| !matches!(p, FieldNamePosition::Identity))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| positions[0].clone());
|
||||
|
||||
Some((field_name.clone(), preferred))
|
||||
if is_suffix {
|
||||
Some(PatternMode::Suffix {
|
||||
relatives: field_parts,
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
Some(PatternMode::Prefix {
|
||||
prefixes: field_parts,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_analyze_instance_suffix_mode() {
|
||||
let mut child_bases = HashMap::new();
|
||||
child_bases.insert("max".to_string(), "lth_cost_basis_max".to_string());
|
||||
child_bases.insert("min".to_string(), "lth_cost_basis_min".to_string());
|
||||
child_bases.insert("percentiles".to_string(), "lth_cost_basis".to_string());
|
||||
|
||||
let analysis = analyze_instance(&child_bases);
|
||||
|
||||
assert!(analysis.is_suffix_mode);
|
||||
assert_eq!(analysis.base, "lth_cost_basis");
|
||||
assert_eq!(analysis.field_parts.get("max"), Some(&"max".to_string()));
|
||||
assert_eq!(analysis.field_parts.get("min"), Some(&"min".to_string()));
|
||||
assert_eq!(analysis.field_parts.get("percentiles"), Some(&"".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_analyze_instance_prefix_mode() {
|
||||
// Period-prefixed metrics like "1y_lump_sum_stack", "1m_lump_sum_stack"
|
||||
// share a common suffix "_lump_sum_stack" with different period prefixes
|
||||
let mut child_bases = HashMap::new();
|
||||
child_bases.insert("_1y".to_string(), "1y_lump_sum_stack".to_string());
|
||||
child_bases.insert("_1m".to_string(), "1m_lump_sum_stack".to_string());
|
||||
child_bases.insert("_1w".to_string(), "1w_lump_sum_stack".to_string());
|
||||
|
||||
let analysis = analyze_instance(&child_bases);
|
||||
|
||||
assert!(!analysis.is_suffix_mode);
|
||||
assert_eq!(analysis.base, "lump_sum_stack");
|
||||
assert_eq!(analysis.field_parts.get("_1y"), Some(&"1y_".to_string()));
|
||||
assert_eq!(analysis.field_parts.get("_1m"), Some(&"1m_".to_string()));
|
||||
assert_eq!(analysis.field_parts.get("_1w"), Some(&"1w_".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_analyze_instance_root_suffix() {
|
||||
// At root level with suffix naming convention
|
||||
let mut child_bases = HashMap::new();
|
||||
child_bases.insert("max".to_string(), "cost_basis_max".to_string());
|
||||
child_bases.insert("min".to_string(), "cost_basis_min".to_string());
|
||||
child_bases.insert("percentiles".to_string(), "cost_basis".to_string());
|
||||
|
||||
let analysis = analyze_instance(&child_bases);
|
||||
|
||||
// With suffix naming, common prefix is "cost_basis_" (since cost_basis is one of the names)
|
||||
assert!(analysis.is_suffix_mode);
|
||||
assert_eq!(analysis.base, "cost_basis");
|
||||
assert_eq!(analysis.field_parts.get("max"), Some(&"max".to_string()));
|
||||
assert_eq!(analysis.field_parts.get("min"), Some(&"min".to_string()));
|
||||
assert_eq!(analysis.field_parts.get("percentiles"), Some(&"".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_determine_pattern_mode_majority_voting() {
|
||||
// Test that majority voting works when instances have mixed modes.
|
||||
// This simulates CostBasisPattern2: most instances use suffix mode,
|
||||
// but root-level uses prefix mode (max_cost_basis, min_cost_basis, cost_basis).
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
let fields = vec![
|
||||
PatternField {
|
||||
name: "max".to_string(),
|
||||
rust_type: "TestType".to_string(),
|
||||
json_type: "number".to_string(),
|
||||
indexes: BTreeSet::new(),
|
||||
type_param: None,
|
||||
},
|
||||
PatternField {
|
||||
name: "min".to_string(),
|
||||
rust_type: "TestType".to_string(),
|
||||
json_type: "number".to_string(),
|
||||
indexes: BTreeSet::new(),
|
||||
type_param: None,
|
||||
},
|
||||
PatternField {
|
||||
name: "percentiles".to_string(),
|
||||
rust_type: "TestType".to_string(),
|
||||
json_type: "number".to_string(),
|
||||
indexes: BTreeSet::new(),
|
||||
type_param: None,
|
||||
},
|
||||
];
|
||||
|
||||
// 3 suffix mode instances (majority)
|
||||
let suffix1 = InstanceAnalysis {
|
||||
base: "lth_cost_basis".to_string(),
|
||||
field_parts: [
|
||||
("max".to_string(), "max".to_string()),
|
||||
("min".to_string(), "min".to_string()),
|
||||
("percentiles".to_string(), "".to_string()),
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
is_suffix_mode: true,
|
||||
};
|
||||
let suffix2 = InstanceAnalysis {
|
||||
base: "sth_cost_basis".to_string(),
|
||||
field_parts: [
|
||||
("max".to_string(), "max".to_string()),
|
||||
("min".to_string(), "min".to_string()),
|
||||
("percentiles".to_string(), "".to_string()),
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
is_suffix_mode: true,
|
||||
};
|
||||
let suffix3 = InstanceAnalysis {
|
||||
base: "utxo_cost_basis".to_string(),
|
||||
field_parts: [
|
||||
("max".to_string(), "max".to_string()),
|
||||
("min".to_string(), "min".to_string()),
|
||||
("percentiles".to_string(), "".to_string()),
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
is_suffix_mode: true,
|
||||
};
|
||||
|
||||
// 1 prefix mode instance (minority - root level)
|
||||
let prefix1 = InstanceAnalysis {
|
||||
base: "cost_basis".to_string(),
|
||||
field_parts: [
|
||||
("max".to_string(), "max_".to_string()),
|
||||
("min".to_string(), "min_".to_string()),
|
||||
("percentiles".to_string(), "".to_string()),
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
is_suffix_mode: false,
|
||||
};
|
||||
|
||||
let analyses = vec![suffix1, suffix2, suffix3, prefix1];
|
||||
|
||||
let mode = determine_pattern_mode(&analyses, &fields);
|
||||
|
||||
// Should pick suffix mode (majority) with the common field_parts
|
||||
assert!(mode.is_some());
|
||||
match mode.unwrap() {
|
||||
PatternMode::Suffix { relatives } => {
|
||||
assert_eq!(relatives.get("max"), Some(&"max".to_string()));
|
||||
assert_eq!(relatives.get("min"), Some(&"min".to_string()));
|
||||
assert_eq!(relatives.get("percentiles"), Some(&"".to_string()));
|
||||
}
|
||||
PatternMode::Prefix { .. } => {
|
||||
panic!("Expected suffix mode, got prefix mode");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_determine_pattern_mode_all_same() {
|
||||
// Test when all instances agree on mode and field_parts
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
let fields = vec![
|
||||
PatternField {
|
||||
name: "max".to_string(),
|
||||
rust_type: "TestType".to_string(),
|
||||
json_type: "number".to_string(),
|
||||
indexes: BTreeSet::new(),
|
||||
type_param: None,
|
||||
},
|
||||
PatternField {
|
||||
name: "min".to_string(),
|
||||
rust_type: "TestType".to_string(),
|
||||
json_type: "number".to_string(),
|
||||
indexes: BTreeSet::new(),
|
||||
type_param: None,
|
||||
},
|
||||
];
|
||||
|
||||
let instance1 = InstanceAnalysis {
|
||||
base: "metric_a".to_string(),
|
||||
field_parts: [
|
||||
("max".to_string(), "max".to_string()),
|
||||
("min".to_string(), "min".to_string()),
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
is_suffix_mode: true,
|
||||
};
|
||||
let instance2 = InstanceAnalysis {
|
||||
base: "metric_b".to_string(),
|
||||
field_parts: [
|
||||
("max".to_string(), "max".to_string()),
|
||||
("min".to_string(), "min".to_string()),
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
is_suffix_mode: true,
|
||||
};
|
||||
|
||||
let analyses = vec![instance1, instance2];
|
||||
let mode = determine_pattern_mode(&analyses, &fields);
|
||||
|
||||
assert!(mode.is_some());
|
||||
match mode.unwrap() {
|
||||
PatternMode::Suffix { relatives } => {
|
||||
assert_eq!(relatives.get("max"), Some(&"max".to_string()));
|
||||
assert_eq!(relatives.get("min"), Some(&"min".to_string()));
|
||||
}
|
||||
PatternMode::Prefix { .. } => {
|
||||
panic!("Expected suffix mode");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@ use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
|
||||
use brk_types::{Index, TreeNode, extract_json_type};
|
||||
|
||||
use crate::{IndexSetPattern, PatternField, analysis::names::{analyze_pattern_level, CommonDenominator}, child_type_name};
|
||||
use crate::{IndexSetPattern, PatternField, child_type_name};
|
||||
|
||||
use super::{find_common_prefix, find_common_suffix};
|
||||
|
||||
/// Get the first leaf name from a tree node.
|
||||
pub fn get_first_leaf_name(node: &TreeNode) -> Option<String> {
|
||||
@@ -147,8 +149,7 @@ impl PatternBaseResult {
|
||||
|
||||
/// Get the metric base for a pattern instance by analyzing direct children.
|
||||
///
|
||||
/// Uses field names and first leaf names from direct children to determine
|
||||
/// the common base via `analyze_pattern_level`.
|
||||
/// Uses the shortest leaf names from direct children to find common prefix/suffix.
|
||||
///
|
||||
/// If the initial analysis fails to find a common pattern, it tries excluding
|
||||
/// each child one at a time to detect outliers (e.g., a mismatched "base" field
|
||||
@@ -164,18 +165,12 @@ pub fn get_pattern_instance_base(node: &TreeNode) -> PatternBaseResult {
|
||||
};
|
||||
}
|
||||
|
||||
let analysis = analyze_pattern_level(&child_names);
|
||||
|
||||
// If we found a common pattern, use it
|
||||
if !matches!(analysis.common, CommonDenominator::None) {
|
||||
return PatternBaseResult {
|
||||
base: analysis.base,
|
||||
has_outlier: false,
|
||||
};
|
||||
// Try to find common base from leaf names
|
||||
if let Some((base, has_outlier)) = try_find_base(&child_names, false) {
|
||||
return PatternBaseResult { base, has_outlier };
|
||||
}
|
||||
|
||||
// If no common pattern found, try excluding each child one at a time
|
||||
// to detect if there's a single outlier breaking the pattern.
|
||||
// If no common pattern found and we have enough children, try excluding outliers
|
||||
if child_names.len() > 2 {
|
||||
for i in 0..child_names.len() {
|
||||
let filtered: Vec<_> = child_names
|
||||
@@ -185,22 +180,43 @@ pub fn get_pattern_instance_base(node: &TreeNode) -> PatternBaseResult {
|
||||
.map(|(_, v)| v.clone())
|
||||
.collect();
|
||||
|
||||
let filtered_analysis = analyze_pattern_level(&filtered);
|
||||
if !matches!(filtered_analysis.common, CommonDenominator::None) {
|
||||
if let Some((base, _)) = try_find_base(&filtered, true) {
|
||||
return PatternBaseResult {
|
||||
base: filtered_analysis.base,
|
||||
base,
|
||||
has_outlier: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: no common prefix/suffix found - this is a root-level pattern
|
||||
// Return empty base so metric names are used directly
|
||||
PatternBaseResult {
|
||||
base: analysis.base,
|
||||
base: String::new(),
|
||||
has_outlier: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to find a common base from child names using prefix/suffix detection.
|
||||
/// Returns Some((base, has_outlier)) if found.
|
||||
fn try_find_base(child_names: &[(String, String)], is_outlier_attempt: bool) -> Option<(String, bool)> {
|
||||
let leaf_names: Vec<&str> = child_names.iter().map(|(_, n)| n.as_str()).collect();
|
||||
|
||||
// Try common prefix first (suffix mode)
|
||||
if let Some(prefix) = find_common_prefix(&leaf_names) {
|
||||
let base = prefix.trim_end_matches('_').to_string();
|
||||
return Some((base, is_outlier_attempt));
|
||||
}
|
||||
|
||||
// Try common suffix (prefix mode)
|
||||
if let Some(suffix) = find_common_suffix(&leaf_names) {
|
||||
let base = suffix.trim_start_matches('_').to_string();
|
||||
return Some((base, is_outlier_attempt));
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Get (field_name, shortest_leaf_name) pairs for direct children of a branch node.
|
||||
///
|
||||
/// Uses the shortest leaf name from each child subtree to find the "base" case
|
||||
@@ -371,4 +387,51 @@ mod tests {
|
||||
assert_eq!(result.base, "block_weight");
|
||||
assert!(result.has_outlier); // Pattern factory should NOT be used
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_pattern_instance_base_root_level_no_common_pattern() {
|
||||
// Simulates root-level pattern with metrics that have no common prefix/suffix.
|
||||
// These names have no shared prefix or suffix, even when excluding any one.
|
||||
// In this case, we should return empty base so metric names are used directly.
|
||||
let tree = make_branch(vec![
|
||||
("alpha", make_leaf("foo_metric")),
|
||||
("beta", make_leaf("bar_value")),
|
||||
("gamma", make_leaf("baz_count")),
|
||||
]);
|
||||
|
||||
let result = get_pattern_instance_base(&tree);
|
||||
// No common prefix or suffix - return empty base
|
||||
assert_eq!(result.base, "");
|
||||
assert!(!result.has_outlier);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_pattern_instance_base_two_children_no_pattern() {
|
||||
// Two children with no common pattern - should still return empty base
|
||||
let tree = make_branch(vec![
|
||||
("foo", make_leaf("alpha")),
|
||||
("bar", make_leaf("beta")),
|
||||
]);
|
||||
|
||||
let result = get_pattern_instance_base(&tree);
|
||||
assert_eq!(result.base, "");
|
||||
assert!(!result.has_outlier);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_pattern_instance_base_with_outlier_excluded() {
|
||||
// Simulates the realized pattern: adjusted_sopr, sopr, asopr.
|
||||
// When "asopr" is excluded as outlier, "adjusted_sopr" and "sopr" share suffix "_sopr".
|
||||
// The outlier detection should find base="sopr" with has_outlier=true.
|
||||
let tree = make_branch(vec![
|
||||
("adjustedSopr", make_leaf("adjusted_sopr")),
|
||||
("sopr", make_leaf("sopr")),
|
||||
("asopr", make_leaf("asopr")),
|
||||
]);
|
||||
|
||||
let result = get_pattern_instance_base(&tree);
|
||||
// Outlier detected - pattern base found by excluding "asopr"
|
||||
assert_eq!(result.base, "sopr");
|
||||
assert!(result.has_outlier); // Pattern factory should NOT be used (inline instead)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user