Skip to main content

organism_runtime/
classifier.rs

1//! Problem classifier Suggestor.
2//!
3//! Reads `Seeds` (and optionally `Signals`), runs the deterministic keyword
4//! classifier from [`organism_intent::problem`], and emits a `Hypotheses`
5//! fact tagged with the resulting [`ProblemClass`].
6//!
7//! The Suggestor is the *in-loop* refinement path: after seeds land, the
8//! classifier observes them and posts a hypothesis the rest of the
9//! convergence loop can react to. The *pre-loop* selection path
10//! ([`crate::guru::FormationGuru`]) calls [`organism_intent::problem::classify`]
11//! directly on the structured `IntentPacket` to pick which formation template
12//! to run in the first place.
13
14use converge_pack::{
15    AgentEffect, Context, ContextFact, ContextKey, ProposedFact, Provenance, ProvenanceSource,
16    Suggestor, TextPayload,
17};
18use organism_intent::problem::{ProblemClassification, classify_text};
19
20use crate::provenance::ORGANISM_RUNTIME_PROVENANCE;
21
22fn proposed_text_fact(
23    key: ContextKey,
24    id: impl Into<converge_pack::ProposalId>,
25    content: impl Into<String>,
26) -> ProposedFact {
27    ORGANISM_RUNTIME_PROVENANCE.proposed_fact(key, id, TextPayload::new(content))
28}
29
30fn fact_text(fact: &ContextFact) -> &str {
31    fact.text().unwrap_or_default()
32}
33
34/// Suggestor that classifies the dominant problem shape from seeds and
35/// signals already in convergence context.
36///
37/// Inputs: `ContextKey::Seeds`, `ContextKey::Signals` (optional).
38/// Outputs: one `ContextKey::Hypotheses` fact carrying the
39/// [`ProblemClassification`] as JSON.
40///
41/// Idempotent: re-running on a context that already contains a
42/// `problem-class:` hypothesis is a no-op (the predicate stops accepting).
43pub struct ProblemClassifierSuggestor;
44
45impl ProblemClassifierSuggestor {
46    #[must_use]
47    pub fn new() -> Self {
48        Self
49    }
50}
51
52impl Default for ProblemClassifierSuggestor {
53    fn default() -> Self {
54        Self::new()
55    }
56}
57
58const FACT_PREFIX: &str = "problem-class";
59
60#[async_trait::async_trait]
61#[allow(clippy::unnecessary_literal_bound)]
62impl Suggestor for ProblemClassifierSuggestor {
63    fn name(&self) -> &'static str {
64        "problem-classifier"
65    }
66
67    fn dependencies(&self) -> &[ContextKey] {
68        &[ContextKey::Seeds]
69    }
70
71    fn provenance(&self) -> Provenance {
72        ORGANISM_RUNTIME_PROVENANCE.provenance()
73    }
74
75    fn accepts(&self, ctx: &dyn Context) -> bool {
76        // Need at least one seed; don't re-fire if we've already classified.
77        ctx.has(ContextKey::Seeds)
78            && !ctx
79                .get(ContextKey::Hypotheses)
80                .iter()
81                .any(|f| f.id().starts_with(FACT_PREFIX))
82    }
83
84    async fn execute(&self, ctx: &dyn Context) -> AgentEffect {
85        let mut haystack = String::new();
86        for fact in ctx.get(ContextKey::Seeds) {
87            haystack.push(' ');
88            haystack.push_str(fact_text(fact));
89        }
90        for fact in ctx.get(ContextKey::Signals) {
91            haystack.push(' ');
92            haystack.push_str(fact_text(fact));
93        }
94
95        let classification = classify_text(&haystack);
96        let payload = serde_json::json!({
97            "agent": "problem-classifier",
98            "class": classification.class.as_str(),
99            "matched_keywords": classification.matched_keywords,
100            "defaulted": classification.defaulted,
101        });
102
103        AgentEffect::with_proposal(proposed_text_fact(
104            ContextKey::Hypotheses,
105            format!("{FACT_PREFIX}:{}", classification.class.as_str()),
106            payload.to_string(),
107        ))
108    }
109}
110
111/// Read the latest `problem-class:` hypothesis out of context, if any.
112///
113/// FormationGuru and other downstream consumers can use this when they want
114/// the in-loop classification rather than computing one from the
115/// `IntentPacket` directly. Returns `None` if no classification has been
116/// emitted yet.
117#[must_use]
118pub fn extract_classification(ctx: &dyn Context) -> Option<ProblemClassification> {
119    ctx.get(ContextKey::Hypotheses)
120        .iter()
121        .find(|f| f.id().starts_with(FACT_PREFIX))
122        .and_then(|f| serde_json::from_str(fact_text(f)).ok())
123        .and_then(|v: serde_json::Value| {
124            let class_str = v.get("class")?.as_str()?;
125            let class = match class_str {
126                "decision" => organism_intent::problem::ProblemClass::Decision,
127                "research" => organism_intent::problem::ProblemClass::Research,
128                "evaluation" => organism_intent::problem::ProblemClass::Evaluation,
129                "planning" => organism_intent::problem::ProblemClass::Planning,
130                "diligence" => organism_intent::problem::ProblemClass::Diligence,
131                "incident" => organism_intent::problem::ProblemClass::Incident,
132                "strategy" => organism_intent::problem::ProblemClass::Strategy,
133                _ => return None,
134            };
135            let matched_keywords = v
136                .get("matched_keywords")?
137                .as_array()?
138                .iter()
139                .filter_map(|w| w.as_str().map(str::to_owned))
140                .collect();
141            let defaulted = v.get("defaulted")?.as_bool()?;
142            let tiebroken = v
143                .get("tiebroken")
144                .and_then(serde_json::Value::as_bool)
145                .unwrap_or(false);
146            Some(ProblemClassification {
147                class,
148                matched_keywords,
149                defaulted,
150                tiebroken,
151            })
152        })
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158    use crate::formation::Formation;
159    use organism_intent::problem::ProblemClass;
160
161    fn classified_payload_from(seed: &str) -> serde_json::Value {
162        let result = tokio::runtime::Runtime::new()
163            .expect("runtime")
164            .block_on(async {
165                Formation::new("classifier-test")
166                    .agent(ProblemClassifierSuggestor::new())
167                    .seed(ContextKey::Seeds, "seed-1", seed, "test")
168                    .run()
169                    .await
170                    .expect("formation runs")
171            });
172
173        let hypotheses = result.converge_result.context.get(ContextKey::Hypotheses);
174        let fact = hypotheses
175            .iter()
176            .find(|f| f.id().starts_with(FACT_PREFIX))
177            .expect("classifier emitted a problem-class hypothesis");
178        serde_json::from_str(fact_text(fact)).expect("payload is JSON")
179    }
180
181    #[test]
182    fn classifier_emits_evaluation_for_evaluation_keywords() {
183        let payload = classified_payload_from("evaluate the vendor proposals carefully");
184        assert_eq!(payload["class"], "evaluation");
185        assert_eq!(payload["defaulted"], false);
186    }
187
188    #[test]
189    fn classifier_emits_diligence_for_vet_keyword() {
190        let payload = classified_payload_from("vet the acquisition target end-to-end");
191        assert_eq!(payload["class"], "diligence");
192    }
193
194    #[test]
195    fn classifier_emits_incident_for_outage_keyword() {
196        let payload = classified_payload_from("respond to the prod outage and stabilize");
197        assert_eq!(payload["class"], "incident");
198    }
199
200    #[test]
201    fn classifier_falls_back_to_decision_with_no_keywords() {
202        let payload = classified_payload_from("doing the thing today");
203        assert_eq!(payload["class"], "decision");
204        assert_eq!(payload["defaulted"], true);
205    }
206
207    #[test]
208    fn extract_classification_recovers_typed_value() {
209        let payload = classified_payload_from("research the competitive landscape");
210        // Roundtrip the JSON payload through extract_classification's matcher
211        // by constructing a ProblemClassification directly from it.
212        assert_eq!(payload["class"], "research");
213        let class_str = payload["class"].as_str().unwrap();
214        let class = match class_str {
215            "research" => ProblemClass::Research,
216            _ => panic!("unexpected class {class_str}"),
217        };
218        assert_eq!(class, ProblemClass::Research);
219    }
220}