1use super::{Ladder, Stage, StageAnswer, NO_DESIGN};
4use crate::jev::protocol::{Answer, Question};
5use anyhow::{bail, Context, Result};
6use serde::{Deserialize, Serialize};
7use std::collections::{BTreeMap, BTreeSet};
8use unicode_width::UnicodeWidthStr;
9
10const NOT_NEEDED: &str = "not_needed";
11const INSUFFICIENT: &str = "insufficient";
12const TEMPLATE: &str = include_str!("../../templates/jev-route-effort.txt");
13
14#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
16#[serde(deny_unknown_fields)]
17pub struct Model {
18 pub name: String,
20 #[serde(default = "all_stages")]
22 pub stages: Vec<Stage>,
23 #[serde(default)]
25 pub sources: Vec<String>,
26 pub verified: Option<String>,
28 pub effort: Profile,
30}
31
32fn all_stages() -> Vec<Stage> {
33 Stage::ALL.to_vec()
34}
35
36#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
38#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
39pub enum Profile {
40 Configurable {
42 control: String,
44 levels: Vec<Level>,
46 supported_levels: Vec<String>,
48 },
49 Fixed {
51 control: String,
53 level: String,
55 reason: String,
57 },
58 Unavailable {
60 reason: String,
62 },
63}
64
65#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
67#[serde(deny_unknown_fields)]
68pub struct Level {
69 pub name: String,
71 pub description: Option<String>,
73 #[serde(default)]
75 pub criteria: BTreeMap<Stage, String>,
76}
77
78impl Level {
79 fn criterion(&self, stage: Stage) -> Option<&str> {
80 self.criteria
81 .get(&stage)
82 .map(String::as_str)
83 .or(self.description.as_deref())
84 }
85}
86
87#[derive(Debug, Clone, PartialEq, Serialize)]
89pub struct ModelEffort {
90 pub model: String,
92 #[serde(skip_serializing_if = "Option::is_none")]
94 pub control: Option<String>,
95 pub status: Status,
97 #[serde(skip_serializing_if = "Option::is_none")]
99 pub level: Option<String>,
100 #[serde(skip_serializing_if = "Option::is_none")]
102 pub reason: Option<String>,
103 #[serde(skip_serializing_if = "Option::is_none")]
105 pub assessment: Option<Assessment>,
106 pub close_call: bool,
108}
109
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
112#[serde(rename_all = "snake_case")]
113pub enum Status {
114 Recommended,
116 NotNeeded,
118 Insufficient,
120 Fixed,
122 Unavailable,
124 Unspecified,
126}
127
128#[derive(Debug, Clone, PartialEq, Serialize)]
130pub struct Assessment {
131 pub choice: String,
133 pub confidence: f64,
135 pub probabilities: BTreeMap<String, f64>,
137}
138
139pub(super) fn validate(models: &[Model]) -> Result<()> {
140 if models.is_empty() {
141 bail!("models must not be empty; omit models for a legacy class-only rung");
142 }
143 let mut seen = BTreeSet::new();
144 for model in models {
145 nonempty(&model.name, "model name")?;
146 if model.stages.is_empty() {
147 bail!("model {:?} has no stages", model.name);
148 }
149 for &stage in &model.stages {
150 if !seen.insert((&model.name, stage)) {
151 bail!(
152 "model {:?} is defined more than once for {stage:?}",
153 model.name
154 );
155 }
156 }
157 validate_profile(model).with_context(|| format!("model {:?}", model.name))?;
158 }
159 for stage in Stage::ALL {
160 if !models.iter().any(|m| m.stages.contains(&stage)) {
161 bail!("no model binding for {stage:?}");
162 }
163 }
164 Ok(())
165}
166
167fn nonempty(value: &str, label: &str) -> Result<()> {
168 if value.trim().is_empty() {
169 bail!("{label} must be non-empty");
170 }
171 Ok(())
172}
173
174fn validate_profile(model: &Model) -> Result<()> {
175 match &model.effort {
176 Profile::Configurable {
177 control,
178 levels,
179 supported_levels,
180 } => {
181 nonempty(control, "control")?;
182 let mut names = BTreeSet::new();
183 for level in levels {
184 nonempty(&level.name, "level name")?;
185 if [NOT_NEEDED, INSUFFICIENT].contains(&level.name.as_str()) {
186 bail!("level {:?} is a reserved outcome", level.name);
187 }
188 if !names.insert(&level.name) {
189 bail!("duplicate level {:?}", level.name);
190 }
191 if level
192 .description
193 .as_ref()
194 .is_some_and(|s| s.trim().is_empty())
195 || level.criteria.values().any(|s| s.trim().is_empty())
196 {
197 bail!("level {:?} has empty criteria", level.name);
198 }
199 for &stage in &model.stages {
200 if level.criterion(stage).is_none() {
201 bail!("level {:?} has missing criteria for {stage:?}", level.name);
202 }
203 }
204 }
205 if supported_levels.len() < 2 {
206 bail!("configurable effort needs at least two supported levels; use kind: fixed for one");
207 }
208 let mut supported = BTreeSet::new();
209 let mut previous_index = None;
210 for name in supported_levels {
211 let Some(index) = levels.iter().position(|level| &level.name == name) else {
212 bail!("unknown supported level {name:?}");
213 };
214 if !supported.insert(name) {
215 bail!("duplicate supported level {name:?}");
216 }
217 if previous_index.is_some_and(|previous| index < previous) {
218 bail!("supported levels must follow the order declared in levels");
219 }
220 previous_index = Some(index);
221 }
222 }
223 Profile::Fixed {
224 control,
225 level,
226 reason,
227 } => {
228 nonempty(control, "control")?;
229 nonempty(level, "fixed level")?;
230 if [NOT_NEEDED, INSUFFICIENT].contains(&level.as_str()) {
231 bail!("fixed level {level:?} is a reserved outcome");
232 }
233 nonempty(reason, "fixed reason")?;
234 }
235 Profile::Unavailable { reason } => nonempty(reason, "unavailable reason")?,
236 }
237 Ok(())
238}
239
240fn key(ladder: &Ladder, stage: Stage, tier: usize, model: usize) -> String {
241 format!(
242 "{}.{}.effort_{tier}_{model}",
243 ladder.name,
244 stage.template_key()
245 )
246}
247
248fn stage_context(stage: Stage) -> &'static str {
249 match stage {
250 Stage::Design => "Design: choose the approach, settle open questions, and write a plan.",
251 Stage::Implement => "Implementation: write code, tests and docs. Assume remaining design has been completed well.",
252 Stage::Review => "Review: review the finished change before merge for mistakes automated tests would miss.",
253 }
254}
255
256fn question(model: &Model, description: &str, stage: Stage) -> Question {
257 let instructions = format!(
258 "{}\n{}\nModel: {}. Capability: {}",
259 TEMPLATE.trim(),
260 stage_context(stage),
261 model.name,
262 description
263 );
264 let mut criteria = BTreeMap::from([(
265 NOT_NEEDED.to_string(),
266 "No work remains for this stage; no effort is needed.".to_string(),
267 )]);
268 if let Profile::Configurable {
269 control,
270 levels,
271 supported_levels,
272 } = &model.effort
273 {
274 for name in supported_levels {
275 if let Some(level) = levels.iter().find(|l| &l.name == name) {
276 if let Some(criterion) = level.criterion(stage) {
277 criteria.insert(name.clone(), criterion.to_string());
278 }
279 }
280 }
281 criteria.insert(INSUFFICIENT.to_string(), "Work remains, but even this model's highest supported effort is unlikely to meet the reliability bar.".to_string());
282 Question::Choice {
283 instructions: format!(
284 "{instructions}\nNative control: {control}. Levels from least to most effort: {}.",
285 supported_levels.join(", ")
286 ),
287 criteria,
288 }
289 } else {
290 criteria.insert(
291 "remaining".to_string(),
292 "Work remains for this stage.".to_string(),
293 );
294 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 }
295 }
296}
297
298fn question_key(ladder: &Ladder, stage: Stage, tier: usize, index: usize, model: &Model) -> String {
300 if matches!(model.effort, Profile::Configurable { .. }) {
301 key(ladder, stage, tier, index)
302 } else {
303 format!("{}.{}.remaining", ladder.name, stage.template_key())
304 }
305}
306
307pub(super) fn add_questions(ladder: &Ladder, questions: &mut BTreeMap<String, Question>) {
308 for stage in Stage::ALL {
309 for (tier_index, tier) in ladder.tiers.as_slice().iter().enumerate() {
310 for (model_index, model) in tier.models.iter().flatten().enumerate() {
311 if model.stages.contains(&stage) {
312 questions.insert(
313 question_key(ladder, stage, tier_index, model_index, model),
314 question(model, &tier.description, stage),
315 );
316 }
317 }
318 }
319 }
320}
321
322fn read(answers: &BTreeMap<String, Answer>, key: &str, question: &Question) -> Result<Assessment> {
323 let Some(Answer::Choice {
324 choice,
325 confidence,
326 probabilities,
327 }) = answers.get(key)
328 else {
329 bail!("no choice answer for {key:?}");
330 };
331 let Question::Choice { criteria, .. } = question else {
332 unreachable!()
333 };
334 if !criteria.contains_key(choice) {
335 bail!("{key:?} chose unsupported effort/outcome {choice:?}");
336 }
337 if !confidence.is_finite() || !(0.0..=1.0).contains(confidence) {
338 bail!("{key:?} has invalid confidence");
339 }
340 if probabilities.len() != criteria.len()
341 || criteria.keys().any(|k| !probabilities.contains_key(k))
342 {
343 bail!("{key:?} probability keys must match offered levels/outcomes");
344 }
345 if probabilities
346 .values()
347 .any(|p| !p.is_finite() || !(0.0..=1.0).contains(p))
348 {
349 bail!("{key:?} has invalid probabilities");
350 }
351 Ok(Assessment {
352 choice: choice.clone(),
353 confidence: *confidence,
354 probabilities: probabilities.clone(),
355 })
356}
357
358pub(super) fn decode(
359 ladder: &Ladder,
360 stage: Stage,
361 class: &StageAnswer,
362 answers: &BTreeMap<String, Answer>,
363 threshold: f64,
364) -> Result<BTreeMap<String, Vec<ModelEffort>>> {
365 let mut output = BTreeMap::new();
366 for (ti, tier) in ladder.tiers.as_slice().iter().enumerate() {
367 let mut entries = Vec::new();
368 for (mi, model) in tier
369 .models
370 .iter()
371 .flatten()
372 .enumerate()
373 .filter(|(_, m)| m.stages.contains(&stage))
374 {
375 let key = question_key(ladder, stage, ti, mi, model);
376 let assessment = read(answers, &key, &question(model, &tier.description, stage))
377 .with_context(|| {
378 format!(
379 "ladder {:?}, rung {:?}, model {:?}, {stage:?}",
380 ladder.name, tier.name, model.name
381 )
382 })?;
383 let (control, status, level, reason) = match &model.effort {
384 Profile::Configurable { control, .. } => (
385 Some(control.clone()),
386 Status::Recommended,
387 Some(assessment.choice.clone()),
388 None,
389 ),
390 Profile::Fixed {
391 control,
392 level,
393 reason,
394 } => (
395 Some(control.clone()),
396 Status::Fixed,
397 Some(level.clone()),
398 Some(reason.clone()),
399 ),
400 Profile::Unavailable { reason } => {
401 (None, Status::Unavailable, None, Some(reason.clone()))
402 }
403 };
404 let mut entry = ModelEffort {
405 model: model.name.clone(),
406 control,
407 status,
408 level,
409 reason,
410 close_call: assessment.confidence < threshold,
411 assessment: Some(assessment.clone()),
412 };
413 if assessment.choice == NOT_NEEDED
414 || (stage == Stage::Design && class.choice == NO_DESIGN)
415 {
416 entry.status = Status::NotNeeded;
417 entry.level = None;
418 entry.reason = Some("No work remains for this stage.".to_string());
419 if stage == Stage::Design && class.choice == NO_DESIGN {
420 entry.assessment = None;
421 entry.close_call = false;
422 }
423 } else if assessment.choice == INSUFFICIENT {
424 entry.status = Status::Insufficient;
425 entry.level = None;
426 entry.reason = Some(
427 "No supported effort meets the reliability bar for this model.".to_string(),
428 );
429 }
430 entries.push(entry);
431 }
432 if tier.models.is_none() {
433 let no_design = stage == Stage::Design && class.choice == NO_DESIGN;
434 entries.push(ModelEffort {
435 model: tier.name.clone(),
436 control: None,
437 status: if no_design {
438 Status::NotNeeded
439 } else {
440 Status::Unspecified
441 },
442 level: None,
443 reason: Some(
444 if no_design {
445 "No design work remains."
446 } else {
447 "Legacy ladder: add models and effort profiles for effort advice."
448 }
449 .to_string(),
450 ),
451 assessment: None,
452 close_call: false,
453 });
454 }
455 output.insert(tier.name.clone(), entries);
456 }
457 Ok(output)
458}
459
460pub(super) fn render(stages: &super::StageAnswers, ladder: Option<&Ladder>) -> Vec<String> {
462 let mut names = Vec::new();
463 if let Some(ladder) = ladder {
464 names.extend(
465 ladder
466 .tiers
467 .as_slice()
468 .iter()
469 .map(|tier| tier.name.as_str()),
470 );
471 }
472 for stage in Stage::ALL {
473 for name in stages.get(stage).effort_by_model.keys() {
474 if !names.contains(&name.as_str()) {
475 names.push(name);
476 }
477 }
478 }
479 let mut rows = vec![[
480 "Model / effort".to_string(),
481 "Design".to_string(),
482 "Implement".to_string(),
483 "Review".to_string(),
484 ]];
485 let mut notes = Vec::new();
486 for name in names {
487 let mut models = Vec::new();
488 if let Some(tier) = ladder.and_then(|ladder| {
489 ladder
490 .tiers
491 .as_slice()
492 .iter()
493 .find(|tier| tier.name == name)
494 }) {
495 models.extend(
496 tier.models
497 .iter()
498 .flatten()
499 .map(|model| model.name.as_str()),
500 );
501 }
502 for stage in Stage::ALL {
503 for entry in stages
504 .get(stage)
505 .effort_by_model
506 .get(name)
507 .into_iter()
508 .flatten()
509 {
510 if !models.contains(&entry.model.as_str()) {
511 models.push(entry.model.as_str());
512 }
513 }
514 }
515 for model in models {
516 if !Stage::ALL.into_iter().any(|stage| {
517 stages
518 .get(stage)
519 .effort_by_model
520 .get(name)
521 .is_some_and(|entries| entries.iter().any(|entry| entry.model == model))
522 }) {
523 continue;
524 }
525 let label = if model == name {
526 name.to_string()
527 } else {
528 format!("{name} [{model}]")
529 };
530 let mut row = [label.clone(), "—".into(), "—".into(), "—".into()];
531 for (index, stage) in Stage::ALL.into_iter().enumerate() {
532 if let Some(entry) = stages
533 .get(stage)
534 .effort_by_model
535 .get(name)
536 .into_iter()
537 .flatten()
538 .find(|entry| entry.model == model)
539 {
540 let detail = render_detail(entry);
541 if entry.close_call || entry.status == Status::Unavailable {
543 notes.push(format!(
544 " [{}] {label}, {}: {detail}",
545 notes.len() + 1,
546 stage.template_key().trim_start_matches("stage_")
547 ));
548 let short = match entry.status {
549 Status::Recommended => {
550 entry.level.as_deref().unwrap_or("unknown").to_string()
551 }
552 Status::Fixed => {
553 format!("{} (fixed)", entry.level.as_deref().unwrap_or("unknown"))
554 }
555 Status::Unavailable => "unavailable".to_string(),
556 Status::Insufficient => "insufficient capability".to_string(),
557 Status::NotNeeded => "not needed".to_string(),
558 Status::Unspecified => "unspecified".to_string(),
559 };
560 row[index + 1] = format!("{short} [{}]", notes.len());
561 } else {
562 row[index + 1] = detail;
563 }
564 }
565 }
566 rows.push(row);
567 }
568 }
569 if rows.len() == 1 {
570 return Vec::new();
571 }
572 let widths: [usize; 3] = std::array::from_fn(|column| {
573 rows.iter()
574 .map(|row| UnicodeWidthStr::width(row[column].as_str()))
575 .max()
576 .unwrap_or(0)
577 });
578 let mut lines: Vec<String> = rows
579 .into_iter()
580 .map(|row| {
581 format!(
582 " {} {} {} {}",
583 pad_to_width(&row[0], widths[0]),
584 pad_to_width(&row[1], widths[1]),
585 pad_to_width(&row[2], widths[2]),
586 row[3],
587 )
588 })
589 .collect();
590 lines.extend(notes);
591 lines
592}
593
594fn pad_to_width(value: &str, width: usize) -> String {
595 format!(
596 "{value}{}",
597 " ".repeat(width.saturating_sub(UnicodeWidthStr::width(value)))
598 )
599}
600
601fn render_detail(entry: &ModelEffort) -> String {
602 let status = match entry.status {
603 Status::Recommended | Status::Fixed => entry.level.as_deref().unwrap_or("unknown"),
604 Status::NotNeeded => "not needed",
605 Status::Insufficient => "insufficient capability",
606 Status::Unavailable => "unavailable",
607 Status::Unspecified => "unspecified (add effort metadata)",
608 };
609 let mut detail = status.to_string();
610 if entry.status == Status::Fixed {
611 detail.push_str(" (fixed)");
612 }
613 if entry.status == Status::Unavailable {
614 if let Some(reason) = &entry.reason {
615 detail.push_str(&format!(" — {reason}"));
616 }
617 }
618 if let Some(a) = &entry.assessment {
619 let label = if matches!(entry.status, Status::Fixed | Status::Unavailable) {
620 "remaining work "
621 } else {
622 ""
623 };
624 detail.push_str(&format!(" ({label}{:.2}", a.confidence));
625 if entry.close_call {
626 detail.push_str(", close call");
627 if let Some((name, p)) = a
628 .probabilities
629 .iter()
630 .filter(|(k, _)| *k != &a.choice)
631 .max_by(|a, b| a.1.total_cmp(b.1).then_with(|| b.0.cmp(a.0)))
632 {
633 detail.push_str(&format!(" — {name} {p:.2}"));
634 }
635 }
636 detail.push(')');
637 }
638 detail
639}
640
641#[cfg(test)]
642#[path = "effort_tests.rs"]
643#[allow(clippy::unwrap_used, clippy::expect_used)]
644mod tests;