use std::sync::Arc;
use crate::engine::{EngineError, RedactionEngine};
use crate::merge::Finding;
const IOU_THRESHOLD: f64 = 0.5;
pub struct EnsembleEngine {
engines: Vec<Arc<dyn RedactionEngine>>,
dual_confirm_labels: std::collections::BTreeSet<crate::label::PrivacyLabel>,
model_ids: Vec<String>,
}
impl std::fmt::Debug for EnsembleEngine {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("EnsembleEngine")
.field("engine_count", &self.engines.len())
.field("dual_confirm_labels", &self.dual_confirm_labels)
.field("model_ids", &self.model_ids)
.finish_non_exhaustive()
}
}
struct PerEngineFinding {
engine_idx: usize,
finding: Finding,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct EngineAttribution {
pub finding_index: usize,
pub contributing_engines: Vec<String>,
}
impl EnsembleEngine {
pub fn new(engines: Vec<Arc<dyn RedactionEngine>>) -> Self {
Self {
engines,
dual_confirm_labels: std::collections::BTreeSet::new(),
model_ids: Vec::new(),
}
}
pub fn with_model_ids<I, S>(mut self, ids: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let collected: Vec<String> = ids.into_iter().map(Into::into).collect();
assert_eq!(
collected.len(),
self.engines.len(),
"EnsembleEngine::with_model_ids:ids.len()={} 与 engines.len()={} 不匹配",
collected.len(),
self.engines.len()
);
self.model_ids = collected;
self
}
pub fn with_dual_confirm<I>(mut self, labels: I) -> Self
where
I: IntoIterator<Item = crate::label::PrivacyLabel>,
{
self.dual_confirm_labels = labels.into_iter().collect();
self
}
pub fn engine_count(&self) -> usize {
self.engines.len()
}
pub fn infer_with_attribution(
&self,
text: &str,
) -> Result<(Vec<Finding>, Vec<EngineAttribution>), EngineError> {
self.infer_with_attribution_with_lang(text, None)
}
pub fn infer_with_attribution_with_lang(
&self,
text: &str,
lang: Option<&str>,
) -> Result<(Vec<Finding>, Vec<EngineAttribution>), EngineError> {
let mut per_engine: Vec<PerEngineFinding> = Vec::new();
for (idx, engine) in self.engines.iter().enumerate() {
let f = engine.infer_with_lang(text, lang)?;
for finding in f {
per_engine.push(PerEngineFinding {
engine_idx: idx,
finding,
});
}
}
let (findings, attrs) =
ensemble_merge_with_attribution(per_engine, &self.dual_confirm_labels, &self.model_ids);
Ok((findings, attrs))
}
}
impl RedactionEngine for EnsembleEngine {
fn infer(&self, text: &str) -> Result<Vec<Finding>, EngineError> {
self.infer_with_lang(text, None)
}
fn infer_with_lang(&self, text: &str, lang: Option<&str>) -> Result<Vec<Finding>, EngineError> {
let mut per_engine: Vec<PerEngineFinding> = Vec::new();
for (idx, engine) in self.engines.iter().enumerate() {
let f = engine.infer_with_lang(text, lang)?;
for finding in f {
per_engine.push(PerEngineFinding {
engine_idx: idx,
finding,
});
}
}
Ok(ensemble_merge_with_dual_confirm(
per_engine,
&self.dual_confirm_labels,
))
}
}
fn iou(a: (usize, usize), b: (usize, usize)) -> f64 {
let inter_start = a.0.max(b.0);
let inter_end = a.1.min(b.1);
if inter_start >= inter_end {
return 0.0;
}
let inter = (inter_end - inter_start) as f64;
let union_start = a.0.min(b.0);
let union_end = a.1.max(b.1);
let union = (union_end - union_start) as f64;
if union <= 0.0 {
0.0
} else {
inter / union
}
}
#[allow(dead_code)] fn ensemble_merge(all: Vec<Finding>) -> Vec<Finding> {
let mut finals: Vec<Finding> = Vec::new();
for f in all {
let mut absorbed = false;
for slot in finals.iter_mut() {
if slot.kind == f.kind && iou(slot.span, f.span) >= IOU_THRESHOLD {
let cur_len = f.span.1.saturating_sub(f.span.0);
let exist_len = slot.span.1.saturating_sub(slot.span.0);
if cur_len > exist_len {
*slot = f.clone();
}
absorbed = true;
break;
}
}
if !absorbed {
finals.push(f);
}
}
finals.sort_by_key(|f| f.span.0);
finals
}
fn ensemble_merge_with_dual_confirm(
per_engine: Vec<PerEngineFinding>,
dual_confirm: &std::collections::BTreeSet<crate::label::PrivacyLabel>,
) -> Vec<Finding> {
use crate::label::PrivacyLabel;
let mut clusters: Vec<(Option<PrivacyLabel>, Vec<PerEngineFinding>)> = Vec::new();
for pf in per_engine {
let canonical = PrivacyLabel::from_kind(pf.finding.kind);
let target_idx = clusters.iter().position(|(existing_label, group)| {
*existing_label == canonical
&& group
.iter()
.any(|g| iou(g.finding.span, pf.finding.span) >= IOU_THRESHOLD)
});
match target_idx {
Some(idx) => clusters[idx].1.push(pf),
None => clusters.push((canonical, vec![pf])),
}
}
let mut finals: Vec<Finding> = Vec::new();
for (canonical, group) in clusters {
if let Some(label) = canonical {
if dual_confirm.contains(&label) {
let distinct: std::collections::BTreeSet<usize> =
group.iter().map(|p| p.engine_idx).collect();
if distinct.len() < 2 {
continue;
}
}
}
if let Some(longest) = group
.into_iter()
.map(|p| p.finding)
.max_by_key(|f| f.span.1.saturating_sub(f.span.0))
{
finals.push(longest);
}
}
finals.sort_by_key(|f| f.span.0);
finals
}
fn ensemble_merge_with_attribution(
per_engine: Vec<PerEngineFinding>,
dual_confirm: &std::collections::BTreeSet<crate::label::PrivacyLabel>,
model_ids: &[String],
) -> (Vec<Finding>, Vec<EngineAttribution>) {
use crate::label::PrivacyLabel;
let mut clusters: Vec<(Option<PrivacyLabel>, Vec<PerEngineFinding>)> = Vec::new();
for pf in per_engine {
let canonical = PrivacyLabel::from_kind(pf.finding.kind);
let target_idx = clusters.iter().position(|(existing_label, group)| {
*existing_label == canonical
&& group
.iter()
.any(|g| iou(g.finding.span, pf.finding.span) >= IOU_THRESHOLD)
});
match target_idx {
Some(idx) => clusters[idx].1.push(pf),
None => clusters.push((canonical, vec![pf])),
}
}
let mut staged: Vec<(Finding, std::collections::BTreeSet<usize>)> = Vec::new();
for (canonical, group) in clusters {
let distinct: std::collections::BTreeSet<usize> =
group.iter().map(|p| p.engine_idx).collect();
if let Some(label) = canonical {
if dual_confirm.contains(&label) && distinct.len() < 2 {
continue;
}
}
if let Some(longest) = group
.into_iter()
.map(|p| p.finding)
.max_by_key(|f| f.span.1.saturating_sub(f.span.0))
{
staged.push((longest, distinct));
}
}
staged.sort_by_key(|(f, _)| f.span.0);
let mut findings = Vec::with_capacity(staged.len());
let mut attrs = Vec::with_capacity(staged.len());
for (idx, (finding, distinct)) in staged.into_iter().enumerate() {
let mut contributing_engines: Vec<String> = distinct
.into_iter()
.map(|engine_idx| {
model_ids
.get(engine_idx)
.cloned()
.unwrap_or_else(|| format!("unknown-{engine_idx}"))
})
.collect();
contributing_engines.sort();
findings.push(finding);
attrs.push(EngineAttribution {
finding_index: idx,
contributing_engines,
});
}
(findings, attrs)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
use crate::engine::{MockEngine, NoopEngine};
#[test]
fn ensemble_empty_engines_returns_empty() {
let ens = EnsembleEngine::new(vec![]);
let f = ens.infer("text").unwrap();
assert!(f.is_empty(), "0 engines 应返空 findings");
assert_eq!(ens.engine_count(), 0);
}
#[test]
fn ensemble_single_noop_returns_empty() {
let ens = EnsembleEngine::new(vec![Arc::new(NoopEngine)]);
let f = ens.infer("hello world").unwrap();
assert!(f.is_empty());
assert_eq!(ens.engine_count(), 1);
}
#[test]
fn ensemble_two_engines_disjoint_findings_both_kept() {
let a = Arc::new(MockEngine::from_findings(vec![Finding::model(
"person",
(0, 5),
0.9,
5,
)]));
let b = Arc::new(MockEngine::from_findings(vec![Finding::model(
"email",
(10, 30),
0.95,
10,
)]));
let ens = EnsembleEngine::new(vec![a, b]);
let f = ens.infer("anything").unwrap();
assert_eq!(f.len(), 2, "无重叠应都保留");
assert_eq!(f[0].span, (0, 5));
assert_eq!(f[1].span, (10, 30));
}
#[test]
fn ensemble_same_kind_overlapping_picks_longer_span() {
let a = Arc::new(MockEngine::from_findings(vec![Finding::model(
"person",
(0, 5),
0.9,
5,
)]));
let b = Arc::new(MockEngine::from_findings(vec![Finding::model(
"person",
(0, 10),
0.85,
5,
)]));
let ens = EnsembleEngine::new(vec![a, b]);
let f = ens.infer("anything").unwrap();
assert_eq!(f.len(), 1, "同 kind IoU >= 0.5 应合并");
assert_eq!(f[0].span, (0, 10), "应取 longer span (10 > 5)");
}
#[test]
fn ensemble_same_kind_low_iou_both_kept() {
let a = Arc::new(MockEngine::from_findings(vec![Finding::model(
"person",
(0, 5),
0.9,
5,
)]));
let b = Arc::new(MockEngine::from_findings(vec![Finding::model(
"person",
(20, 30),
0.9,
5,
)]));
let ens = EnsembleEngine::new(vec![a, b]);
let f = ens.infer("any").unwrap();
assert_eq!(f.len(), 2, "同 kind 不重叠应都保留");
}
#[test]
fn ensemble_different_kind_overlapping_both_kept() {
let a = Arc::new(MockEngine::from_findings(vec![Finding::model(
"person",
(0, 10),
0.9,
5,
)]));
let b = Arc::new(MockEngine::from_findings(vec![Finding::model(
"email",
(0, 10),
0.9,
10,
)]));
let ens = EnsembleEngine::new(vec![a, b]);
let f = ens.infer("any").unwrap();
assert_eq!(f.len(), 2, "不同 kind 重叠不去重");
}
#[test]
fn ensemble_propagates_engine_error() {
struct FailingEngine;
impl RedactionEngine for FailingEngine {
fn infer(&self, _: &str) -> Result<Vec<Finding>, EngineError> {
Err(EngineError::InferRun("mock-failure".to_string()))
}
}
let a = Arc::new(MockEngine::from_findings(vec![Finding::model(
"person",
(0, 5),
0.9,
5,
)]));
let b = Arc::new(FailingEngine);
let ens = EnsembleEngine::new(vec![a, b]);
let r = ens.infer("any");
assert!(
matches!(r, Err(EngineError::InferRun(_))),
"任一引擎失败应 propagate(fail-closed)"
);
}
#[test]
fn ensemble_three_engines_iou_above_threshold_merges() {
let xlmr = Arc::new(MockEngine::from_findings(vec![Finding::model(
"person",
(0, 6),
0.85,
5,
)]));
let yonigo = Arc::new(MockEngine::from_findings(vec![]));
let openai = Arc::new(MockEngine::from_findings(vec![Finding::model(
"person",
(0, 10),
0.95,
5,
)]));
let ens = EnsembleEngine::new(vec![xlmr, yonigo, openai]);
let f = ens.infer("John Smith works here.").unwrap();
assert_eq!(f.len(), 1, "三 engine 同 kind IoU 0.6 应合 1");
assert_eq!(f[0].span, (0, 10), "应取 longer span (10 > 6)");
}
#[test]
fn ensemble_spike3_realistic_iou_below_threshold_keeps_both() {
let xlmr = Arc::new(MockEngine::from_findings(vec![Finding::model(
"person",
(0, 4),
0.85,
5,
)]));
let openai = Arc::new(MockEngine::from_findings(vec![Finding::model(
"person",
(0, 10),
0.95,
5,
)]));
let ens = EnsembleEngine::new(vec![xlmr, openai]);
let f = ens.infer("John Smith.").unwrap();
assert_eq!(f.len(), 2, "IoU 0.4 < 0.5 不合并(spike-3 实测真实行为)");
}
#[test]
fn ensemble_iou_threshold_boundary_just_below_05_keeps_both() {
let a = Arc::new(MockEngine::from_findings(vec![Finding::model(
"person",
(0, 4),
0.9,
5,
)]));
let b = Arc::new(MockEngine::from_findings(vec![Finding::model(
"person",
(3, 10),
0.9,
5,
)]));
let ens = EnsembleEngine::new(vec![a, b]);
let f = ens.infer("any").unwrap();
assert_eq!(f.len(), 2, "IoU < 0.5 不合并");
}
use crate::label::PrivacyLabel;
#[test]
fn dual_confirm_default_off_keeps_single_engine_finding() {
let a = Arc::new(MockEngine::from_findings(vec![Finding::model(
"private_address",
(0, 10),
0.9,
5,
)]));
let b = Arc::new(MockEngine::from_findings(vec![]));
let ens = EnsembleEngine::new(vec![a, b]); let f = ens.infer("any").unwrap();
assert_eq!(f.len(), 1, "默认无 dual_confirm,单 engine 报应保留");
}
#[test]
fn dual_confirm_address_drops_single_engine_finding() {
let a = Arc::new(MockEngine::from_findings(vec![Finding::model(
"private_address",
(0, 10),
0.9,
5,
)]));
let b = Arc::new(MockEngine::from_findings(vec![]));
let ens = EnsembleEngine::new(vec![a, b]).with_dual_confirm([PrivacyLabel::Address]);
let f = ens.infer("any").unwrap();
assert!(
f.is_empty(),
"dual_confirm Address 启用 + 仅 engine_a 报 → 丢弃,实际: {:?}",
f
);
}
#[test]
fn dual_confirm_address_keeps_dual_engine_consensus() {
let a = Arc::new(MockEngine::from_findings(vec![Finding::model(
"private_address",
(0, 6),
0.9,
5,
)]));
let b = Arc::new(MockEngine::from_findings(vec![Finding::model(
"private_address",
(0, 10),
0.9,
5,
)]));
let ens = EnsembleEngine::new(vec![a, b]).with_dual_confirm([PrivacyLabel::Address]);
let f = ens.infer("any").unwrap();
assert_eq!(f.len(), 1, "双 engine 共识应保留 1");
assert_eq!(f[0].span, (0, 10), "应取 longest span");
}
#[test]
fn dual_confirm_selective_keeps_other_labels() {
let a = Arc::new(MockEngine::from_findings(vec![
Finding::model("private_person", (0, 10), 0.9, 5),
Finding::model("private_address", (20, 30), 0.9, 5),
]));
let b = Arc::new(MockEngine::from_findings(vec![])); let ens = EnsembleEngine::new(vec![a, b]).with_dual_confirm([PrivacyLabel::Address]);
let f = ens.infer("any").unwrap();
assert_eq!(f.len(), 1);
assert_eq!(f[0].kind, "private_person");
}
#[test]
fn dual_confirm_multi_labels() {
let a = Arc::new(MockEngine::from_findings(vec![
Finding::model("private_address", (0, 10), 0.9, 5),
Finding::model("private_date", (20, 30), 0.9, 5),
Finding::model("private_email", (40, 50), 0.9, 5),
]));
let b = Arc::new(MockEngine::from_findings(vec![])); let ens = EnsembleEngine::new(vec![a, b])
.with_dual_confirm([PrivacyLabel::Address, PrivacyLabel::Date]);
let f = ens.infer("any").unwrap();
assert_eq!(f.len(), 1);
assert_eq!(f[0].kind, "private_email");
}
#[test]
fn dual_confirm_separate_clusters_each_checked() {
let a = Arc::new(MockEngine::from_findings(vec![Finding::model(
"private_address",
(0, 5),
0.9,
5,
)]));
let b = Arc::new(MockEngine::from_findings(vec![Finding::model(
"private_address",
(20, 30),
0.9,
5,
)]));
let ens = EnsembleEngine::new(vec![a, b]).with_dual_confirm([PrivacyLabel::Address]);
let f = ens.infer("any").unwrap();
assert!(
f.is_empty(),
"两个独立 cluster 各 1 engine → 都丢(dual_confirm 不跨 cluster 共识)"
);
}
#[test]
fn attribution_default_uses_unknown_idx() {
let a = Arc::new(MockEngine::from_findings(vec![Finding::model(
"person",
(0, 10),
0.9,
5,
)]));
let ens = EnsembleEngine::new(vec![a]);
let (findings, attrs) = ens.infer_with_attribution("any").unwrap();
assert_eq!(findings.len(), 1);
assert_eq!(attrs.len(), 1);
assert_eq!(attrs[0].finding_index, 0);
assert_eq!(attrs[0].contributing_engines, vec!["unknown-0".to_string()]);
}
#[test]
fn attribution_with_model_ids_returns_real_names() {
let a = Arc::new(MockEngine::from_findings(vec![Finding::model(
"person",
(0, 10),
0.9,
5,
)]));
let b = Arc::new(MockEngine::from_findings(vec![Finding::model(
"email",
(20, 30),
0.9,
5,
)]));
let ens = EnsembleEngine::new(vec![a, b])
.with_model_ids(["openai-privacy-filter-v1", "xlmr-pii-v1"]);
let (findings, attrs) = ens.infer_with_attribution("any").unwrap();
assert_eq!(findings.len(), 2);
assert_eq!(attrs.len(), 2);
assert_eq!(
attrs[0].contributing_engines,
vec!["openai-privacy-filter-v1".to_string()]
);
assert_eq!(
attrs[1].contributing_engines,
vec!["xlmr-pii-v1".to_string()]
);
}
#[test]
fn attribution_consensus_lists_all_contributing_engines() {
let a = Arc::new(MockEngine::from_findings(vec![Finding::model(
"person",
(0, 6),
0.85,
5,
)]));
let b = Arc::new(MockEngine::from_findings(vec![Finding::model(
"person",
(0, 10),
0.95,
5,
)]));
let ens = EnsembleEngine::new(vec![a, b])
.with_model_ids(["xlmr-pii-v1", "openai-privacy-filter-v1"]);
let (findings, attrs) = ens.infer_with_attribution("any").unwrap();
assert_eq!(findings.len(), 1, "IoU 0.6 应合 1");
assert_eq!(findings[0].span, (0, 10));
assert_eq!(attrs.len(), 1);
assert_eq!(
attrs[0].contributing_engines,
vec![
"openai-privacy-filter-v1".to_string(),
"xlmr-pii-v1".to_string()
]
);
}
#[test]
#[should_panic(expected = "with_model_ids")]
fn attribution_with_mismatched_ids_count_panics() {
let a = Arc::new(MockEngine::from_findings(vec![]));
let b = Arc::new(MockEngine::from_findings(vec![]));
let _ens = EnsembleEngine::new(vec![a, b]).with_model_ids(["only-one"]);
}
#[test]
fn attribution_dual_confirm_drops_single_engine_consistent() {
let a = Arc::new(MockEngine::from_findings(vec![Finding::model(
"private_address",
(0, 10),
0.9,
5,
)]));
let b = Arc::new(MockEngine::from_findings(vec![]));
let ens = EnsembleEngine::new(vec![a, b])
.with_model_ids(["openai", "xlmr"])
.with_dual_confirm([PrivacyLabel::Address]);
let (findings, attrs) = ens.infer_with_attribution("any").unwrap();
assert!(findings.is_empty());
assert!(
attrs.is_empty(),
"dual_confirm 丢 finding 时 attribution 也应丢"
);
}
#[test]
fn attribution_finding_index_aligns_with_findings_array() {
let a = Arc::new(MockEngine::from_findings(vec![
Finding::model("address", (50, 100), 0.9, 5),
Finding::model("person", (0, 10), 0.9, 5),
]));
let b = Arc::new(MockEngine::from_findings(vec![Finding::model(
"email",
(20, 40),
0.9,
10,
)]));
let ens = EnsembleEngine::new(vec![a, b]).with_model_ids(["e0", "e1"]);
let (findings, attrs) = ens.infer_with_attribution("any").unwrap();
assert_eq!(findings.len(), 3);
assert_eq!(attrs.len(), 3);
for (i, a) in attrs.iter().enumerate() {
assert_eq!(
a.finding_index, i,
"finding_index 必须与 findings 数组下标对齐"
);
}
}
struct LangCapturingTestEngine {
captured: std::sync::Mutex<Vec<Option<String>>>,
}
impl LangCapturingTestEngine {
fn new() -> Self {
Self {
captured: std::sync::Mutex::new(Vec::new()),
}
}
fn captured(&self) -> Vec<Option<String>> {
self.captured.lock().unwrap().clone()
}
}
impl RedactionEngine for LangCapturingTestEngine {
fn infer(&self, _text: &str) -> Result<Vec<Finding>, EngineError> {
self.captured.lock().unwrap().push(None);
Ok(Vec::new())
}
fn infer_with_lang(
&self,
text: &str,
lang: Option<&str>,
) -> Result<Vec<Finding>, EngineError> {
if lang.is_none() {
return self.infer(text);
}
self.captured.lock().unwrap().push(lang.map(String::from));
Ok(Vec::new())
}
}
#[test]
fn ensemble_infer_with_lang_propagates_lang_to_all_sub_engines() {
let a = Arc::new(LangCapturingTestEngine::new());
let b = Arc::new(LangCapturingTestEngine::new());
let ens = EnsembleEngine::new(vec![a.clone(), b.clone()]);
let _ = ens.infer_with_lang("any text", Some("de")).unwrap();
assert_eq!(
a.captured(),
vec![Some("de".to_string())],
"engine a 应收到透传的 lang Some(\"de\");若是 None 表明 ensemble 走 default 委托 infer (bug 根因)"
);
assert_eq!(
b.captured(),
vec![Some("de".to_string())],
"engine b 应收到透传的 lang Some(\"de\")"
);
}
#[test]
fn ensemble_infer_legacy_passes_none_lang() {
let a = Arc::new(LangCapturingTestEngine::new());
let ens = EnsembleEngine::new(vec![a.clone()]);
let _ = ens.infer("any").unwrap();
assert_eq!(
a.captured(),
vec![None],
"legacy ensemble.infer 应让子 engine 走 lang None(等价 v0.8)"
);
}
#[test]
fn ensemble_infer_with_attribution_with_lang_propagates_lang() {
let a = Arc::new(LangCapturingTestEngine::new());
let b = Arc::new(LangCapturingTestEngine::new());
let ens = EnsembleEngine::new(vec![a.clone(), b.clone()]).with_model_ids(["e0", "e1"]);
let _ = ens
.infer_with_attribution_with_lang("any", Some("de"))
.unwrap();
assert_eq!(
a.captured(),
vec![Some("de".to_string())],
"engine a 必须收到 lang Some(\"de\")(P1.3 R1 NICE attribution lang 透传)"
);
assert_eq!(b.captured(), vec![Some("de".to_string())]);
}
#[test]
fn ensemble_infer_with_attribution_legacy_passes_none_lang() {
let a = Arc::new(LangCapturingTestEngine::new());
let ens = EnsembleEngine::new(vec![a.clone()]).with_model_ids(["e0"]);
let _ = ens.infer_with_attribution("any").unwrap();
assert_eq!(
a.captured(),
vec![None],
"legacy infer_with_attribution 应走 lang None(等价 v0.9 baseline)"
);
}
#[test]
fn ensemble_output_sorted_by_span_start() {
let a = Arc::new(MockEngine::from_findings(vec![
Finding::model("address", (50, 100), 0.9, 5),
Finding::model("person", (0, 10), 0.9, 5),
]));
let b = Arc::new(MockEngine::from_findings(vec![Finding::model(
"email",
(20, 40),
0.9,
10,
)]));
let ens = EnsembleEngine::new(vec![a, b]);
let f = ens.infer("any").unwrap();
assert_eq!(f.len(), 3);
assert_eq!(f[0].span, (0, 10));
assert_eq!(f[1].span, (20, 40));
assert_eq!(f[2].span, (50, 100));
}
}