use super::{Ladder, Stage, StageAnswer, NO_DESIGN};
use crate::jev::protocol::{Answer, Question};
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use unicode_width::UnicodeWidthStr;
const NOT_NEEDED: &str = "not_needed";
const INSUFFICIENT: &str = "insufficient";
const TEMPLATE: &str = include_str!("../../templates/jev-route-effort.txt");
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Model {
pub name: String,
#[serde(default = "all_stages")]
pub stages: Vec<Stage>,
#[serde(default)]
pub sources: Vec<String>,
pub verified: Option<String>,
pub effort: Profile,
}
fn all_stages() -> Vec<Stage> {
Stage::ALL.to_vec()
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum Profile {
Configurable {
control: String,
levels: Vec<Level>,
supported_levels: Vec<String>,
},
Fixed {
control: String,
level: String,
reason: String,
},
Unavailable {
reason: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Level {
pub name: String,
pub description: Option<String>,
#[serde(default)]
pub criteria: BTreeMap<Stage, String>,
}
impl Level {
fn criterion(&self, stage: Stage) -> Option<&str> {
self.criteria
.get(&stage)
.map(String::as_str)
.or(self.description.as_deref())
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct ModelEffort {
pub model: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub control: Option<String>,
pub status: Status,
#[serde(skip_serializing_if = "Option::is_none")]
pub level: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub assessment: Option<Assessment>,
pub close_call: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Status {
Recommended,
NotNeeded,
Insufficient,
Fixed,
Unavailable,
Unspecified,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Assessment {
pub choice: String,
pub confidence: f64,
pub probabilities: BTreeMap<String, f64>,
}
pub(super) fn validate(models: &[Model]) -> Result<()> {
if models.is_empty() {
bail!("models must not be empty; omit models for a legacy class-only rung");
}
let mut seen = BTreeSet::new();
for model in models {
nonempty(&model.name, "model name")?;
if model.stages.is_empty() {
bail!("model {:?} has no stages", model.name);
}
for &stage in &model.stages {
if !seen.insert((&model.name, stage)) {
bail!(
"model {:?} is defined more than once for {stage:?}",
model.name
);
}
}
validate_profile(model).with_context(|| format!("model {:?}", model.name))?;
}
for stage in Stage::ALL {
if !models.iter().any(|m| m.stages.contains(&stage)) {
bail!("no model binding for {stage:?}");
}
}
Ok(())
}
fn nonempty(value: &str, label: &str) -> Result<()> {
if value.trim().is_empty() {
bail!("{label} must be non-empty");
}
Ok(())
}
fn validate_profile(model: &Model) -> Result<()> {
match &model.effort {
Profile::Configurable {
control,
levels,
supported_levels,
} => {
nonempty(control, "control")?;
let mut names = BTreeSet::new();
for level in levels {
nonempty(&level.name, "level name")?;
if [NOT_NEEDED, INSUFFICIENT].contains(&level.name.as_str()) {
bail!("level {:?} is a reserved outcome", level.name);
}
if !names.insert(&level.name) {
bail!("duplicate level {:?}", level.name);
}
if level
.description
.as_ref()
.is_some_and(|s| s.trim().is_empty())
|| level.criteria.values().any(|s| s.trim().is_empty())
{
bail!("level {:?} has empty criteria", level.name);
}
for &stage in &model.stages {
if level.criterion(stage).is_none() {
bail!("level {:?} has missing criteria for {stage:?}", level.name);
}
}
}
if supported_levels.len() < 2 {
bail!("configurable effort needs at least two supported levels; use kind: fixed for one");
}
let mut supported = BTreeSet::new();
let mut previous_index = None;
for name in supported_levels {
let Some(index) = levels.iter().position(|level| &level.name == name) else {
bail!("unknown supported level {name:?}");
};
if !supported.insert(name) {
bail!("duplicate supported level {name:?}");
}
if previous_index.is_some_and(|previous| index < previous) {
bail!("supported levels must follow the order declared in levels");
}
previous_index = Some(index);
}
}
Profile::Fixed {
control,
level,
reason,
} => {
nonempty(control, "control")?;
nonempty(level, "fixed level")?;
if [NOT_NEEDED, INSUFFICIENT].contains(&level.as_str()) {
bail!("fixed level {level:?} is a reserved outcome");
}
nonempty(reason, "fixed reason")?;
}
Profile::Unavailable { reason } => nonempty(reason, "unavailable reason")?,
}
Ok(())
}
fn key(ladder: &Ladder, stage: Stage, tier: usize, model: usize) -> String {
format!(
"{}.{}.effort_{tier}_{model}",
ladder.name,
stage.template_key()
)
}
fn stage_context(stage: Stage) -> &'static str {
match stage {
Stage::Design => "Design: choose the approach, settle open questions, and write a plan.",
Stage::Implement => "Implementation: write code, tests and docs. Assume remaining design has been completed well.",
Stage::Review => "Review: review the finished change before merge for mistakes automated tests would miss.",
}
}
fn question(model: &Model, description: &str, stage: Stage) -> Question {
let instructions = format!(
"{}\n{}\nModel: {}. Capability: {}",
TEMPLATE.trim(),
stage_context(stage),
model.name,
description
);
let mut criteria = BTreeMap::from([(
NOT_NEEDED.to_string(),
"No work remains for this stage; no effort is needed.".to_string(),
)]);
if let Profile::Configurable {
control,
levels,
supported_levels,
} = &model.effort
{
for name in supported_levels {
if let Some(level) = levels.iter().find(|l| &l.name == name) {
if let Some(criterion) = level.criterion(stage) {
criteria.insert(name.clone(), criterion.to_string());
}
}
}
criteria.insert(INSUFFICIENT.to_string(), "Work remains, but even this model's highest supported effort is unlikely to meet the reliability bar.".to_string());
Question::Choice {
instructions: format!(
"{instructions}\nNative control: {control}. Levels from least to most effort: {}.",
supported_levels.join(", ")
),
criteria,
}
} else {
criteria.insert(
"remaining".to_string(),
"Work remains for this stage.".to_string(),
);
Question::Choice { instructions: format!("{} Is there work remaining for this stage? Judge only the issue's remaining work, regardless of model capability.", stage_context(stage)), criteria }
}
}
fn question_key(ladder: &Ladder, stage: Stage, tier: usize, index: usize, model: &Model) -> String {
if matches!(model.effort, Profile::Configurable { .. }) {
key(ladder, stage, tier, index)
} else {
format!("{}.{}.remaining", ladder.name, stage.template_key())
}
}
pub(super) fn add_questions(ladder: &Ladder, questions: &mut BTreeMap<String, Question>) {
for stage in Stage::ALL {
for (tier_index, tier) in ladder.tiers.as_slice().iter().enumerate() {
for (model_index, model) in tier.models.iter().flatten().enumerate() {
if model.stages.contains(&stage) {
questions.insert(
question_key(ladder, stage, tier_index, model_index, model),
question(model, &tier.description, stage),
);
}
}
}
}
}
fn read(answers: &BTreeMap<String, Answer>, key: &str, question: &Question) -> Result<Assessment> {
let Some(Answer::Choice {
choice,
confidence,
probabilities,
}) = answers.get(key)
else {
bail!("no choice answer for {key:?}");
};
let Question::Choice { criteria, .. } = question else {
unreachable!()
};
if !criteria.contains_key(choice) {
bail!("{key:?} chose unsupported effort/outcome {choice:?}");
}
if !confidence.is_finite() || !(0.0..=1.0).contains(confidence) {
bail!("{key:?} has invalid confidence");
}
if probabilities.len() != criteria.len()
|| criteria.keys().any(|k| !probabilities.contains_key(k))
{
bail!("{key:?} probability keys must match offered levels/outcomes");
}
if probabilities
.values()
.any(|p| !p.is_finite() || !(0.0..=1.0).contains(p))
{
bail!("{key:?} has invalid probabilities");
}
Ok(Assessment {
choice: choice.clone(),
confidence: *confidence,
probabilities: probabilities.clone(),
})
}
pub(super) fn decode(
ladder: &Ladder,
stage: Stage,
class: &StageAnswer,
answers: &BTreeMap<String, Answer>,
threshold: f64,
) -> Result<BTreeMap<String, Vec<ModelEffort>>> {
let mut output = BTreeMap::new();
for (ti, tier) in ladder.tiers.as_slice().iter().enumerate() {
let mut entries = Vec::new();
for (mi, model) in tier
.models
.iter()
.flatten()
.enumerate()
.filter(|(_, m)| m.stages.contains(&stage))
{
let key = question_key(ladder, stage, ti, mi, model);
let assessment = read(answers, &key, &question(model, &tier.description, stage))
.with_context(|| {
format!(
"ladder {:?}, rung {:?}, model {:?}, {stage:?}",
ladder.name, tier.name, model.name
)
})?;
let (control, status, level, reason) = match &model.effort {
Profile::Configurable { control, .. } => (
Some(control.clone()),
Status::Recommended,
Some(assessment.choice.clone()),
None,
),
Profile::Fixed {
control,
level,
reason,
} => (
Some(control.clone()),
Status::Fixed,
Some(level.clone()),
Some(reason.clone()),
),
Profile::Unavailable { reason } => {
(None, Status::Unavailable, None, Some(reason.clone()))
}
};
let mut entry = ModelEffort {
model: model.name.clone(),
control,
status,
level,
reason,
close_call: assessment.confidence < threshold,
assessment: Some(assessment.clone()),
};
if assessment.choice == NOT_NEEDED
|| (stage == Stage::Design && class.choice == NO_DESIGN)
{
entry.status = Status::NotNeeded;
entry.level = None;
entry.reason = Some("No work remains for this stage.".to_string());
if stage == Stage::Design && class.choice == NO_DESIGN {
entry.assessment = None;
entry.close_call = false;
}
} else if assessment.choice == INSUFFICIENT {
entry.status = Status::Insufficient;
entry.level = None;
entry.reason = Some(
"No supported effort meets the reliability bar for this model.".to_string(),
);
}
entries.push(entry);
}
if tier.models.is_none() {
let no_design = stage == Stage::Design && class.choice == NO_DESIGN;
entries.push(ModelEffort {
model: tier.name.clone(),
control: None,
status: if no_design {
Status::NotNeeded
} else {
Status::Unspecified
},
level: None,
reason: Some(
if no_design {
"No design work remains."
} else {
"Legacy ladder: add models and effort profiles for effort advice."
}
.to_string(),
),
assessment: None,
close_call: false,
});
}
output.insert(tier.name.clone(), entries);
}
Ok(output)
}
pub(super) fn render(stages: &super::StageAnswers, ladder: Option<&Ladder>) -> Vec<String> {
let mut names = Vec::new();
if let Some(ladder) = ladder {
names.extend(
ladder
.tiers
.as_slice()
.iter()
.map(|tier| tier.name.as_str()),
);
}
for stage in Stage::ALL {
for name in stages.get(stage).effort_by_model.keys() {
if !names.contains(&name.as_str()) {
names.push(name);
}
}
}
let mut rows = vec![[
"Model / effort".to_string(),
"Design".to_string(),
"Implement".to_string(),
"Review".to_string(),
]];
let mut notes = Vec::new();
for name in names {
let mut models = Vec::new();
if let Some(tier) = ladder.and_then(|ladder| {
ladder
.tiers
.as_slice()
.iter()
.find(|tier| tier.name == name)
}) {
models.extend(
tier.models
.iter()
.flatten()
.map(|model| model.name.as_str()),
);
}
for stage in Stage::ALL {
for entry in stages
.get(stage)
.effort_by_model
.get(name)
.into_iter()
.flatten()
{
if !models.contains(&entry.model.as_str()) {
models.push(entry.model.as_str());
}
}
}
for model in models {
if !Stage::ALL.into_iter().any(|stage| {
stages
.get(stage)
.effort_by_model
.get(name)
.is_some_and(|entries| entries.iter().any(|entry| entry.model == model))
}) {
continue;
}
let label = if model == name {
name.to_string()
} else {
format!("{name} [{model}]")
};
let mut row = [label.clone(), "—".into(), "—".into(), "—".into()];
for (index, stage) in Stage::ALL.into_iter().enumerate() {
if let Some(entry) = stages
.get(stage)
.effort_by_model
.get(name)
.into_iter()
.flatten()
.find(|entry| entry.model == model)
{
let detail = render_detail(entry);
if entry.close_call || entry.status == Status::Unavailable {
notes.push(format!(
" [{}] {label}, {}: {detail}",
notes.len() + 1,
stage.template_key().trim_start_matches("stage_")
));
let short = match entry.status {
Status::Recommended => {
entry.level.as_deref().unwrap_or("unknown").to_string()
}
Status::Fixed => {
format!("{} (fixed)", entry.level.as_deref().unwrap_or("unknown"))
}
Status::Unavailable => "unavailable".to_string(),
Status::Insufficient => "insufficient capability".to_string(),
Status::NotNeeded => "not needed".to_string(),
Status::Unspecified => "unspecified".to_string(),
};
row[index + 1] = format!("{short} [{}]", notes.len());
} else {
row[index + 1] = detail;
}
}
}
rows.push(row);
}
}
if rows.len() == 1 {
return Vec::new();
}
let widths: [usize; 3] = std::array::from_fn(|column| {
rows.iter()
.map(|row| UnicodeWidthStr::width(row[column].as_str()))
.max()
.unwrap_or(0)
});
let mut lines: Vec<String> = rows
.into_iter()
.map(|row| {
format!(
" {} {} {} {}",
pad_to_width(&row[0], widths[0]),
pad_to_width(&row[1], widths[1]),
pad_to_width(&row[2], widths[2]),
row[3],
)
})
.collect();
lines.extend(notes);
lines
}
fn pad_to_width(value: &str, width: usize) -> String {
format!(
"{value}{}",
" ".repeat(width.saturating_sub(UnicodeWidthStr::width(value)))
)
}
fn render_detail(entry: &ModelEffort) -> String {
let status = match entry.status {
Status::Recommended | Status::Fixed => entry.level.as_deref().unwrap_or("unknown"),
Status::NotNeeded => "not needed",
Status::Insufficient => "insufficient capability",
Status::Unavailable => "unavailable",
Status::Unspecified => "unspecified (add effort metadata)",
};
let mut detail = status.to_string();
if entry.status == Status::Fixed {
detail.push_str(" (fixed)");
}
if entry.status == Status::Unavailable {
if let Some(reason) = &entry.reason {
detail.push_str(&format!(" — {reason}"));
}
}
if let Some(a) = &entry.assessment {
let label = if matches!(entry.status, Status::Fixed | Status::Unavailable) {
"remaining work "
} else {
""
};
detail.push_str(&format!(" ({label}{:.2}", a.confidence));
if entry.close_call {
detail.push_str(", close call");
if let Some((name, p)) = a
.probabilities
.iter()
.filter(|(k, _)| *k != &a.choice)
.max_by(|a, b| a.1.total_cmp(b.1).then_with(|| b.0.cmp(a.0)))
{
detail.push_str(&format!(" — {name} {p:.2}"));
}
}
detail.push(')');
}
detail
}
#[cfg(test)]
#[path = "effort_tests.rs"]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests;