#![forbid(unsafe_code)]
#![warn(missing_docs)]
use std::collections::BTreeMap;
use std::error::Error;
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ExpansionPrinciple {
PlaceIntegrity,
CulturalContinuity,
PrivacyAndSpace,
QuietExperience,
ServiceReadiness,
LongTermStewardship,
}
impl ExpansionPrinciple {
pub const ALL: [Self; 6] = [
Self::PlaceIntegrity,
Self::CulturalContinuity,
Self::PrivacyAndSpace,
Self::QuietExperience,
Self::ServiceReadiness,
Self::LongTermStewardship,
];
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PrincipleAssessment {
score: u8,
evidence: String,
}
impl PrincipleAssessment {
pub fn new(score: u8, evidence: impl Into<String>) -> Result<Self, ExpansionError> {
if score > 100 {
return Err(ExpansionError::ScoreOutOfRange(score));
}
let evidence = evidence.into();
if evidence.trim().is_empty() {
return Err(ExpansionError::EmptyEvidence);
}
Ok(Self { score, evidence })
}
pub const fn score(&self) -> u8 {
self.score
}
pub fn evidence(&self) -> &str {
&self.evidence
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExpansionCandidate {
name: String,
region: String,
opening_year: u16,
assessments: BTreeMap<ExpansionPrinciple, PrincipleAssessment>,
}
impl ExpansionCandidate {
pub fn new(
name: impl Into<String>,
region: impl Into<String>,
opening_year: u16,
) -> Result<Self, ExpansionError> {
let name = non_blank(name.into(), "candidate")?;
let region = non_blank(region.into(), "region")?;
Ok(Self {
name,
region,
opening_year,
assessments: BTreeMap::new(),
})
}
pub fn assess(
mut self,
principle: ExpansionPrinciple,
score: u8,
evidence: impl Into<String>,
) -> Result<Self, ExpansionError> {
self.set_assessment(principle, score, evidence)?;
Ok(self)
}
pub fn set_assessment(
&mut self,
principle: ExpansionPrinciple,
score: u8,
evidence: impl Into<String>,
) -> Result<(), ExpansionError> {
self.assessments
.insert(principle, PrincipleAssessment::new(score, evidence)?);
Ok(())
}
pub fn name(&self) -> &str {
&self.name
}
pub fn region(&self) -> &str {
&self.region
}
pub const fn opening_year(&self) -> u16 {
self.opening_year
}
pub fn assessment(&self, principle: ExpansionPrinciple) -> Option<&PrincipleAssessment> {
self.assessments.get(&principle)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommittedOpening {
name: String,
region: String,
opening_year: u16,
}
impl CommittedOpening {
pub fn new(
name: impl Into<String>,
region: impl Into<String>,
opening_year: u16,
) -> Result<Self, ExpansionError> {
Ok(Self {
name: non_blank(name.into(), "opening")?,
region: non_blank(region.into(), "region")?,
opening_year,
})
}
pub fn name(&self) -> &str {
&self.name
}
pub fn region(&self) -> &str {
&self.region
}
pub const fn opening_year(&self) -> u16 {
self.opening_year
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Portfolio {
openings: Vec<CommittedOpening>,
}
impl Portfolio {
pub const fn new() -> Self {
Self {
openings: Vec::new(),
}
}
pub fn commit(&mut self, opening: CommittedOpening) -> Result<(), ExpansionError> {
if self.openings.iter().any(|item| item.name == opening.name) {
return Err(ExpansionError::DuplicateOpeningName(opening.name));
}
self.openings.push(opening);
self.openings.sort_by(|left, right| {
left.opening_year
.cmp(&right.opening_year)
.then_with(|| left.region.cmp(&right.region))
.then_with(|| left.name.cmp(&right.name))
});
Ok(())
}
pub fn openings(&self) -> &[CommittedOpening] {
&self.openings
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ExpansionPolicy {
minimum_principle_score: u8,
ready_average_score: u8,
max_openings_per_year: u16,
max_openings_per_region_per_year: u16,
}
impl ExpansionPolicy {
pub fn new(
minimum_principle_score: u8,
ready_average_score: u8,
max_openings_per_year: u16,
max_openings_per_region_per_year: u16,
) -> Result<Self, ExpansionError> {
if minimum_principle_score > 100 || ready_average_score > 100 {
return Err(ExpansionError::PolicyScoreOutOfRange);
}
if ready_average_score < minimum_principle_score {
return Err(ExpansionError::ReadyScoreBelowMinimum);
}
if max_openings_per_year == 0 || max_openings_per_region_per_year == 0 {
return Err(ExpansionError::ZeroPortfolioLimit);
}
Ok(Self {
minimum_principle_score,
ready_average_score,
max_openings_per_year,
max_openings_per_region_per_year,
})
}
pub fn evaluate(
&self,
candidate: &ExpansionCandidate,
portfolio: &Portfolio,
) -> ExpansionReport {
let mut missing = Vec::new();
let mut below_minimum = Vec::new();
let mut score_sum = 0_u16;
for principle in ExpansionPrinciple::ALL {
match candidate.assessment(principle) {
None => missing.push(principle),
Some(assessment) => {
score_sum += u16::from(assessment.score);
if assessment.score < self.minimum_principle_score {
below_minimum.push(principle);
}
}
}
}
let score_basis_points = if missing.is_empty() {
Some(score_sum * 100 / ExpansionPrinciple::ALL.len() as u16)
} else {
None
};
let same_year = portfolio
.openings
.iter()
.filter(|opening| opening.opening_year == candidate.opening_year)
.count();
let same_region_year = portfolio
.openings
.iter()
.filter(|opening| {
opening.opening_year == candidate.opening_year && opening.region == candidate.region
})
.count();
let mut constraints = Vec::new();
if same_year + 1 > usize::from(self.max_openings_per_year) {
constraints.push(PortfolioConstraint::AnnualOpeningLimit);
}
if same_region_year + 1 > usize::from(self.max_openings_per_region_per_year) {
constraints.push(PortfolioConstraint::RegionalOpeningLimit);
}
let decision = if !below_minimum.is_empty() {
ExpansionDecision::Reject
} else if !missing.is_empty() {
ExpansionDecision::NeedsEvidence
} else if score_basis_points.unwrap() < u16::from(self.ready_average_score) * 100 {
ExpansionDecision::Refine
} else if !constraints.is_empty() {
ExpansionDecision::Defer
} else {
ExpansionDecision::Advance
};
ExpansionReport {
decision,
score_basis_points,
missing,
below_minimum,
constraints,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PortfolioConstraint {
AnnualOpeningLimit,
RegionalOpeningLimit,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExpansionDecision {
Reject,
NeedsEvidence,
Refine,
Defer,
Advance,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExpansionReport {
decision: ExpansionDecision,
score_basis_points: Option<u16>,
missing: Vec<ExpansionPrinciple>,
below_minimum: Vec<ExpansionPrinciple>,
constraints: Vec<PortfolioConstraint>,
}
impl ExpansionReport {
pub const fn decision(&self) -> ExpansionDecision {
self.decision
}
pub const fn score_basis_points(&self) -> Option<u16> {
self.score_basis_points
}
pub fn missing(&self) -> &[ExpansionPrinciple] {
&self.missing
}
pub fn below_minimum(&self) -> &[ExpansionPrinciple] {
&self.below_minimum
}
pub fn constraints(&self) -> &[PortfolioConstraint] {
&self.constraints
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ExpansionError {
EmptyName(&'static str),
EmptyEvidence,
ScoreOutOfRange(u8),
PolicyScoreOutOfRange,
ReadyScoreBelowMinimum,
ZeroPortfolioLimit,
DuplicateOpeningName(String),
}
impl fmt::Display for ExpansionError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::EmptyName(kind) => write!(formatter, "{kind} name must not be blank"),
Self::EmptyEvidence => formatter.write_str("assessment evidence must not be blank"),
Self::ScoreOutOfRange(score) => write!(formatter, "score {score} is outside 0..=100"),
Self::PolicyScoreOutOfRange => {
formatter.write_str("policy scores must be within 0..=100")
}
Self::ReadyScoreBelowMinimum => {
formatter.write_str("ready average must not be below the principle floor")
}
Self::ZeroPortfolioLimit => {
formatter.write_str("portfolio limits must be greater than zero")
}
Self::DuplicateOpeningName(name) => {
write!(formatter, "opening {name:?} already exists")
}
}
}
}
impl Error for ExpansionError {}
fn non_blank(value: String, kind: &'static str) -> Result<String, ExpansionError> {
if value.trim().is_empty() {
Err(ExpansionError::EmptyName(kind))
} else {
Ok(value)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn candidate(scores: [u8; 6]) -> ExpansionCandidate {
ExpansionPrinciple::ALL
.into_iter()
.zip(scores)
.try_fold(
ExpansionCandidate::new("retreat", "north", 2030).unwrap(),
|candidate, (principle, score)| {
candidate.assess(principle, score, "documented evidence")
},
)
.unwrap()
}
fn policy() -> ExpansionPolicy {
ExpansionPolicy::new(70, 80, 2, 1).unwrap()
}
#[test]
fn strong_candidate_advances() {
let report = policy().evaluate(&candidate([90, 85, 88, 82, 80, 85]), &Portfolio::new());
assert_eq!(report.decision(), ExpansionDecision::Advance);
assert_eq!(report.score_basis_points(), Some(8500));
}
#[test]
fn weak_principle_is_a_veto() {
let report =
policy().evaluate(&candidate([100, 100, 100, 100, 100, 60]), &Portfolio::new());
assert_eq!(report.decision(), ExpansionDecision::Reject);
assert_eq!(
report.below_minimum(),
&[ExpansionPrinciple::LongTermStewardship]
);
}
#[test]
fn missing_principles_need_evidence() {
let candidate = ExpansionCandidate::new("retreat", "north", 2030)
.unwrap()
.assess(ExpansionPrinciple::PlaceIntegrity, 90, "evidence")
.unwrap();
let report = policy().evaluate(&candidate, &Portfolio::new());
assert_eq!(report.decision(), ExpansionDecision::NeedsEvidence);
assert_eq!(report.score_basis_points(), None);
assert_eq!(report.missing(), &ExpansionPrinciple::ALL[1..]);
}
#[test]
fn passing_floors_but_low_average_requires_refinement() {
let report = policy().evaluate(&candidate([79, 79, 79, 79, 79, 79]), &Portfolio::new());
assert_eq!(report.decision(), ExpansionDecision::Refine);
}
#[test]
fn regional_concentration_defers_a_good_candidate() {
let mut portfolio = Portfolio::new();
portfolio
.commit(CommittedOpening::new("existing", "north", 2030).unwrap())
.unwrap();
let report = policy().evaluate(&candidate([90; 6]), &portfolio);
assert_eq!(report.decision(), ExpansionDecision::Defer);
assert_eq!(
report.constraints(),
&[PortfolioConstraint::RegionalOpeningLimit]
);
}
#[test]
fn annual_concentration_is_reported_separately() {
let mut portfolio = Portfolio::new();
portfolio
.commit(CommittedOpening::new("one", "south", 2030).unwrap())
.unwrap();
portfolio
.commit(CommittedOpening::new("two", "west", 2030).unwrap())
.unwrap();
let report = policy().evaluate(&candidate([90; 6]), &portfolio);
assert_eq!(report.decision(), ExpansionDecision::Defer);
assert_eq!(
report.constraints(),
&[PortfolioConstraint::AnnualOpeningLimit]
);
}
#[test]
fn duplicate_commitments_and_invalid_policy_are_rejected() {
let mut portfolio = Portfolio::new();
portfolio
.commit(CommittedOpening::new("one", "north", 2030).unwrap())
.unwrap();
assert_eq!(
portfolio.commit(CommittedOpening::new("one", "south", 2031).unwrap()),
Err(ExpansionError::DuplicateOpeningName("one".into()))
);
assert_eq!(
ExpansionPolicy::new(80, 70, 1, 1),
Err(ExpansionError::ReadyScoreBelowMinimum)
);
assert_eq!(
ExpansionPolicy::new(70, 80, 0, 1),
Err(ExpansionError::ZeroPortfolioLimit)
);
}
}