perspt_agent/
types.rs

1//! SRBN Types
2//!
3//! Core types for the Stabilized Recursive Barrier Network.
4//! Based on PSP-000004 specification.
5
6use serde::{Deserialize, Serialize};
7use std::path::PathBuf;
8use std::time::SystemTime;
9
10/// Model tier for different agent roles
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
12pub enum ModelTier {
13    /// Deep reasoning model for planning and architecture
14    Architect,
15    /// Fast coding model for implementation
16    Actuator,
17    /// Sensor for LSP + Contract checking
18    Verifier,
19    /// Fast lookahead for speculation
20    Speculator,
21}
22
23impl ModelTier {
24    /// Get the recommended model for this tier
25    /// Default: gemini-flash-lite-latest for all tiers (can be overridden via CLI)
26    pub fn default_model(&self) -> &'static str {
27        // Use gemini-flash-lite-latest as the default for all tiers
28        // This can be overridden per-tier via CLI: --architect-model, --actuator-model, etc.
29        Self::default_model_name()
30    }
31
32    /// Get the default model name (static, for use when no instance is available)
33    pub fn default_model_name() -> &'static str {
34        "gemini-flash-lite-latest"
35    }
36}
37
38/// Test criticality levels for weighted tests
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
40pub enum Criticality {
41    /// Critical tests - highest energy penalty on failure
42    Critical,
43    /// High priority tests
44    High,
45    /// Low priority tests
46    Low,
47}
48
49impl Criticality {
50    /// Get the energy weight multiplier
51    pub fn weight(&self) -> f32 {
52        match self {
53            Criticality::Critical => 10.0,
54            Criticality::High => 3.0,
55            Criticality::Low => 1.0,
56        }
57    }
58}
59
60/// Weighted test definition
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct WeightedTest {
63    /// Test name or pattern
64    pub test_name: String,
65    /// Criticality level
66    pub criticality: Criticality,
67}
68
69/// Behavioral contract for a node
70///
71/// Defines the constraints and expectations for an SRBN node.
72#[derive(Debug, Clone, Default, Serialize, Deserialize)]
73pub struct BehavioralContract {
74    /// Required public API signature (hard constraint)
75    pub interface_signature: String,
76    /// Semantic constraints (e.g., "Use RS256 algorithm")
77    pub invariants: Vec<String>,
78    /// Anti-patterns to reject (e.g., "no unwrap()")
79    pub forbidden_patterns: Vec<String>,
80    /// Weighted test cases
81    pub weighted_tests: Vec<WeightedTest>,
82    /// Energy weights (alpha, beta, gamma) for V(x) calculation
83    /// Default: (1.0, 0.5, 2.0) - Logic failures weighted highest
84    pub energy_weights: (f32, f32, f32),
85}
86
87impl BehavioralContract {
88    /// Create a new contract with default weights
89    pub fn new() -> Self {
90        Self {
91            interface_signature: String::new(),
92            invariants: Vec::new(),
93            forbidden_patterns: Vec::new(),
94            weighted_tests: Vec::new(),
95            energy_weights: (1.0, 0.5, 2.0), // alpha, beta, gamma from PSP
96        }
97    }
98
99    /// Get the alpha weight (syntactic energy)
100    pub fn alpha(&self) -> f32 {
101        self.energy_weights.0
102    }
103
104    /// Get the beta weight (structural energy)
105    pub fn beta(&self) -> f32 {
106        self.energy_weights.1
107    }
108
109    /// Get the gamma weight (logic energy)
110    pub fn gamma(&self) -> f32 {
111        self.energy_weights.2
112    }
113}
114
115/// Error type for determining retry limits per PSP-4
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
117pub enum ErrorType {
118    /// Compilation/syntax/type errors (3 attempts)
119    #[default]
120    Compilation,
121    /// Tool execution failures (5 attempts)
122    ToolFailure,
123    /// User/reviewer rejection (3 rejections)
124    ReviewRejection,
125    /// Unknown/other errors (3 attempts default)
126    Other,
127}
128
129/// Retry policy configuration per PSP-4 specification
130#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct RetryPolicy {
132    /// Max retries for compilation errors (default: 3)
133    pub max_compilation_retries: usize,
134    /// Max retries for tool failures (default: 5)
135    pub max_tool_retries: usize,
136    /// Max reviewer rejections before escalation (default: 3)
137    pub max_review_rejections: usize,
138    /// Current consecutive failures by type
139    pub compilation_failures: usize,
140    pub tool_failures: usize,
141    pub review_rejections: usize,
142    /// Last error type encountered
143    pub last_error_type: Option<ErrorType>,
144}
145
146impl Default for RetryPolicy {
147    fn default() -> Self {
148        Self {
149            // PSP-4 specified limits
150            max_compilation_retries: 3,
151            max_tool_retries: 5,
152            max_review_rejections: 3,
153            compilation_failures: 0,
154            tool_failures: 0,
155            review_rejections: 0,
156            last_error_type: None,
157        }
158    }
159}
160
161impl RetryPolicy {
162    /// Record a failure of a specific type
163    pub fn record_failure(&mut self, error_type: ErrorType) {
164        self.last_error_type = Some(error_type);
165        match error_type {
166            ErrorType::Compilation => self.compilation_failures += 1,
167            ErrorType::ToolFailure => self.tool_failures += 1,
168            ErrorType::ReviewRejection => self.review_rejections += 1,
169            ErrorType::Other => self.compilation_failures += 1, // Treat as compilation
170        }
171    }
172
173    /// Reset failures of a specific type (on success)
174    pub fn reset_failures(&mut self, error_type: ErrorType) {
175        match error_type {
176            ErrorType::Compilation => self.compilation_failures = 0,
177            ErrorType::ToolFailure => self.tool_failures = 0,
178            ErrorType::ReviewRejection => self.review_rejections = 0,
179            ErrorType::Other => self.compilation_failures = 0,
180        }
181    }
182
183    /// Reset all failure counters
184    pub fn reset_all(&mut self) {
185        self.compilation_failures = 0;
186        self.tool_failures = 0;
187        self.review_rejections = 0;
188        self.last_error_type = None;
189    }
190
191    /// Check if we should escalate for a specific error type
192    pub fn should_escalate(&self, error_type: ErrorType) -> bool {
193        match error_type {
194            ErrorType::Compilation | ErrorType::Other => {
195                self.compilation_failures >= self.max_compilation_retries
196            }
197            ErrorType::ToolFailure => self.tool_failures >= self.max_tool_retries,
198            ErrorType::ReviewRejection => self.review_rejections >= self.max_review_rejections,
199        }
200    }
201
202    /// Check if any error type has exceeded its limit
203    pub fn any_exceeded(&self) -> bool {
204        self.compilation_failures >= self.max_compilation_retries
205            || self.tool_failures >= self.max_tool_retries
206            || self.review_rejections >= self.max_review_rejections
207    }
208
209    /// Get the current failure count for an error type
210    pub fn failure_count(&self, error_type: ErrorType) -> usize {
211        match error_type {
212            ErrorType::Compilation | ErrorType::Other => self.compilation_failures,
213            ErrorType::ToolFailure => self.tool_failures,
214            ErrorType::ReviewRejection => self.review_rejections,
215        }
216    }
217
218    /// Get remaining attempts for an error type
219    pub fn remaining_attempts(&self, error_type: ErrorType) -> usize {
220        match error_type {
221            ErrorType::Compilation | ErrorType::Other => self
222                .max_compilation_retries
223                .saturating_sub(self.compilation_failures),
224            ErrorType::ToolFailure => self.max_tool_retries.saturating_sub(self.tool_failures),
225            ErrorType::ReviewRejection => self
226                .max_review_rejections
227                .saturating_sub(self.review_rejections),
228        }
229    }
230
231    /// Get a formatted summary
232    pub fn summary(&self) -> String {
233        format!(
234            "Retries: comp {}/{}, tool {}/{}, review {}/{}",
235            self.compilation_failures,
236            self.max_compilation_retries,
237            self.tool_failures,
238            self.max_tool_retries,
239            self.review_rejections,
240            self.max_review_rejections
241        )
242    }
243}
244
245/// Stability monitor for tracking Lyapunov Energy
246#[derive(Debug, Clone, Default, Serialize, Deserialize)]
247pub struct StabilityMonitor {
248    /// History of V(x) values
249    pub energy_history: Vec<f32>,
250    /// Number of convergence attempts
251    pub attempt_count: usize,
252    /// Whether the node has converged to stability
253    pub stable: bool,
254    /// Stability threshold (epsilon)
255    pub stability_epsilon: f32,
256    /// Maximum retry attempts before escalation (legacy, use retry_policy)
257    pub max_retries: usize,
258    /// Retry policy with PSP-4 compliant limits
259    pub retry_policy: RetryPolicy,
260}
261
262impl StabilityMonitor {
263    /// Create with default epsilon = 0.1
264    pub fn new() -> Self {
265        Self {
266            energy_history: Vec::new(),
267            attempt_count: 0,
268            stable: false,
269            stability_epsilon: 0.1,
270            max_retries: 3,
271            retry_policy: RetryPolicy::default(),
272        }
273    }
274
275    /// Record a new energy value
276    pub fn record_energy(&mut self, energy: f32) {
277        self.energy_history.push(energy);
278        self.attempt_count += 1;
279        self.stable = energy < self.stability_epsilon;
280    }
281
282    /// Record a failure with error type
283    pub fn record_failure(&mut self, error_type: ErrorType) {
284        self.retry_policy.record_failure(error_type);
285    }
286
287    /// Check if we should escalate (exceeded retries without stability)
288    pub fn should_escalate(&self) -> bool {
289        // Legacy check or new policy check
290        (self.attempt_count >= self.max_retries && !self.stable) || self.retry_policy.any_exceeded()
291    }
292
293    /// Check if we should escalate for a specific error type
294    pub fn should_escalate_for(&self, error_type: ErrorType) -> bool {
295        self.retry_policy.should_escalate(error_type)
296    }
297
298    /// Get remaining attempts for current error type
299    pub fn remaining_attempts(&self) -> usize {
300        match self.retry_policy.last_error_type {
301            Some(et) => self.retry_policy.remaining_attempts(et),
302            None => self.max_retries.saturating_sub(self.attempt_count),
303        }
304    }
305
306    /// Get the current energy level (last recorded)
307    pub fn current_energy(&self) -> f32 {
308        self.energy_history.last().copied().unwrap_or(f32::INFINITY)
309    }
310
311    /// Check if energy is decreasing (converging)
312    pub fn is_converging(&self) -> bool {
313        if self.energy_history.len() < 2 {
314            return true; // Not enough data
315        }
316        let last = self.energy_history.last().unwrap();
317        let prev = &self.energy_history[self.energy_history.len() - 2];
318        last < prev
319    }
320}
321
322/// SRBN Node - the fundamental unit of control
323#[derive(Debug, Clone, Serialize, Deserialize)]
324pub struct SRBNNode {
325    /// Unique node identifier
326    pub node_id: String,
327    /// High-level goal description for LLM reasoning
328    pub goal: String,
329    /// Files the LLM MUST read for context
330    pub context_files: Vec<PathBuf>,
331    /// Files the LLM MUST modify
332    pub output_targets: Vec<PathBuf>,
333    /// Behavioral contract defining constraints
334    pub contract: BehavioralContract,
335    /// Model tier for this node
336    pub tier: ModelTier,
337    /// Stability monitor
338    pub monitor: StabilityMonitor,
339    /// Current state
340    pub state: NodeState,
341    /// Parent node ID (for DAG structure)
342    pub parent_id: Option<String>,
343    /// Child node IDs
344    pub children: Vec<String>,
345}
346
347impl SRBNNode {
348    /// Create a new node with the given goal
349    pub fn new(node_id: String, goal: String, tier: ModelTier) -> Self {
350        Self {
351            node_id,
352            goal,
353            context_files: Vec::new(),
354            output_targets: Vec::new(),
355            contract: BehavioralContract::new(),
356            tier,
357            monitor: StabilityMonitor::new(),
358            state: NodeState::TaskQueued,
359            parent_id: None,
360            children: Vec::new(),
361        }
362    }
363}
364
365/// Node execution state (from PSP state machine)
366#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
367pub enum NodeState {
368    /// Task is queued for execution
369    TaskQueued,
370    /// Planning phase
371    Planning,
372    /// Coding/implementation phase
373    Coding,
374    /// Verification phase (LSP + Tests)
375    Verifying,
376    /// Retry loop (convergence)
377    Retry,
378    /// Sheaf consistency check
379    SheafCheck,
380    /// Committing stable state
381    Committing,
382    /// Escalated to user
383    Escalated,
384    /// Successfully completed
385    Completed,
386    /// Failed after max retries
387    Failed,
388    /// Aborted by user
389    Aborted,
390}
391
392impl NodeState {
393    /// Check if this is a terminal state
394    pub fn is_terminal(&self) -> bool {
395        matches!(
396            self,
397            NodeState::Completed | NodeState::Failed | NodeState::Aborted
398        )
399    }
400}
401
402/// Token budget tracking for cost control
403///
404/// Tracks input/output token usage and enforces limits per PSP-4 --max-cost.
405#[derive(Debug, Clone, Serialize, Deserialize)]
406pub struct TokenBudget {
407    /// Maximum total tokens allowed (input + output)
408    pub max_tokens: usize,
409    /// Maximum cost in dollars (optional)
410    pub max_cost_usd: Option<f64>,
411    /// Input tokens used
412    pub input_tokens_used: usize,
413    /// Output tokens used
414    pub output_tokens_used: usize,
415    /// Estimated cost so far (in USD)
416    pub cost_usd: f64,
417    /// Cost per 1K input tokens (varies by model)
418    pub input_cost_per_1k: f64,
419    /// Cost per 1K output tokens (varies by model)
420    pub output_cost_per_1k: f64,
421}
422
423impl Default for TokenBudget {
424    fn default() -> Self {
425        Self {
426            max_tokens: 100_000, // 100K default (PSP-4 mentions 100k+ context)
427            max_cost_usd: None,  // No cost limit by default
428            input_tokens_used: 0,
429            output_tokens_used: 0,
430            cost_usd: 0.0,
431            // Default to Gemini Flash pricing (roughly)
432            input_cost_per_1k: 0.075 / 1000.0, // $0.075 per 1M = $0.000075 per 1K
433            output_cost_per_1k: 0.30 / 1000.0, // $0.30 per 1M = $0.0003 per 1K
434        }
435    }
436}
437
438impl TokenBudget {
439    /// Create a new token budget with limits
440    pub fn new(max_tokens: usize, max_cost_usd: Option<f64>) -> Self {
441        Self {
442            max_tokens,
443            max_cost_usd,
444            ..Default::default()
445        }
446    }
447
448    /// Record token usage from an LLM call
449    pub fn record_usage(&mut self, input_tokens: usize, output_tokens: usize) {
450        self.input_tokens_used += input_tokens;
451        self.output_tokens_used += output_tokens;
452
453        // Update cost estimate
454        let input_cost = (input_tokens as f64 / 1000.0) * self.input_cost_per_1k;
455        let output_cost = (output_tokens as f64 / 1000.0) * self.output_cost_per_1k;
456        self.cost_usd += input_cost + output_cost;
457    }
458
459    /// Get total tokens used
460    pub fn total_tokens_used(&self) -> usize {
461        self.input_tokens_used + self.output_tokens_used
462    }
463
464    /// Get remaining token budget
465    pub fn remaining_tokens(&self) -> usize {
466        self.max_tokens.saturating_sub(self.total_tokens_used())
467    }
468
469    /// Check if budget is exhausted
470    pub fn is_exhausted(&self) -> bool {
471        self.total_tokens_used() >= self.max_tokens
472    }
473
474    /// Check if cost limit exceeded
475    pub fn cost_exceeded(&self) -> bool {
476        if let Some(max_cost) = self.max_cost_usd {
477            self.cost_usd >= max_cost
478        } else {
479            false
480        }
481    }
482
483    /// Check if we should stop due to budget
484    pub fn should_stop(&self) -> bool {
485        self.is_exhausted() || self.cost_exceeded()
486    }
487
488    /// Get budget usage percentage
489    pub fn usage_percent(&self) -> f32 {
490        if self.max_tokens == 0 {
491            0.0
492        } else {
493            (self.total_tokens_used() as f32 / self.max_tokens as f32) * 100.0
494        }
495    }
496
497    /// Set model-specific pricing
498    pub fn set_pricing(&mut self, input_per_1k: f64, output_per_1k: f64) {
499        self.input_cost_per_1k = input_per_1k;
500        self.output_cost_per_1k = output_per_1k;
501    }
502
503    /// Get formatted summary
504    pub fn summary(&self) -> String {
505        format!(
506            "Tokens: {}/{} ({:.1}%), Cost: ${:.4}{}",
507            self.total_tokens_used(),
508            self.max_tokens,
509            self.usage_percent(),
510            self.cost_usd,
511            self.max_cost_usd
512                .map(|m| format!(" / ${:.2}", m))
513                .unwrap_or_default()
514        )
515    }
516}
517
518/// Agent context containing workspace state
519#[derive(Debug, Clone, Serialize, Deserialize)]
520pub struct AgentContext {
521    /// Working directory for the agent
522    pub working_dir: PathBuf,
523    /// Conversation history
524    pub history: Vec<AgentMessage>,
525    /// Merkle root hash of current state
526    pub merkle_root: [u8; 32],
527    /// Complexity threshold K for sub-graph approval
528    pub complexity_k: usize,
529    /// Session ID
530    pub session_id: String,
531    /// Auto-approve mode
532    pub auto_approve: bool,
533    /// Last diagnostics from LSP (for correction prompts)
534    #[serde(skip)]
535    pub last_diagnostics: Vec<lsp_types::Diagnostic>,
536    /// Token budget for cost control
537    pub token_budget: TokenBudget,
538}
539
540impl Default for AgentContext {
541    fn default() -> Self {
542        Self {
543            working_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
544            history: Vec::new(),
545            merkle_root: [0u8; 32],
546            complexity_k: 5, // Default from PSP
547            session_id: uuid::Uuid::new_v4().to_string(),
548            auto_approve: false,
549            last_diagnostics: Vec::new(),
550            token_budget: TokenBudget::default(),
551        }
552    }
553}
554
555/// Agent message in conversation history
556#[derive(Debug, Clone, Serialize, Deserialize)]
557pub struct AgentMessage {
558    /// Role/tier of the sender
559    pub role: ModelTier,
560    /// Message content
561    pub content: String,
562    /// Timestamp
563    pub timestamp: SystemTime,
564    /// Associated node ID
565    pub node_id: Option<String>,
566}
567
568impl AgentMessage {
569    /// Create a new message
570    pub fn new(role: ModelTier, content: String) -> Self {
571        Self {
572            role,
573            content,
574            timestamp: SystemTime::now(),
575            node_id: None,
576        }
577    }
578}
579
580/// Energy components for Lyapunov calculation
581#[derive(Debug, Clone, Default, Serialize, Deserialize)]
582pub struct EnergyComponents {
583    /// Syntactic energy (from LSP diagnostics)
584    pub v_syn: f32,
585    /// Structural energy (from contract verification)
586    pub v_str: f32,
587    /// Logic energy (from test results)
588    pub v_log: f32,
589}
590
591impl EnergyComponents {
592    /// Calculate total energy: V(x) = α*V_syn + β*V_str + γ*V_log
593    pub fn total(&self, contract: &BehavioralContract) -> f32 {
594        contract.alpha() * self.v_syn + contract.beta() * self.v_str + contract.gamma() * self.v_log
595    }
596}
597
598// =============================================================================
599// Task Plan Types - Structured output from Architect
600// =============================================================================
601
602/// Task type classification for planning
603#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
604#[serde(rename_all = "snake_case")]
605pub enum TaskType {
606    /// Implementation code
607    #[default]
608    Code,
609    /// Unit tests
610    UnitTest,
611    /// Integration/E2E tests
612    IntegrationTest,
613    /// Refactoring existing code
614    Refactor,
615    /// Documentation
616    Documentation,
617}
618
619/// Structured task plan from Architect
620/// Output as JSON for reliable parsing
621#[derive(Debug, Clone, Serialize, Deserialize)]
622pub struct TaskPlan {
623    /// List of tasks to execute
624    pub tasks: Vec<PlannedTask>,
625}
626
627impl TaskPlan {
628    /// Create an empty plan
629    pub fn new() -> Self {
630        Self { tasks: Vec::new() }
631    }
632
633    /// Get the total number of tasks
634    pub fn len(&self) -> usize {
635        self.tasks.len()
636    }
637
638    /// Check if plan is empty
639    pub fn is_empty(&self) -> bool {
640        self.tasks.is_empty()
641    }
642
643    /// Get task by ID
644    pub fn get_task(&self, id: &str) -> Option<&PlannedTask> {
645        self.tasks.iter().find(|t| t.id == id)
646    }
647
648    /// Validate the plan structure
649    pub fn validate(&self) -> Result<(), String> {
650        if self.tasks.is_empty() {
651            return Err("Plan has no tasks".to_string());
652        }
653
654        // Check for duplicate IDs
655        let mut seen_ids = std::collections::HashSet::new();
656        for task in &self.tasks {
657            if !seen_ids.insert(&task.id) {
658                return Err(format!("Duplicate task ID: {}", task.id));
659            }
660            if task.goal.is_empty() {
661                return Err(format!("Task {} has empty goal", task.id));
662            }
663        }
664
665        // Check for invalid dependencies
666        for task in &self.tasks {
667            for dep in &task.dependencies {
668                if !seen_ids.contains(dep) {
669                    return Err(format!("Task {} has unknown dependency: {}", task.id, dep));
670                }
671            }
672        }
673
674        Ok(())
675    }
676}
677
678impl Default for TaskPlan {
679    fn default() -> Self {
680        Self::new()
681    }
682}
683
684/// A planned task from the Architect
685#[derive(Debug, Clone, Serialize, Deserialize)]
686pub struct PlannedTask {
687    /// Unique task identifier (e.g., "task_1", "test_auth")
688    pub id: String,
689    /// Human-readable goal description
690    pub goal: String,
691    /// Files to read for context
692    #[serde(default)]
693    pub context_files: Vec<String>,
694    /// Files to create or modify
695    #[serde(default)]
696    pub output_files: Vec<String>,
697    /// Task IDs this depends on (must complete first)
698    #[serde(default)]
699    pub dependencies: Vec<String>,
700    /// Type of task
701    #[serde(default)]
702    pub task_type: TaskType,
703    /// Behavioral contract for this task
704    #[serde(default)]
705    pub contract: PlannedContract,
706}
707
708impl PlannedTask {
709    /// Create a simple task
710    pub fn new(id: impl Into<String>, goal: impl Into<String>) -> Self {
711        Self {
712            id: id.into(),
713            goal: goal.into(),
714            context_files: Vec::new(),
715            output_files: Vec::new(),
716            dependencies: Vec::new(),
717            task_type: TaskType::Code,
718            contract: PlannedContract::default(),
719        }
720    }
721
722    /// Convert to SRBNNode
723    pub fn to_srbn_node(&self, tier: ModelTier) -> SRBNNode {
724        let mut node = SRBNNode::new(self.id.clone(), self.goal.clone(), tier);
725        node.context_files = self.context_files.iter().map(PathBuf::from).collect();
726        node.output_targets = self.output_files.iter().map(PathBuf::from).collect();
727        node.contract = self.contract.to_behavioral_contract();
728        node
729    }
730}
731
732/// Contract specified in the plan
733#[derive(Debug, Clone, Default, Serialize, Deserialize)]
734pub struct PlannedContract {
735    /// Required public API signature
736    #[serde(default)]
737    pub interface_signature: Option<String>,
738    /// Semantic constraints
739    #[serde(default)]
740    pub invariants: Vec<String>,
741    /// Patterns to avoid
742    #[serde(default)]
743    pub forbidden_patterns: Vec<String>,
744    /// Test cases with criticality
745    #[serde(default)]
746    pub tests: Vec<PlannedTest>,
747}
748
749impl PlannedContract {
750    /// Convert to BehavioralContract
751    pub fn to_behavioral_contract(&self) -> BehavioralContract {
752        BehavioralContract {
753            interface_signature: self.interface_signature.clone().unwrap_or_default(),
754            invariants: self.invariants.clone(),
755            forbidden_patterns: self.forbidden_patterns.clone(),
756            weighted_tests: self
757                .tests
758                .iter()
759                .map(|t| WeightedTest {
760                    test_name: t.name.clone(),
761                    criticality: t.criticality,
762                })
763                .collect(),
764            energy_weights: (1.0, 0.5, 2.0),
765        }
766    }
767}
768
769/// A test case in the plan
770#[derive(Debug, Clone, Serialize, Deserialize)]
771pub struct PlannedTest {
772    /// Test name or pattern
773    pub name: String,
774    /// Criticality level
775    #[serde(default = "default_criticality")]
776    pub criticality: Criticality,
777}
778
779fn default_criticality() -> Criticality {
780    Criticality::High
781}