Skip to main content

organism_intent/
problem.rs

1//! Problem classification.
2//!
3//! Coarse taxonomy of organizational problems. The classifier takes an
4//! [`IntentPacket`](crate::IntentPacket) and tags it with one of seven classes,
5//! which the Formation Guru uses to narrow down candidate formation templates.
6//!
7//! The deterministic classifier here matches keywords in the intent's outcome
8//! and known entities; it is intentionally cheap and biased toward returning
9//! something reasonable. Ambiguous intents fall back to [`ProblemClass::Decision`]
10//! since most multi-Suggestor work in Organism is decision-shaped. An LLM
11//! tiebreaker is the natural follow-on for genuinely ambiguous cases — out of
12//! scope here.
13
14use serde::{Deserialize, Serialize};
15
16use crate::IntentPacket;
17
18/// Coarse taxonomy of organizational problems Organism handles.
19///
20/// The seven classes cover most business problem shapes. They are *not*
21/// mutually exclusive in practice — a "vendor selection" intent is both a
22/// Decision and a Diligence problem — but the classifier picks a single
23/// dominant class so the Formation Guru can route to one template per run.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum ProblemClass {
27    /// Pick one option from a finite candidate set ("approve this expense",
28    /// "select this vendor", "choose between two strategies").
29    Decision,
30    /// Open-ended fact-finding ("research the competitive landscape",
31    /// "investigate why churn rose", "explore options").
32    Research,
33    /// Score / rank / compare against criteria ("evaluate vendor proposals",
34    /// "rate candidate performance").
35    Evaluation,
36    /// Forward-looking sequencing ("plan the Q3 launch", "schedule the
37    /// migration", "design the rollout").
38    Planning,
39    /// Adversarial fact-gathering with a verdict ("vet this acquisition
40    /// target", "audit the contract", "verify these claims").
41    Diligence,
42    /// Time-pressured stabilization ("fix the prod outage", "respond to the
43    /// incident", "resolve the breach").
44    Incident,
45    /// Long-horizon framing ("set our 3-year strategy", "define the vision",
46    /// "frame the market position").
47    Strategy,
48}
49
50impl ProblemClass {
51    /// Stable string name used in fact payloads and log lines.
52    #[must_use]
53    pub fn as_str(self) -> &'static str {
54        match self {
55            Self::Decision => "decision",
56            Self::Research => "research",
57            Self::Evaluation => "evaluation",
58            Self::Planning => "planning",
59            Self::Diligence => "diligence",
60            Self::Incident => "incident",
61            Self::Strategy => "strategy",
62        }
63    }
64}
65
66impl std::fmt::Display for ProblemClass {
67    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68        f.write_str(self.as_str())
69    }
70}
71
72/// Result of classifying an intent.
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74pub struct ProblemClassification {
75    pub class: ProblemClass,
76    /// The trigger words that fired to produce this class. Empty if the
77    /// classifier fell back to the default. Useful for traces and
78    /// explanations.
79    pub matched_keywords: Vec<String>,
80    /// True if no keyword matched and the classifier defaulted (without a
81    /// tiebreaker resolving the ambiguity).
82    pub defaulted: bool,
83    /// True if a [`ClassifierTiebreaker`] was consulted to resolve an
84    /// ambiguous keyword pass. A consumer that audits selections may prefer
85    /// to surface tiebroken classifications differently from
86    /// keyword-driven ones (lower confidence, may flip on retry).
87    #[serde(default)]
88    pub tiebroken: bool,
89}
90
91/// Plug-Boundary trait for LLM-backed (or otherwise external) tiebreakers
92/// the keyword classifier can defer to on ambiguous intents. Implementations
93/// live in host code (e.g. axiom or the application) so organism stays free
94/// of vendor adapter imports — same shape as the SemanticMatcher trait at
95/// resolution-ladder Level 3.
96#[async_trait::async_trait]
97pub trait ClassifierTiebreaker: Send + Sync {
98    /// Given the text the keyword classifier could not classify, commit to
99    /// one of the seven [`ProblemClass`] values. Implementations are
100    /// expected to always pick a class — but errors are advisory; the
101    /// caller falls back to the deterministic default on any failure.
102    ///
103    /// # Errors
104    ///
105    /// `TiebreakerError::Unavailable` — backend offline / no quota / etc.
106    /// `TiebreakerError::Other` — anything else.
107    async fn break_tie(&self, text: &str) -> Result<ProblemClass, TiebreakerError>;
108}
109
110/// Why a tiebreaker couldn't decide.
111#[derive(Debug, Clone, thiserror::Error)]
112pub enum TiebreakerError {
113    #[error("classifier tiebreaker unavailable")]
114    Unavailable,
115    #[error("classifier tiebreaker failed: {0}")]
116    Other(String),
117}
118
119/// Deterministic keyword-based classifier for an [`IntentPacket`].
120///
121/// Looks at the intent's `outcome` text plus any context-key prefixes the
122/// caller exposes via the intent's `constraints` and `forbidden` lists and
123/// returns the dominant [`ProblemClass`].
124///
125/// Ranking is by class-specific keyword count: the class whose keywords match
126/// the most distinct words wins. Ties break in the order:
127/// `Incident → Diligence → Evaluation → Decision → Research → Planning →
128/// Strategy`. Incidents win ties because misclassifying a stabilization
129/// problem as anything else is the most expensive error.
130#[must_use]
131pub fn classify(intent: &IntentPacket) -> ProblemClassification {
132    classify_text(&build_haystack(intent))
133}
134
135/// Classify a free-form text blob (e.g. concatenated Seed contents pulled
136/// from convergence context). Used by `ProblemClassifierSuggestor`, which sees
137/// `ProposedFact` content strings rather than typed `IntentPacket`s.
138#[must_use]
139pub fn classify_text(haystack: &str) -> ProblemClassification {
140    let words = tokenize(haystack);
141
142    let mut hits: Vec<(ProblemClass, Vec<String>)> = Vec::new();
143    for class in ALL_CLASSES {
144        let keywords = class_keywords(class);
145        let matched: Vec<String> = words
146            .iter()
147            .filter(|w| keywords.iter().any(|k| word_matches(w, k)))
148            .cloned()
149            .collect();
150        if !matched.is_empty() {
151            hits.push((class, matched));
152        }
153    }
154
155    if hits.is_empty() {
156        return ProblemClassification {
157            class: ProblemClass::Decision,
158            matched_keywords: Vec::new(),
159            defaulted: true,
160            tiebroken: false,
161        };
162    }
163
164    // Highest match count wins; ties go to the class earliest in TIE_ORDER.
165    hits.sort_by(|a, b| {
166        let by_count = b.1.len().cmp(&a.1.len());
167        if by_count.is_eq() {
168            tie_rank(a.0).cmp(&tie_rank(b.0))
169        } else {
170            by_count
171        }
172    });
173
174    let (class, matched) = hits.into_iter().next().expect("non-empty");
175    ProblemClassification {
176        class,
177        matched_keywords: matched,
178        defaulted: false,
179        tiebroken: false,
180    }
181}
182
183/// Classify with optional LLM tiebreaker fallback. The deterministic keyword
184/// pass runs first; only if it defaults (no keyword matched) does the
185/// tiebreaker get consulted. On tiebreaker error, the result falls back to
186/// the deterministic default — degraded but never absent.
187///
188/// Hosts use this when ambiguous truths arrive often enough to justify the
189/// LLM round-trip cost. For deterministic-only classification (no LLM
190/// dependency, instant), call [`classify`] directly.
191pub async fn classify_with_tiebreaker<T: ClassifierTiebreaker + ?Sized>(
192    intent: &IntentPacket,
193    tiebreaker: &T,
194) -> ProblemClassification {
195    classify_text_with_tiebreaker(&build_haystack(intent), tiebreaker).await
196}
197
198/// Free-text variant of [`classify_with_tiebreaker`]. Used by Suggestors
199/// that read seed text out of context.
200pub async fn classify_text_with_tiebreaker<T: ClassifierTiebreaker + ?Sized>(
201    haystack: &str,
202    tiebreaker: &T,
203) -> ProblemClassification {
204    let initial = classify_text(haystack);
205    if !initial.defaulted {
206        return initial;
207    }
208    match tiebreaker.break_tie(haystack).await {
209        Ok(class) => ProblemClassification {
210            class,
211            matched_keywords: vec![format!("tiebreaker:{class}")],
212            defaulted: false,
213            tiebroken: true,
214        },
215        Err(_) => initial, // degraded: keep the default
216    }
217}
218
219const ALL_CLASSES: [ProblemClass; 7] = [
220    ProblemClass::Decision,
221    ProblemClass::Research,
222    ProblemClass::Evaluation,
223    ProblemClass::Planning,
224    ProblemClass::Diligence,
225    ProblemClass::Incident,
226    ProblemClass::Strategy,
227];
228
229const TIE_ORDER: [ProblemClass; 7] = [
230    ProblemClass::Incident,
231    ProblemClass::Diligence,
232    ProblemClass::Evaluation,
233    ProblemClass::Decision,
234    ProblemClass::Research,
235    ProblemClass::Planning,
236    ProblemClass::Strategy,
237];
238
239fn tie_rank(class: ProblemClass) -> usize {
240    TIE_ORDER
241        .iter()
242        .position(|c| *c == class)
243        .unwrap_or(usize::MAX)
244}
245
246fn class_keywords(class: ProblemClass) -> &'static [&'static str] {
247    match class {
248        ProblemClass::Decision => &[
249            "decide",
250            "decision",
251            "select",
252            "selection",
253            "choose",
254            "choice",
255            "pick",
256            "approve",
257            "approval",
258            "reject",
259            "rejection",
260        ],
261        ProblemClass::Research => &[
262            "research",
263            "investigate",
264            "investigation",
265            "explore",
266            "exploration",
267            "discover",
268            "find",
269            "study",
270            "learn",
271            "survey",
272        ],
273        ProblemClass::Evaluation => &[
274            "evaluate",
275            "evaluation",
276            "assess",
277            "assessment",
278            "score",
279            "rank",
280            "rating",
281            "rate",
282            "compare",
283            "comparison",
284            "benchmark",
285            "review",
286        ],
287        ProblemClass::Planning => &[
288            "plan",
289            "planning",
290            "schedule",
291            "scheduling",
292            "design",
293            "prepare",
294            "organize",
295            "structure",
296            "roadmap-execution",
297            "rollout",
298            "sequence",
299        ],
300        ProblemClass::Diligence => &[
301            "diligence",
302            "due-diligence",
303            "vet",
304            "audit",
305            "verify",
306            "verification",
307            "validate",
308            "validation",
309            "qualify",
310            "qualification",
311            "background-check",
312        ],
313        ProblemClass::Incident => &[
314            "incident",
315            "outage",
316            "issue",
317            "bug",
318            "fix",
319            "resolve",
320            "emergency",
321            "urgent",
322            "stabilize",
323            "remediate",
324            "rollback",
325            "respond",
326        ],
327        ProblemClass::Strategy => &[
328            "strategy",
329            "strategic",
330            "vision",
331            "roadmap",
332            "long-term",
333            "direction",
334            "positioning",
335            "market-position",
336            "framing",
337        ],
338    }
339}
340
341fn build_haystack(intent: &IntentPacket) -> String {
342    let mut buf = intent.outcome.clone();
343    for c in &intent.constraints {
344        buf.push(' ');
345        buf.push_str(c);
346    }
347    for f in &intent.forbidden {
348        buf.push(' ');
349        buf.push_str(&f.action);
350    }
351    if let Some(s) = intent.context.as_str() {
352        buf.push(' ');
353        buf.push_str(s);
354    }
355    buf
356}
357
358fn tokenize(haystack: &str) -> Vec<String> {
359    haystack
360        .to_lowercase()
361        .split(|c: char| !(c.is_alphanumeric() || c == '-'))
362        .filter(|w| !w.is_empty())
363        .map(str::to_owned)
364        .collect()
365}
366
367fn word_matches(word: &str, keyword: &str) -> bool {
368    word == keyword || word.starts_with(keyword) || keyword.starts_with(word) && word.len() >= 4
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374    use chrono::{Duration, Utc};
375
376    fn intent(outcome: &str) -> IntentPacket {
377        IntentPacket::new(outcome, Utc::now() + Duration::hours(1))
378    }
379
380    #[test]
381    fn decision_keyword_matches() {
382        let i = intent("decide which vendor to approve");
383        let r = classify(&i);
384        assert_eq!(r.class, ProblemClass::Decision);
385        assert!(!r.defaulted);
386    }
387
388    #[test]
389    fn research_keyword_matches() {
390        let i = intent("research the competitive landscape for Q3");
391        assert_eq!(classify(&i).class, ProblemClass::Research);
392    }
393
394    #[test]
395    fn evaluation_keyword_matches() {
396        let i = intent("evaluate vendor proposals against the rubric");
397        assert_eq!(classify(&i).class, ProblemClass::Evaluation);
398    }
399
400    #[test]
401    fn planning_keyword_matches() {
402        let i = intent("plan the Q3 launch sequence");
403        assert_eq!(classify(&i).class, ProblemClass::Planning);
404    }
405
406    #[test]
407    fn diligence_keyword_matches() {
408        let i = intent("vet the acquisition target end-to-end");
409        assert_eq!(classify(&i).class, ProblemClass::Diligence);
410    }
411
412    #[test]
413    fn incident_keyword_matches() {
414        let i = intent("respond to the prod outage and stabilize");
415        assert_eq!(classify(&i).class, ProblemClass::Incident);
416    }
417
418    #[test]
419    fn strategy_keyword_matches() {
420        let i = intent("set our three-year strategic direction");
421        assert_eq!(classify(&i).class, ProblemClass::Strategy);
422    }
423
424    #[test]
425    fn empty_outcome_defaults_to_decision() {
426        let i = intent("doing the thing");
427        let r = classify(&i);
428        assert_eq!(r.class, ProblemClass::Decision);
429        assert!(r.defaulted);
430        assert!(r.matched_keywords.is_empty());
431    }
432
433    #[test]
434    fn incident_wins_tie_against_decision() {
435        let i = intent("decide how to respond to the outage");
436        // both Decision (decide) and Incident (respond, outage) match;
437        // Incident has more matches AND wins ties anyway.
438        assert_eq!(classify(&i).class, ProblemClass::Incident);
439    }
440
441    #[test]
442    fn diligence_wins_over_research_when_keywords_co_occur() {
443        let i = intent("vet and research the new partner");
444        let r = classify(&i);
445        // Both classes match; with equal counts, Diligence wins by tie rank.
446        // (Misclassifying a vetting workflow as Research would skip the
447        // adversarial verdict, which is the point of Diligence templates.)
448        assert_eq!(r.class, ProblemClass::Diligence);
449    }
450
451    #[test]
452    fn matched_keywords_recorded() {
453        let i = intent("evaluate and rank the vendor proposals");
454        let r = classify(&i);
455        assert_eq!(r.class, ProblemClass::Evaluation);
456        assert!(r.matched_keywords.iter().any(|w| w == "evaluate"));
457        assert!(r.matched_keywords.iter().any(|w| w == "rank"));
458    }
459
460    #[test]
461    fn constraints_and_forbidden_contribute_to_classification() {
462        let mut i = intent("ship the thing");
463        i.constraints = vec!["audit trail required".into()];
464        // "audit" is a Diligence keyword; "ship" alone wouldn't match anything.
465        assert_eq!(classify(&i).class, ProblemClass::Diligence);
466    }
467
468    #[test]
469    fn problem_class_serde_snake_case() {
470        let s = serde_json::to_string(&ProblemClass::Diligence).unwrap();
471        assert_eq!(s, "\"diligence\"");
472        let back: ProblemClass = serde_json::from_str("\"incident\"").unwrap();
473        assert_eq!(back, ProblemClass::Incident);
474    }
475
476    #[test]
477    fn problem_class_display_matches_as_str() {
478        for class in ALL_CLASSES {
479            assert_eq!(class.to_string(), class.as_str());
480        }
481    }
482
483    // ── Tiebreaker tests ─────────────────────────────────────────────
484
485    struct StubTiebreaker {
486        class: ProblemClass,
487    }
488
489    #[async_trait::async_trait]
490    impl ClassifierTiebreaker for StubTiebreaker {
491        async fn break_tie(&self, _text: &str) -> Result<ProblemClass, TiebreakerError> {
492            Ok(self.class)
493        }
494    }
495
496    struct UnavailableTiebreaker;
497
498    #[async_trait::async_trait]
499    impl ClassifierTiebreaker for UnavailableTiebreaker {
500        async fn break_tie(&self, _text: &str) -> Result<ProblemClass, TiebreakerError> {
501            Err(TiebreakerError::Unavailable)
502        }
503    }
504
505    #[tokio::test]
506    async fn tiebreaker_invoked_only_when_keyword_pass_defaulted() {
507        let tb = StubTiebreaker {
508            class: ProblemClass::Strategy,
509        };
510        // Clear keyword match — tiebreaker NOT consulted.
511        let i = intent("evaluate the proposal carefully");
512        let r = classify_with_tiebreaker(&i, &tb).await;
513        assert_eq!(r.class, ProblemClass::Evaluation);
514        assert!(!r.tiebroken);
515    }
516
517    #[tokio::test]
518    async fn tiebreaker_resolves_ambiguous_classification() {
519        let tb = StubTiebreaker {
520            class: ProblemClass::Strategy,
521        };
522        let i = intent("doing the thing today");
523        let r = classify_with_tiebreaker(&i, &tb).await;
524        assert_eq!(r.class, ProblemClass::Strategy);
525        assert!(!r.defaulted);
526        assert!(r.tiebroken);
527        assert!(
528            r.matched_keywords
529                .iter()
530                .any(|k| k.starts_with("tiebreaker:"))
531        );
532    }
533
534    #[tokio::test]
535    async fn tiebreaker_failure_falls_back_to_default() {
536        let tb = UnavailableTiebreaker;
537        let i = intent("doing the thing today");
538        let r = classify_with_tiebreaker(&i, &tb).await;
539        assert_eq!(r.class, ProblemClass::Decision);
540        assert!(r.defaulted);
541        assert!(!r.tiebroken);
542    }
543}