Skip to main content

omni_dev/jev/route/
effort.rs

1//! Data-driven downstream effort advice. No provider execution configuration lives here.
2
3use 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/// A concrete model represented by a rung, optionally only for certain stages.
15#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
16#[serde(deny_unknown_fields)]
17pub struct Model {
18    /// Provider model identifier (opaque, including punctuation).
19    pub name: String,
20    /// Stages this binding covers; omitted means all stages.
21    #[serde(default = "all_stages")]
22    pub stages: Vec<Stage>,
23    /// Official capability references, for maintenance rather than prompting.
24    #[serde(default)]
25    pub sources: Vec<String>,
26    /// Date the capability references were checked.
27    pub verified: Option<String>,
28    /// The model's native effort control and routing criteria.
29    pub effort: Profile,
30}
31
32fn all_stages() -> Vec<Stage> {
33    Stage::ALL.to_vec()
34}
35
36/// Native effort capabilities. Profiles are inline and can be shared with YAML anchors.
37#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
38#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
39pub enum Profile {
40    /// A caller can select one of the supported native levels.
41    Configurable {
42        /// Native parameter, informational only.
43        control: String,
44        /// Ordered vocabulary with routing criteria.
45        levels: Vec<Level>,
46        /// Levels actually supported by this model, in ascending effort order.
47        supported_levels: Vec<String>,
48    },
49    /// A documented fixed setting; no judgement is required to select it.
50    Fixed {
51        /// Native control name.
52        control: String,
53        /// The sole setting.
54        level: String,
55        /// Why the setting is fixed.
56        reason: String,
57    },
58    /// No verified, configurable control is available for this binding.
59    Unavailable {
60        /// Why advice cannot be offered.
61        reason: String,
62    },
63}
64
65/// One effort level's shared criterion with optional per-stage overrides.
66#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
67#[serde(deny_unknown_fields)]
68pub struct Level {
69    /// Native value, not a provider-neutral scale.
70    pub name: String,
71    /// Fallback criterion for stages without an override.
72    pub description: Option<String>,
73    /// Stage-specific criteria.
74    #[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/// Advice for each concrete model in one rung.
88#[derive(Debug, Clone, PartialEq, Serialize)]
89pub struct ModelEffort {
90    /// Concrete model identifier, or the rung name for legacy unspecified data.
91    pub model: String,
92    /// Native control name when known.
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub control: Option<String>,
95    /// Whether there is a recommendation or why there is none.
96    pub status: Status,
97    /// Native value, present only for recommended or fixed settings.
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub level: Option<String>,
100    /// Explanation for deterministic outcomes.
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub reason: Option<String>,
103    /// Effort or remaining-work judgement, absent for legacy or normalized no-design results.
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub assessment: Option<Assessment>,
106    /// Effort uncertainty, separate from model-class close calls.
107    pub close_call: bool,
108}
109
110/// The possible outcomes of downstream effort routing.
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
112#[serde(rename_all = "snake_case")]
113pub enum Status {
114    /// A native effort level was selected.
115    Recommended,
116    /// No work remains for this stage.
117    NotNeeded,
118    /// Even maximum effort does not meet the reliability bar.
119    Insufficient,
120    /// The model has one documented setting.
121    Fixed,
122    /// No verified control is available.
123    Unavailable,
124    /// A legacy custom ladder has no effort metadata.
125    Unspecified,
126}
127
128/// Jev's unmodified evidence for an effort or applicability judgement.
129#[derive(Debug, Clone, PartialEq, Serialize)]
130pub struct Assessment {
131    /// Selected level or outcome.
132    pub choice: String,
133    /// Jev confidence (not necessarily the chosen option's probability).
134    pub confidence: f64,
135    /// Probabilities of the offered options.
136    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
298/// Non-configurable models share one stage-applicability question per ladder.
299fn 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
460/// Group all stage advice by concrete model so each binding is printed once.
461pub(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                    // Keep exceptional explanations below the table instead of widening every row.
542                    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;