use crate::error::{HsPredictError, Result};
use crate::rules::chapter38::{
classify_by_intended_use, special_chapter_by_use, CHAPTER38_CATCH_ALL_CODE,
CHAPTER38_CATCH_ALL_DESC,
};
use crate::rules::jp_table::{find_jp_rule, JP_TARIFF_YEAR};
use crate::types::{
GrayZone, HsPrediction, IntendedUse, MixtureComponent, ProductDescription,
PredictionSource, RecommendedAction,
};
pub(crate) fn classify_mixture(
product: &ProductDescription,
classify_component: impl Fn(&ProductDescription) -> Result<HsPrediction>,
) -> Result<HsPrediction> {
let components = product
.mixture_components
.as_ref()
.filter(|v| !v.is_empty())
.ok_or(HsPredictError::MissingIdentifier)?;
if let Some(ref intended_use) = product.intended_use {
if let Some((hs_code, desc, confidence)) = special_chapter_by_use(intended_use) {
return Ok(PredictionBuilder {
hs_code: hs_code.to_string(),
heading_description: desc.to_string(),
confidence,
source: PredictionSource::EmbeddedRule {
rule_id: "chapter38::special_use".to_string(),
},
notes: vec![format!(
"Mixture classified by intended use ({}); verify with Chapter Notes.",
intended_use_label(intended_use)
)],
gray_zone: None,
recommended_action: RecommendedAction::VerifyWithLlm,
}
.build());
}
if let Some((hs_code, desc, confidence)) = classify_by_intended_use(intended_use) {
return Ok(PredictionBuilder {
hs_code: hs_code.to_string(),
heading_description: desc.to_string(),
confidence,
source: PredictionSource::EmbeddedRule {
rule_id: "chapter38::agricultural".to_string(),
},
notes: vec![
"Mixture classified by agricultural intended use → Ch. 38.08.".to_string(),
"Verify: active ingredient type and concentration may shift the sub-heading."
.to_string(),
],
gray_zone: Some(GrayZone::Chapter29vs38),
recommended_action: RecommendedAction::PriorConsultation,
}
.build());
}
}
let mut component_preds: Vec<(Option<f64>, HsPrediction)> = Vec::new();
let mut unclassified_count = 0usize;
for comp in components {
let comp_product = component_to_product(comp);
match classify_component(&comp_product) {
Ok(pred) => {
component_preds.push((comp.weight_fraction_pct, pred));
}
Err(_) => {
unclassified_count += 1;
}
}
}
if component_preds.is_empty() {
return Ok(ch38_catch_all(
vec![
"No components could be individually classified.".to_string(),
"Review each component's CAS/SMILES and consult a trade-compliance expert."
.to_string(),
],
0.35,
));
}
if let Some(gri3a_result) = try_gri3a(&component_preds) {
return Ok(gri3a_result);
}
if let Some(gri3b_result) = try_gri3b(&component_preds) {
return Ok(gri3b_result);
}
Ok(gri3c(&component_preds, unclassified_count))
}
fn try_gri3a(
component_preds: &[(Option<f64>, HsPrediction)],
) -> Option<HsPrediction> {
if component_preds.is_empty() {
return None;
}
let first_chapter = &component_preds[0].1.hs_code[..2];
let all_same_chapter = component_preds
.iter()
.all(|(_, p)| &p.hs_code[..2] == first_chapter);
if !all_same_chapter {
return None;
}
let best = component_preds
.iter()
.max_by(|(_, a), (_, b)| {
a.confidence
.partial_cmp(&b.confidence)
.unwrap_or(std::cmp::Ordering::Equal)
})?;
let pred = &best.1;
let confidence = (pred.confidence * 0.90).min(0.85); let recommended_action = if confidence >= 0.85 {
RecommendedAction::Accept
} else {
RecommendedAction::VerifyWithLlm
};
Some(
PredictionBuilder {
hs_code: pred.hs_code.clone(),
heading_description: pred.heading_description.clone(),
confidence,
source: PredictionSource::RuleEngine {
matched_rules: vec!["GRI-3a: all components same chapter".to_string()],
},
notes: vec![format!(
"GRI 3a applied: all {} component(s) are in Chapter {}. \
Most specific heading selected by confidence.",
component_preds.len(),
first_chapter
)],
gray_zone: None, recommended_action,
}
.build(),
)
}
fn try_gri3b(
component_preds: &[(Option<f64>, HsPrediction)],
) -> Option<HsPrediction> {
let dominant = component_preds
.iter()
.find(|(frac, _)| frac.map(|f| f > 50.0).unwrap_or(false));
let (frac, pred) = dominant?;
let fraction = frac.unwrap();
let confidence = (pred.confidence * 0.88).min(0.82);
let gray_zone = if pred.hs_code.starts_with("29") {
Some(GrayZone::Chapter29vs38)
} else {
None
};
let recommended_action = match (&gray_zone, confidence >= 0.75) {
(Some(_), _) => RecommendedAction::PriorConsultation,
(None, true) => RecommendedAction::VerifyWithLlm,
(None, false) => RecommendedAction::ExpertReview,
};
let mut notes = vec![format!(
"GRI 3b applied: dominant component ({:.1}% w/w) determines essential character.",
fraction
)];
if gray_zone.is_some() {
notes.push(
"Chapter 29 vs 38 boundary: verify whether this mixture is sold as a \
pure substance (Ch. 29) or as a prepared formulation (Ch. 38)."
.to_string(),
);
}
Some(
PredictionBuilder {
hs_code: pred.hs_code.clone(),
heading_description: pred.heading_description.clone(),
confidence,
source: PredictionSource::RuleEngine {
matched_rules: vec![format!("GRI-3b: dominant component {:.1}% w/w", fraction)],
},
notes,
gray_zone,
recommended_action,
}
.build(),
)
}
fn gri3c(
component_preds: &[(Option<f64>, HsPrediction)],
unclassified_count: usize,
) -> HsPrediction {
let last = component_preds
.iter()
.max_by(|(_, a), (_, b)| a.hs_code.cmp(&b.hs_code));
if let Some((_, pred)) = last {
let mut notes = vec![
"GRI 3c applied: essential character could not be determined (no dominant \
component >50% w/w); last heading by numeric order was used."
.to_string(),
"Confidence is LOW. An advance ruling (事前教示) from customs is strongly \
recommended before making a declaration."
.to_string(),
];
if unclassified_count > 0 {
notes.push(format!(
"{} component(s) could not be classified individually and were excluded.",
unclassified_count
));
}
PredictionBuilder {
hs_code: pred.hs_code.clone(),
heading_description: pred.heading_description.clone(),
confidence: 0.40,
source: PredictionSource::RuleEngine {
matched_rules: vec!["GRI-3c: last heading numerically".to_string()],
},
notes,
gray_zone: Some(GrayZone::MixtureEssentialCharacterUnclear),
recommended_action: RecommendedAction::PriorConsultation,
}
.build()
} else {
ch38_catch_all(
vec![
"GRI 3c could not be applied (no components classified).".to_string(),
"Ch. 38 NEC catch-all used as last resort.".to_string(),
],
0.30,
)
}
}
fn ch38_catch_all(notes: Vec<String>, confidence: f32) -> HsPrediction {
PredictionBuilder {
hs_code: CHAPTER38_CATCH_ALL_CODE.to_string(),
heading_description: CHAPTER38_CATCH_ALL_DESC.to_string(),
confidence,
source: PredictionSource::RuleEngine {
matched_rules: vec!["chapter38::catch_all".to_string()],
},
notes,
gray_zone: Some(GrayZone::Chapter29vs38),
recommended_action: RecommendedAction::PriorConsultation,
}
.build()
}
fn component_to_product(comp: &MixtureComponent) -> ProductDescription {
ProductDescription {
identifier: comp.substance.clone(),
physical_form: None, purity_pct: None,
purity_type: None,
mixture_components: None, intended_use: None,
additional_context: None,
}
}
struct PredictionBuilder {
hs_code: String,
heading_description: String,
confidence: f32,
source: PredictionSource,
notes: Vec<String>,
gray_zone: Option<GrayZone>,
recommended_action: RecommendedAction,
}
impl PredictionBuilder {
fn build(self) -> HsPrediction {
let jp = find_jp_rule(&self.hs_code);
HsPrediction {
hs_code: self.hs_code,
heading_description: self.heading_description,
confidence: self.confidence,
source: self.source,
notes: self.notes,
alternatives: vec![],
recommended_action: self.recommended_action,
gray_zone: self.gray_zone,
jp_tariff_code: jp.map(|r| r.jp_code.to_string()),
jp_tariff_year: jp.map(|_| JP_TARIFF_YEAR),
}
}
}
fn intended_use_label(use_: &IntendedUse) -> &'static str {
match use_ {
IntendedUse::Pharmaceutical => "pharmaceutical",
IntendedUse::Agricultural => "agricultural",
IntendedUse::Cosmetic => "cosmetic",
IntendedUse::Food => "food",
IntendedUse::Industrial => "industrial",
IntendedUse::Other(_) => "other",
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{MixtureComponent, SubstanceIdentifier};
fn make_pred(hs_code: &str, confidence: f32) -> HsPrediction {
HsPrediction {
hs_code: hs_code.to_string(),
heading_description: format!("Test heading for {}", hs_code),
confidence,
source: PredictionSource::EmbeddedRule { rule_id: "test".to_string() },
notes: vec![],
alternatives: vec![],
recommended_action: RecommendedAction::Accept,
gray_zone: None,
jp_tariff_code: None,
jp_tariff_year: None,
}
}
fn comp(cas: &str, weight_pct: Option<f64>) -> MixtureComponent {
MixtureComponent {
substance: SubstanceIdentifier::from_cas(cas),
weight_fraction_pct: weight_pct,
volume_fraction_pct: None,
is_solvent: false,
}
}
#[test]
fn gri3a_same_chapter_picks_highest_confidence() {
let preds = vec![
(Some(40.0), make_pred("290511", 0.97)), (Some(60.0), make_pred("290531", 0.90)), ];
let result = try_gri3a(&preds).unwrap();
assert_eq!(&result.hs_code, "290511"); assert!(result.gray_zone.is_none());
}
#[test]
fn gri3a_different_chapters_returns_none() {
let preds = vec![
(Some(50.0), make_pred("281511", 0.97)), (Some(50.0), make_pred("290511", 0.97)), ];
assert!(try_gri3a(&preds).is_none());
}
#[test]
fn gri3b_dominant_component_wins() {
let preds = vec![
(Some(70.0), make_pred("280700", 0.97)), (Some(30.0), make_pred("290531", 0.97)), ];
let result = try_gri3b(&preds).unwrap();
assert_eq!(&result.hs_code, "280700");
}
#[test]
fn gri3b_no_dominant_returns_none() {
let preds = vec![
(Some(40.0), make_pred("280700", 0.97)),
(Some(40.0), make_pred("290511", 0.97)),
];
assert!(try_gri3b(&preds).is_none());
}
#[test]
fn gri3b_ch29_sets_gray_zone() {
let preds = vec![
(Some(60.0), make_pred("290531", 0.97)), (Some(40.0), make_pred("280700", 0.90)), ];
let result = try_gri3b(&preds).unwrap();
assert_eq!(result.gray_zone, Some(GrayZone::Chapter29vs38));
assert_eq!(result.recommended_action, RecommendedAction::PriorConsultation);
}
#[test]
fn gri3c_picks_last_heading_numerically() {
let preds = vec![
(Some(35.0), make_pred("280700", 0.90)), (Some(35.0), make_pred("290511", 0.90)), (Some(30.0), make_pred("280610", 0.90)), ];
let result = gri3c(&preds, 0);
assert_eq!(&result.hs_code, "290511");
assert_eq!(result.gray_zone, Some(GrayZone::MixtureEssentialCharacterUnclear));
assert_eq!(result.recommended_action, RecommendedAction::PriorConsultation);
assert!(result.confidence <= 0.40);
}
#[test]
fn pharmaceutical_use_gives_ch30() {
let product = ProductDescription {
identifier: SubstanceIdentifier::default(),
physical_form: None,
purity_pct: None,
purity_type: None,
mixture_components: Some(vec![comp("64-17-5", Some(50.0))]),
intended_use: Some(IntendedUse::Pharmaceutical),
additional_context: None,
};
let result = classify_mixture(&product, |_p| {
Ok(make_pred("290511", 0.97))
})
.unwrap();
assert_eq!(&result.hs_code[..2], "30");
}
#[test]
fn agricultural_use_gives_ch38() {
let product = ProductDescription {
identifier: SubstanceIdentifier::default(),
physical_form: None,
purity_pct: None,
purity_type: None,
mixture_components: Some(vec![comp("64-17-5", Some(50.0))]),
intended_use: Some(IntendedUse::Agricultural),
additional_context: None,
};
let result = classify_mixture(&product, |_p| {
Ok(make_pred("290511", 0.97))
})
.unwrap();
assert_eq!(&result.hs_code[..2], "38");
assert_eq!(result.recommended_action, RecommendedAction::PriorConsultation);
}
#[test]
fn empty_components_returns_error() {
let product = ProductDescription {
identifier: SubstanceIdentifier::default(),
physical_form: None,
purity_pct: None,
purity_type: None,
mixture_components: Some(vec![]),
intended_use: None,
additional_context: None,
};
let result = classify_mixture(&product, |_p| Ok(make_pred("290511", 0.97)));
assert!(result.is_err());
}
#[test]
fn all_unknown_weights_falls_to_gri3c() {
let preds = vec![
(None, make_pred("280700", 0.90)), (None, make_pred("290511", 0.90)), ];
assert!(try_gri3b(&preds).is_none(), "GRI 3b must return None when all weights are unknown");
let result = gri3c(&preds, 0);
assert_eq!(&result.hs_code, "290511");
assert_eq!(result.gray_zone, Some(GrayZone::MixtureEssentialCharacterUnclear));
assert_eq!(result.recommended_action, RecommendedAction::PriorConsultation);
}
#[test]
fn single_component_mixture_classifies_via_gri3a() {
let preds = vec![(Some(100.0), make_pred("290511", 0.97))];
let result = try_gri3a(&preds);
assert!(result.is_some(), "GRI 3a must succeed for a single-component mixture");
let result = result.unwrap();
assert_eq!(&result.hs_code, "290511");
}
#[test]
fn gri3b_exactly_50pct_is_not_dominant() {
let preds = vec![
(Some(50.0), make_pred("280700", 0.97)),
(Some(50.0), make_pred("290511", 0.97)),
];
assert!(
try_gri3b(&preds).is_none(),
"50.0% is not strictly > 50.0; GRI 3b must return None"
);
}
#[test]
fn gri3a_nan_confidence_does_not_panic() {
let preds = vec![
(Some(50.0), make_pred("290511", f32::NAN)),
(Some(50.0), make_pred("290512", 0.80)),
];
let _ = try_gri3a(&preds);
}
}