Skip to main content

converge_model/
formation.rs

1// Copyright 2024-2026 Reflective Labs
2// SPDX-License-Identifier: MIT
3
4//! Canonical formation semantics for downstream consumers and embedders.
5
6use converge_pack::ContextKey;
7use converge_provider_api::{CostClass, LatencyClass};
8use serde::{Deserialize, Serialize};
9
10/// Describes the shape and operating envelope of a suggestor.
11pub trait SuggestorProfile {
12    fn role(&self) -> SuggestorRole;
13    fn output_keys(&self) -> &[ContextKey];
14    fn cost_hint(&self) -> CostClass;
15    fn latency_hint(&self) -> LatencyClass;
16    fn capabilities(&self) -> &[SuggestorCapability];
17    fn confidence_range(&self) -> (f32, f32);
18}
19
20/// The coarse role a suggestor plays inside a formation.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum SuggestorRole {
24    Analysis,
25    Planning,
26    Evaluation,
27    Constraint,
28    Signal,
29    Synthesis,
30    Meta,
31}
32
33/// Capabilities formation assembly may use to filter or prefer suggestors.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
35#[serde(rename_all = "snake_case")]
36pub enum SuggestorCapability {
37    LlmReasoning,
38    KnowledgeRetrieval,
39    Analytics,
40    Optimization,
41    PolicyEnforcement,
42    HumanInTheLoop,
43    ExperienceLearning,
44}
45
46/// Serializable snapshot of a suggestor's formation metadata.
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct ProfileSnapshot {
49    pub name: String,
50    pub role: SuggestorRole,
51    pub output_keys: Vec<ContextKey>,
52    pub cost_hint: CostClass,
53    pub latency_hint: LatencyClass,
54    pub capabilities: Vec<SuggestorCapability>,
55    pub confidence_min: f32,
56    pub confidence_max: f32,
57}
58
59impl ProfileSnapshot {
60    #[must_use]
61    pub fn from_profile(name: impl Into<String>, profile: &dyn SuggestorProfile) -> Self {
62        let (min, max) = profile.confidence_range();
63        Self {
64            name: name.into(),
65            role: profile.role(),
66            output_keys: profile.output_keys().to_vec(),
67            cost_hint: profile.cost_hint(),
68            latency_hint: profile.latency_hint(),
69            capabilities: profile.capabilities().to_vec(),
70            confidence_min: min,
71            confidence_max: max,
72        }
73    }
74}
75
76/// Structured request for formation assembly.
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct FormationRequest {
79    /// Stable request identifier used for idempotency.
80    pub id: String,
81    /// Roles that must be covered. Duplicates mean multiple seats of the same role.
82    pub required_roles: Vec<SuggestorRole>,
83    /// Extra capability constraints all eligible suggestors must satisfy.
84    pub required_capabilities: Vec<SuggestorCapability>,
85}
86
87/// Structured result of formation assembly.
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct FormationPlan {
90    /// The request this plan answers.
91    pub request_id: String,
92    /// Matched role-to-suggestor assignments.
93    pub assignments: Vec<RoleAssignment>,
94    /// Roles that could not be filled from the catalog.
95    pub unmatched_roles: Vec<SuggestorRole>,
96    /// `assignments.len() / required_roles.len()` — 1.0 is full coverage.
97    pub coverage_ratio: f64,
98}
99
100/// A single role-to-suggestor assignment.
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102pub struct RoleAssignment {
103    pub role: SuggestorRole,
104    pub suggestor: String,
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    struct AnalysisSuggestor;
112
113    impl SuggestorProfile for AnalysisSuggestor {
114        fn role(&self) -> SuggestorRole {
115            SuggestorRole::Analysis
116        }
117
118        fn output_keys(&self) -> &[ContextKey] {
119            &[ContextKey::Hypotheses]
120        }
121
122        fn cost_hint(&self) -> CostClass {
123            CostClass::Medium
124        }
125
126        fn latency_hint(&self) -> LatencyClass {
127            LatencyClass::Interactive
128        }
129
130        fn capabilities(&self) -> &[SuggestorCapability] {
131            &[SuggestorCapability::LlmReasoning]
132        }
133
134        fn confidence_range(&self) -> (f32, f32) {
135            (0.5, 0.95)
136        }
137    }
138
139    #[test]
140    fn profile_snapshot_captures_all_fields() {
141        let suggestor = AnalysisSuggestor;
142        let snap = ProfileSnapshot::from_profile("analysis-1", &suggestor);
143
144        assert_eq!(snap.name, "analysis-1");
145        assert_eq!(snap.role, SuggestorRole::Analysis);
146        assert_eq!(snap.output_keys, vec![ContextKey::Hypotheses]);
147        assert_eq!(snap.confidence_min, 0.5);
148        assert_eq!(snap.confidence_max, 0.95);
149        assert_eq!(snap.capabilities, vec![SuggestorCapability::LlmReasoning]);
150    }
151
152    #[test]
153    fn profile_snapshot_serde_roundtrip() {
154        let suggestor = AnalysisSuggestor;
155        let snap = ProfileSnapshot::from_profile("analysis-1", &suggestor);
156        let json = serde_json::to_string(&snap).unwrap();
157        let back: ProfileSnapshot = serde_json::from_str(&json).unwrap();
158
159        assert_eq!(back.name, snap.name);
160        assert_eq!(back.role, snap.role);
161        assert_eq!(back.confidence_min, snap.confidence_min);
162    }
163
164    #[test]
165    fn formation_request_and_plan_roundtrip() {
166        let request = FormationRequest {
167            id: "req-1".to_string(),
168            required_roles: vec![SuggestorRole::Analysis, SuggestorRole::Planning],
169            required_capabilities: vec![SuggestorCapability::Analytics],
170        };
171        let plan = FormationPlan {
172            request_id: request.id.clone(),
173            assignments: vec![RoleAssignment {
174                role: SuggestorRole::Analysis,
175                suggestor: "analysis-1".to_string(),
176            }],
177            unmatched_roles: vec![SuggestorRole::Planning],
178            coverage_ratio: 0.5,
179        };
180
181        let request_back: FormationRequest =
182            serde_json::from_str(&serde_json::to_string(&request).unwrap()).unwrap();
183        let plan_back: FormationPlan =
184            serde_json::from_str(&serde_json::to_string(&plan).unwrap()).unwrap();
185
186        assert_eq!(request_back.required_roles, request.required_roles);
187        assert_eq!(plan_back.assignments, plan.assignments);
188        assert_eq!(plan_back.unmatched_roles, plan.unmatched_roles);
189    }
190}