cellstate_core/
battle_intel.rs

1//! Battle Intel features: Edges, Evolution, Summarization
2
3use crate::{
4    AbstractionLevel, ContentHash, EdgeId, EdgeType, EntityRef, EvolutionPhase, Provenance,
5    SnapshotId, SummarizationPolicyId, SummarizationTrigger, Timestamp, TrajectoryId,
6};
7use serde::{Deserialize, Serialize};
8
9/// Participant in an edge with optional role.
10/// Enables both binary edges (2 participants) and hyperedges (N participants).
11#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
12#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
13pub struct EdgeParticipant {
14    /// Reference to the entity participating in this edge
15    pub entity_ref: EntityRef,
16    /// Optional role label (e.g., "source", "target", "input", "output")
17    pub role: Option<String>,
18}
19
20/// Edge - graph relationship between entities.
21/// Supports both binary edges (A→B) and hyperedges (N-ary relationships).
22#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
23#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
24pub struct Edge {
25    #[cfg_attr(feature = "openapi", schema(value_type = String, format = "uuid"))]
26    pub edge_id: EdgeId,
27    pub edge_type: EdgeType,
28    /// Participants in this edge (len=2 for binary, len>2 for hyperedge)
29    pub participants: Vec<EdgeParticipant>,
30    /// Optional relationship strength [0.0, 1.0]
31    pub weight: Option<f32>,
32    /// Optional trajectory context
33    #[cfg_attr(feature = "openapi", schema(value_type = Option<String>, format = "uuid"))]
34    pub trajectory_id: Option<TrajectoryId>,
35    /// How this edge was created
36    pub provenance: Provenance,
37    #[cfg_attr(feature = "openapi", schema(value_type = String, format = "date-time"))]
38    pub created_at: Timestamp,
39    #[cfg_attr(feature = "openapi", schema(value_type = Option<Object>))]
40    pub metadata: Option<serde_json::Value>,
41}
42
43impl Edge {
44    /// Check if this is a binary edge (exactly 2 participants)
45    pub fn is_binary(&self) -> bool {
46        self.participants.len() == 2
47    }
48
49    /// Check if this is a hyperedge (more than 2 participants)
50    pub fn is_hyperedge(&self) -> bool {
51        self.participants.len() > 2
52    }
53
54    /// Get participants with a specific role
55    pub fn participants_with_role(&self, role: &str) -> Vec<&EdgeParticipant> {
56        self.participants
57            .iter()
58            .filter(|p| p.role.as_deref() == Some(role))
59            .collect()
60    }
61}
62
63// ============================================================================
64// EVOLUTION ENTITIES (Battle Intel Feature 3)
65// ============================================================================
66
67/// Benchmark metrics from an evolution run.
68#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
69#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
70pub struct EvolutionMetrics {
71    /// How relevant were the retrievals? [0.0, 1.0]
72    pub retrieval_accuracy: f32,
73    /// Tokens used vs budget ratio [0.0, 1.0]
74    pub token_efficiency: f32,
75    /// 50th percentile latency in milliseconds
76    pub latency_p50_ms: i64,
77    /// 99th percentile latency in milliseconds
78    pub latency_p99_ms: i64,
79    /// Estimated cost for the benchmark run
80    pub cost_estimate: f32,
81    /// Number of queries used in benchmark
82    pub benchmark_queries: i32,
83}
84
85/// Evolution snapshot for pack config benchmarking.
86/// Captures a frozen state of configuration for A/B testing.
87#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
88#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
89pub struct EvolutionSnapshot {
90    /// Snapshot ID
91    #[cfg_attr(feature = "openapi", schema(value_type = String, format = "uuid"))]
92    pub snapshot_id: SnapshotId,
93    /// Human-readable snapshot name
94    pub name: String,
95    /// SHA-256 hash of the pack config source
96    #[cfg_attr(feature = "openapi", schema(value_type = String, format = "byte"))]
97    pub config_hash: ContentHash,
98    /// The actual pack configuration text
99    pub config_source: String,
100    /// Current phase of this snapshot
101    pub phase: EvolutionPhase,
102    #[cfg_attr(feature = "openapi", schema(value_type = String, format = "date-time"))]
103    pub created_at: Timestamp,
104    /// Metrics populated after benchmark completes
105    pub metrics: Option<EvolutionMetrics>,
106    #[cfg_attr(feature = "openapi", schema(value_type = Option<Object>))]
107    pub metadata: Option<serde_json::Value>,
108}
109
110// ============================================================================
111// SUMMARIZATION POLICY (Battle Intel Feature 4)
112// ============================================================================
113
114/// Policy for automatic summarization/abstraction.
115/// Defines when and how to generate L1/L2 notes from lower levels.
116#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
117#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
118pub struct SummarizationPolicy {
119    #[cfg_attr(feature = "openapi", schema(value_type = String, format = "uuid"))]
120    pub summarization_policy_id: SummarizationPolicyId,
121    /// Human-readable policy name
122    pub name: String,
123    /// Conditions that trigger summarization
124    pub triggers: Vec<SummarizationTrigger>,
125    /// Target abstraction level to generate (L1 or L2)
126    pub target_level: AbstractionLevel,
127    /// Source abstraction level to summarize FROM
128    pub source_level: AbstractionLevel,
129    /// Maximum number of source items to summarize at once
130    pub max_sources: i32,
131    /// Whether to auto-create SynthesizedFrom edges
132    pub create_edges: bool,
133    #[cfg_attr(feature = "openapi", schema(value_type = String, format = "date-time"))]
134    pub created_at: Timestamp,
135    #[cfg_attr(feature = "openapi", schema(value_type = Option<Object>))]
136    pub metadata: Option<serde_json::Value>,
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142    use crate::{EntityIdType, EntityType, ExtractionMethod, Provenance};
143
144    fn make_edge(n_participants: usize) -> Edge {
145        let participants: Vec<EdgeParticipant> = (0..n_participants)
146            .map(|i| EdgeParticipant {
147                entity_ref: EntityRef {
148                    entity_type: EntityType::Artifact,
149                    id: uuid::Uuid::now_v7(),
150                },
151                role: if i == 0 {
152                    Some("source".into())
153                } else {
154                    Some("target".into())
155                },
156            })
157            .collect();
158        Edge {
159            edge_id: EdgeId::new(uuid::Uuid::now_v7()),
160            edge_type: EdgeType::RelatesTo,
161            participants,
162            weight: Some(0.8),
163            trajectory_id: None,
164            provenance: Provenance {
165                source_turn: 1,
166                extraction_method: ExtractionMethod::Explicit,
167                confidence: Some(0.9),
168            },
169            created_at: chrono::Utc::now(),
170            metadata: None,
171        }
172    }
173
174    #[test]
175    fn binary_edge_is_binary() {
176        let edge = make_edge(2);
177        assert!(edge.is_binary());
178        assert!(!edge.is_hyperedge());
179    }
180
181    #[test]
182    fn hyperedge_is_hyperedge() {
183        let edge = make_edge(3);
184        assert!(!edge.is_binary());
185        assert!(edge.is_hyperedge());
186    }
187
188    #[test]
189    fn single_participant_is_neither() {
190        let edge = make_edge(1);
191        assert!(!edge.is_binary());
192        assert!(!edge.is_hyperedge());
193    }
194
195    #[test]
196    fn participants_with_role_filters_correctly() {
197        let edge = make_edge(3);
198        let sources = edge.participants_with_role("source");
199        assert_eq!(sources.len(), 1);
200        let targets = edge.participants_with_role("target");
201        assert_eq!(targets.len(), 2);
202        let unknown = edge.participants_with_role("unknown");
203        assert!(unknown.is_empty());
204    }
205
206    #[test]
207    fn edge_serde_roundtrip() {
208        let edge = make_edge(2);
209        let json = serde_json::to_string(&edge).unwrap();
210        let d: Edge = serde_json::from_str(&json).unwrap();
211        assert_eq!(edge.edge_id, d.edge_id);
212        assert_eq!(edge.participants.len(), d.participants.len());
213    }
214
215    #[test]
216    fn evolution_metrics_serde_roundtrip() {
217        let m = EvolutionMetrics {
218            retrieval_accuracy: 0.95,
219            token_efficiency: 0.8,
220            latency_p50_ms: 100,
221            latency_p99_ms: 500,
222            cost_estimate: 0.05,
223            benchmark_queries: 100,
224        };
225        let json = serde_json::to_string(&m).unwrap();
226        let d: EvolutionMetrics = serde_json::from_str(&json).unwrap();
227        assert_eq!(m, d);
228    }
229
230    #[test]
231    fn summarization_policy_serde_roundtrip() {
232        let p = SummarizationPolicy {
233            summarization_policy_id: <SummarizationPolicyId as EntityIdType>::new(
234                uuid::Uuid::now_v7(),
235            ),
236            name: "test-policy".into(),
237            triggers: vec![crate::SummarizationTrigger::Manual],
238            target_level: crate::AbstractionLevel::Summary,
239            source_level: crate::AbstractionLevel::Raw,
240            max_sources: 10,
241            create_edges: true,
242            created_at: chrono::Utc::now(),
243            metadata: None,
244        };
245        let json = serde_json::to_string(&p).unwrap();
246        let d: SummarizationPolicy = serde_json::from_str(&json).unwrap();
247        assert_eq!(p.name, d.name);
248        assert_eq!(p.max_sources, d.max_sources);
249    }
250}