use std::collections::{BTreeMap, BTreeSet};
use crate::links_format::format_lino_record;
use crate::world_model::{Context, ContextDiff};
pub const SCALE: i64 = 1_000;
#[must_use]
pub const fn units(whole: i64) -> i64 {
whole * SCALE
}
#[must_use]
pub const fn milli(whole: i64, thousandths: i64) -> i64 {
whole * SCALE + thousandths
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Comparison {
Equal,
AtLeast,
AtMost,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Demand {
Nominal(String),
Quantity {
value: i64,
unit: String,
comparison: Comparison,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Constraint {
pub attribute: String,
pub demand: Demand,
pub source: Option<String>,
}
impl Constraint {
#[must_use]
pub fn nominal(attribute: impl Into<String>, value: impl Into<String>) -> Self {
Self {
attribute: attribute.into(),
demand: Demand::Nominal(value.into()),
source: None,
}
}
#[must_use]
pub fn quantity(
attribute: impl Into<String>,
value: i64,
unit: impl Into<String>,
comparison: Comparison,
) -> Self {
Self {
attribute: attribute.into(),
demand: Demand::Quantity {
value,
unit: unit.into(),
comparison,
},
source: None,
}
}
#[must_use]
pub fn from_source(mut self, source: impl Into<String>) -> Self {
self.source = Some(source.into());
self
}
#[must_use]
pub fn satisfied_by(&self, supply: &Supply) -> bool {
match (&self.demand, supply) {
(Demand::Nominal(required), Supply::Nominal(offered)) => {
normalize(required) == normalize(offered)
}
(
Demand::Quantity {
value,
unit,
comparison,
},
Supply::Quantity {
value: offered,
unit: offered_unit,
},
) => {
normalize(unit) == normalize(offered_unit)
&& match comparison {
Comparison::Equal => offered == value,
Comparison::AtLeast => offered >= value,
Comparison::AtMost => offered <= value,
}
}
_ => false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Supply {
Nominal(String),
Quantity {
value: i64,
unit: String,
},
}
impl Supply {
#[must_use]
pub fn nominal(value: impl Into<String>) -> Self {
Self::Nominal(value.into())
}
#[must_use]
pub fn quantity(value: i64, unit: impl Into<String>) -> Self {
Self::Quantity {
value,
unit: unit.into(),
}
}
fn render(&self) -> String {
match self {
Self::Nominal(value) => value.clone(),
Self::Quantity { value, unit } => format!("{} {unit}", render_fixed(*value)),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Tier {
Authentic,
OfficialCompatible,
GenericCompatible,
}
impl Tier {
pub const LADDER: [Self; 3] = [
Self::Authentic,
Self::OfficialCompatible,
Self::GenericCompatible,
];
#[must_use]
pub const fn id(self) -> &'static str {
match self {
Self::Authentic => "authentic",
Self::OfficialCompatible => "official_compatible",
Self::GenericCompatible => "generic_compatible",
}
}
#[must_use]
pub const fn next(self) -> Option<Self> {
match self {
Self::Authentic => Some(Self::OfficialCompatible),
Self::OfficialCompatible => Some(Self::GenericCompatible),
Self::GenericCompatible => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Offer {
pub price: i64,
pub currency: String,
pub seller: String,
pub url: String,
pub available: Option<bool>,
}
impl Offer {
#[must_use]
pub fn new(
price: i64,
currency: impl Into<String>,
seller: impl Into<String>,
url: impl Into<String>,
) -> Self {
Self {
price,
currency: currency.into(),
seller: seller.into(),
url: url.into(),
available: None,
}
}
#[must_use]
pub const fn with_available(mut self, available: bool) -> Self {
self.available = Some(available);
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Candidate {
pub id: String,
pub tier: Tier,
pub supplies: BTreeMap<String, Supply>,
pub offer: Option<Offer>,
}
impl Candidate {
#[must_use]
pub fn new(id: impl Into<String>, tier: Tier) -> Self {
Self {
id: id.into(),
tier,
supplies: BTreeMap::new(),
offer: None,
}
}
#[must_use]
pub fn supplying(mut self, attribute: impl Into<String>, supply: Supply) -> Self {
self.supplies.insert(attribute.into(), supply);
self
}
#[must_use]
pub fn offered(mut self, offer: Offer) -> Self {
self.offer = Some(offer);
self
}
fn covers(&self, constraints: &[Constraint]) -> BTreeSet<String> {
constraints
.iter()
.filter(|constraint| {
self.supplies
.get(&constraint.attribute)
.is_some_and(|supply| constraint.satisfied_by(supply))
})
.map(|constraint| constraint.attribute.clone())
.collect()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Plan {
pub items: Vec<String>,
pub total: Option<i64>,
pub currency: Option<String>,
pub tier: Tier,
}
impl Plan {
#[must_use]
pub const fn is_composite(&self) -> bool {
self.items.len() > 1
}
}
#[derive(Debug, Clone, Default)]
pub struct OptionNetwork {
subject: String,
constraints: Vec<Constraint>,
candidates: Vec<Candidate>,
}
pub const MAX_PLAN_ITEMS: usize = 3;
impl OptionNetwork {
#[must_use]
pub fn new(subject: impl Into<String>) -> Self {
Self {
subject: subject.into(),
constraints: Vec::new(),
candidates: Vec::new(),
}
}
pub fn require(&mut self, constraint: Constraint) {
if let Some(existing) = self
.constraints
.iter_mut()
.find(|existing| existing.attribute == constraint.attribute)
{
*existing = constraint;
return;
}
self.constraints.push(constraint);
}
pub fn observe(&mut self, candidate: Candidate) {
if let Some(existing) = self
.candidates
.iter_mut()
.find(|existing| existing.id == candidate.id)
{
*existing = candidate;
return;
}
self.candidates.push(candidate);
}
#[must_use]
pub fn subject(&self) -> &str {
&self.subject
}
#[must_use]
pub fn constraints(&self) -> &[Constraint] {
&self.constraints
}
#[must_use]
pub fn candidates(&self) -> &[Candidate] {
&self.candidates
}
#[must_use]
pub fn at_tier(&self, tier: Tier) -> Vec<&Candidate> {
self.candidates
.iter()
.filter(|candidate| candidate.tier == tier)
.collect()
}
#[must_use]
pub fn target_context(&self) -> Context {
let mut context = Context::new(format!("{}_target", self.subject));
for constraint in &self.constraints {
context.assert_link(&self.subject, &constraint_link(constraint));
}
context
}
#[must_use]
pub fn current_context(&self) -> Context {
let mut context = Context::new(format!("{}_current", self.subject));
for constraint in &self.constraints {
if self.candidates.iter().any(|candidate| {
!candidate
.covers(std::slice::from_ref(constraint))
.is_empty()
}) {
context.assert_link(&self.subject, &constraint_link(constraint));
}
}
context
}
#[must_use]
pub fn unmet(&self) -> ContextDiff {
self.current_context().difference(&self.target_context())
}
#[must_use]
pub fn open_attributes(&self) -> Vec<String> {
self.constraints
.iter()
.filter(|constraint| {
!self.candidates.iter().any(|candidate| {
!candidate
.covers(std::slice::from_ref(constraint))
.is_empty()
})
})
.map(|constraint| constraint.attribute.clone())
.collect()
}
#[must_use]
pub fn is_closed(&self) -> bool {
self.open_attributes().is_empty()
}
#[must_use]
pub fn ranked_plans(&self) -> Vec<Plan> {
let mut plans = self.satisfying_plans();
plans.sort_by(|left, right| {
price_key(left)
.cmp(&price_key(right))
.then(left.items.len().cmp(&right.items.len()))
.then(left.tier.cmp(&right.tier))
.then(left.items.cmp(&right.items))
});
plans
}
#[must_use]
pub fn best_plan(&self) -> Option<Plan> {
self.ranked_plans().into_iter().next()
}
fn satisfying_plans(&self) -> Vec<Plan> {
if self.constraints.is_empty() {
return Vec::new();
}
let required: BTreeSet<String> = self
.constraints
.iter()
.map(|constraint| constraint.attribute.clone())
.collect();
let coverage: Vec<(usize, BTreeSet<String>)> = self
.candidates
.iter()
.enumerate()
.map(|(index, candidate)| (index, candidate.covers(&self.constraints)))
.collect();
let mut plans = Vec::new();
let mut combination = Vec::new();
self.walk(0, &mut combination, &coverage, &required, &mut plans);
plans
}
fn walk(
&self,
start: usize,
combination: &mut Vec<usize>,
coverage: &[(usize, BTreeSet<String>)],
required: &BTreeSet<String>,
plans: &mut Vec<Plan>,
) {
if !combination.is_empty() {
let covered = union_of(combination, coverage);
if required.iter().all(|attribute| covered.contains(attribute)) {
if is_minimal(combination, coverage, required) {
plans.push(self.plan_for(combination));
}
return;
}
}
if combination.len() == MAX_PLAN_ITEMS {
return;
}
for index in start..coverage.len() {
combination.push(index);
self.walk(index + 1, combination, coverage, required, plans);
combination.pop();
}
}
fn plan_for(&self, combination: &[usize]) -> Plan {
let mut items: Vec<String> = combination
.iter()
.map(|index| self.candidates[*index].id.clone())
.collect();
items.sort();
let tier = combination
.iter()
.map(|index| self.candidates[*index].tier)
.max()
.unwrap_or(Tier::GenericCompatible);
let offers: Vec<&Offer> = combination
.iter()
.filter_map(|index| self.candidates[*index].offer.as_ref())
.collect();
let priced = offers.len() == combination.len();
let currency = offers.first().map(|offer| offer.currency.clone());
let single_currency = currency.as_ref().is_some_and(|currency| {
offers
.iter()
.all(|offer| normalize(&offer.currency) == normalize(currency))
});
let total = (priced && single_currency)
.then(|| offers.iter().map(|offer| offer.price).sum::<i64>());
Plan {
items,
currency: total.is_some().then(|| currency.unwrap_or_default()),
total,
tier,
}
}
#[must_use]
pub fn links_notation(&self) -> String {
let mut sections = vec![format_lino_record(
"option_network",
&[
("subject", self.subject.clone()),
("constraints", self.constraints.len().to_string()),
("candidates", self.candidates.len().to_string()),
("open", self.open_attributes().join(" ")),
],
)];
for constraint in &self.constraints {
sections.push(format_lino_record(
"constraint",
&[
("attribute", constraint.attribute.clone()),
("demand", render_demand(&constraint.demand)),
("source", constraint.source.clone().unwrap_or_default()),
],
));
}
for candidate in &self.candidates {
let supplies = candidate
.supplies
.iter()
.map(|(attribute, supply)| format!("{attribute}={}", supply.render()))
.collect::<Vec<_>>()
.join(" ");
sections.push(format_lino_record(
"candidate",
&[
("id", candidate.id.clone()),
("tier", candidate.tier.id().to_owned()),
("supplies", supplies),
(
"price",
candidate
.offer
.as_ref()
.map(|offer| {
format!("{} {}", render_fixed(offer.price), offer.currency)
})
.unwrap_or_default(),
),
(
"url",
candidate
.offer
.as_ref()
.map(|offer| offer.url.clone())
.unwrap_or_default(),
),
],
));
}
for (rank, plan) in self.ranked_plans().iter().enumerate() {
sections.push(format_lino_record(
"plan",
&[
("rank", (rank + 1).to_string()),
("items", plan.items.join(" ")),
(
"total",
plan.total
.map(|total| {
format!(
"{} {}",
render_fixed(total),
plan.currency.clone().unwrap_or_default()
)
})
.unwrap_or_default(),
),
("tier", plan.tier.id().to_owned()),
("composite", plan.is_composite().to_string()),
],
));
}
sections.join("\n")
}
}
fn is_minimal(
combination: &[usize],
coverage: &[(usize, BTreeSet<String>)],
required: &BTreeSet<String>,
) -> bool {
if combination.len() <= 1 {
return true;
}
!combination.iter().enumerate().any(|(position, _)| {
let reduced: Vec<usize> = combination
.iter()
.enumerate()
.filter(|(other, _)| *other != position)
.map(|(_, index)| *index)
.collect();
let covered = union_of(&reduced, coverage);
required.iter().all(|attribute| covered.contains(attribute))
})
}
fn union_of(combination: &[usize], coverage: &[(usize, BTreeSet<String>)]) -> BTreeSet<String> {
combination
.iter()
.flat_map(|index| coverage[*index].1.iter().cloned())
.collect()
}
fn price_key(plan: &Plan) -> (u8, i64) {
plan.total.map_or((1, 0), |total| (0, total))
}
fn constraint_link(constraint: &Constraint) -> String {
format!(
"{}:{}",
constraint.attribute,
render_demand(&constraint.demand)
)
}
fn render_demand(demand: &Demand) -> String {
match demand {
Demand::Nominal(value) => value.clone(),
Demand::Quantity {
value,
unit,
comparison,
} => {
let mut rendered = String::from(match comparison {
Comparison::Equal => "=",
Comparison::AtLeast => ">=",
Comparison::AtMost => "<=",
});
rendered.push_str(&render_fixed(*value));
rendered.push(' ');
rendered.push_str(unit);
rendered
}
}
}
fn render_fixed(value: i64) -> String {
let sign = if value < 0 { "-" } else { "" };
let magnitude = value.abs();
let whole = magnitude / SCALE;
let fraction = magnitude % SCALE;
if fraction == 0 {
return format!("{sign}{whole}");
}
let rendered = format!("{fraction:03}");
format!("{sign}{whole}.{}", rendered.trim_end_matches('0'))
}
fn normalize(value: &str) -> String {
value
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.to_lowercase()
}