Skip to main content

haima_core/
outcome.rs

1//! Outcome-based pricing engine — task contracts, success verification, and pricing tiers.
2//!
3//! Implements the "charge per outcome, not per seat or per token" model:
4//! - **Task contracts** define what "done" means for each task type
5//! - **Success verification** provides automated checks (tests pass, data validated, etc.)
6//! - **Pricing tiers** adjust price by task complexity and agent trust score
7//! - **Refund policy** handles automatic refunds when SLA is not met
8//!
9//! # Pricing Examples
10//!
11//! | Task Type           | Price Range (USDC)  |
12//! |---------------------|---------------------|
13//! | Code review         | $2 - $5 per PR      |
14//! | Data pipeline       | $5 - $20 per run    |
15//! | Support ticket      | $0.50 - $2.00       |
16//! | Document generation | $1 - $10            |
17
18use chrono::{DateTime, Utc};
19use serde::{Deserialize, Serialize};
20
21// ---------------------------------------------------------------------------
22// Task types
23// ---------------------------------------------------------------------------
24
25/// The category of task being priced.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum TaskType {
29    /// Code review of a pull request.
30    CodeReview,
31    /// Data pipeline execution (ETL, transformations).
32    DataPipeline,
33    /// Customer support ticket resolution.
34    SupportTicket,
35    /// Document generation (reports, specs, etc.).
36    DocumentGeneration,
37    /// User-defined task type.
38    Custom,
39}
40
41impl std::fmt::Display for TaskType {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        match self {
44            Self::CodeReview => write!(f, "code_review"),
45            Self::DataPipeline => write!(f, "data_pipeline"),
46            Self::SupportTicket => write!(f, "support_ticket"),
47            Self::DocumentGeneration => write!(f, "document_generation"),
48            Self::Custom => write!(f, "custom"),
49        }
50    }
51}
52
53// ---------------------------------------------------------------------------
54// Task complexity
55// ---------------------------------------------------------------------------
56
57/// Complexity level of a task — drives pricing within a contract's range.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(rename_all = "snake_case")]
60pub enum TaskComplexity {
61    /// Trivial task — minimum price.
62    Simple,
63    /// Normal task — midpoint price.
64    Standard,
65    /// Involved task — upper-range price.
66    Complex,
67    /// Mission-critical or urgent — maximum price.
68    Critical,
69}
70
71impl TaskComplexity {
72    /// Multiplier applied to the base price (0.0 - 1.0 within the range).
73    fn range_position(self) -> f64 {
74        match self {
75            Self::Simple => 0.0,
76            Self::Standard => 0.33,
77            Self::Complex => 0.66,
78            Self::Critical => 1.0,
79        }
80    }
81}
82
83// ---------------------------------------------------------------------------
84// Success criteria
85// ---------------------------------------------------------------------------
86
87/// A criterion that must be satisfied for a task to be considered successful.
88#[derive(Debug, Clone, Serialize, Deserialize)]
89#[serde(tag = "type", rename_all = "snake_case")]
90pub enum SuccessCriterion {
91    /// All tests in the specified scope must pass.
92    TestsPassed {
93        /// Scope of tests (e.g., "unit", "integration", "e2e").
94        scope: String,
95    },
96    /// Output data must pass validation against a schema or set of rules.
97    DataValidated {
98        /// Validation rule identifier.
99        rule_id: String,
100    },
101    /// Explicit approval from the customer or reviewer.
102    ManualApproval {
103        /// Who must approve (role or agent ID).
104        approver: String,
105    },
106    /// A webhook returned a success status code.
107    WebhookConfirmed {
108        /// The webhook URL that was called.
109        url: String,
110    },
111    /// Custom criterion with a freeform description.
112    Custom {
113        /// Human-readable description of what must be true.
114        description: String,
115    },
116}
117
118// ---------------------------------------------------------------------------
119// Refund policy
120// ---------------------------------------------------------------------------
121
122/// Policy governing when refunds are issued for failed tasks.
123#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct RefundPolicy {
125    /// Whether automatic refunds are enabled.
126    pub auto_refund: bool,
127    /// SLA deadline in seconds from task acceptance. If the task is not
128    /// verified as successful within this window, a refund is triggered.
129    pub sla_seconds: u64,
130    /// Percentage of the billed amount to refund (0 - 100).
131    /// 100 = full refund, 50 = half refund, etc.
132    pub refund_percentage: u8,
133    /// Grace period in seconds after SLA expiry before auto-refund fires.
134    pub grace_period_seconds: u64,
135}
136
137impl Default for RefundPolicy {
138    fn default() -> Self {
139        Self {
140            auto_refund: true,
141            sla_seconds: 3600,         // 1 hour
142            refund_percentage: 100,    // full refund
143            grace_period_seconds: 300, // 5 minute grace
144        }
145    }
146}
147
148// ---------------------------------------------------------------------------
149// Task contract
150// ---------------------------------------------------------------------------
151
152/// A task contract defines the pricing, success criteria, and SLA for a task type.
153///
154/// Contracts are registered once and applied to every task of that type.
155/// The actual price is resolved at billing time based on complexity and trust score.
156#[derive(Debug, Clone, Serialize, Deserialize)]
157pub struct TaskContract {
158    /// Unique contract identifier.
159    pub contract_id: String,
160    /// The task type this contract covers.
161    pub task_type: TaskType,
162    /// Human-readable name for this contract.
163    pub name: String,
164    /// Minimum price in micro-credits (e.g., `500_000` = $0.50).
165    pub price_floor_micro_credits: i64,
166    /// Maximum price in micro-credits (e.g., `5_000_000` = $5.00).
167    pub price_ceiling_micro_credits: i64,
168    /// Success criteria that must all be satisfied.
169    pub success_criteria: Vec<SuccessCriterion>,
170    /// Refund policy for failed tasks.
171    pub refund_policy: RefundPolicy,
172    /// Minimum trust score (0.0 - 1.0) required for an agent to accept this task.
173    /// Higher trust score → lower price within the range.
174    pub min_trust_score: f64,
175    /// Optional custom label for the task type (used when `task_type` is `Custom`).
176    #[serde(skip_serializing_if = "Option::is_none")]
177    pub custom_label: Option<String>,
178    /// When this contract was created.
179    pub created_at: DateTime<Utc>,
180}
181
182impl TaskContract {
183    /// Resolve the price in micro-credits for a given complexity and trust score.
184    ///
185    /// Higher trust score → discount (lower end of range).
186    /// Higher complexity → premium (upper end of range).
187    ///
188    /// Formula:
189    /// ```text
190    /// range = ceiling - floor
191    /// complexity_offset = range * complexity_position
192    /// trust_discount = range * trust_score * 0.2  (up to 20% discount)
193    /// price = floor + complexity_offset - trust_discount
194    /// ```
195    pub fn resolve_price(&self, complexity: TaskComplexity, trust_score: f64) -> i64 {
196        let range = self.price_ceiling_micro_credits - self.price_floor_micro_credits;
197        if range <= 0 {
198            return self.price_floor_micro_credits;
199        }
200
201        let complexity_offset = (range as f64 * complexity.range_position()) as i64;
202        // Trust discount: up to 20% of the range for a perfect trust score.
203        let trust_discount = (range as f64 * trust_score.clamp(0.0, 1.0) * 0.2) as i64;
204
205        let price = self.price_floor_micro_credits + complexity_offset - trust_discount;
206        // Clamp to [floor, ceiling].
207        price.clamp(
208            self.price_floor_micro_credits,
209            self.price_ceiling_micro_credits,
210        )
211    }
212}
213
214// ---------------------------------------------------------------------------
215// Task outcome
216// ---------------------------------------------------------------------------
217
218/// The result of executing and verifying a task.
219#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
220#[serde(rename_all = "snake_case")]
221pub enum TaskOutcome {
222    /// All success criteria were met — bill the customer.
223    Success,
224    /// One or more success criteria failed — trigger refund policy.
225    Failure,
226    /// Some criteria met — partial billing may apply.
227    PartialSuccess,
228    /// Task exceeded the SLA deadline — auto-refund triggered.
229    Timeout,
230    /// Refund was already processed for this task.
231    Refunded,
232}
233
234/// A verification result for a single success criterion.
235#[derive(Debug, Clone, Serialize, Deserialize)]
236pub struct CriterionResult {
237    /// Which criterion was checked.
238    pub criterion: SuccessCriterion,
239    /// Whether this criterion passed.
240    pub passed: bool,
241    /// Optional details about the check.
242    #[serde(skip_serializing_if = "Option::is_none")]
243    pub details: Option<String>,
244    /// When the check was performed.
245    pub checked_at: DateTime<Utc>,
246}
247
248/// Complete verification record for a task.
249#[derive(Debug, Clone, Serialize, Deserialize)]
250pub struct OutcomeVerification {
251    /// The task being verified.
252    pub task_id: String,
253    /// The contract that governs this task.
254    pub contract_id: String,
255    /// Individual criterion results.
256    pub results: Vec<CriterionResult>,
257    /// Overall outcome.
258    pub outcome: TaskOutcome,
259    /// The price that was (or would be) charged.
260    pub price_micro_credits: i64,
261    /// When verification was completed.
262    pub verified_at: DateTime<Utc>,
263}
264
265impl OutcomeVerification {
266    /// Derive the overall outcome from individual criterion results.
267    pub fn derive_outcome(results: &[CriterionResult]) -> TaskOutcome {
268        if results.is_empty() {
269            return TaskOutcome::Failure;
270        }
271        let passed = results.iter().filter(|r| r.passed).count();
272        let total = results.len();
273        if passed == total {
274            TaskOutcome::Success
275        } else if passed > 0 {
276            TaskOutcome::PartialSuccess
277        } else {
278            TaskOutcome::Failure
279        }
280    }
281}
282
283/// A record of a completed task with its outcome and billing.
284#[derive(Debug, Clone, Serialize, Deserialize)]
285pub struct OutcomeRecord {
286    /// Unique task identifier.
287    pub task_id: String,
288    /// Contract that governed this task.
289    pub contract_id: String,
290    /// Task type.
291    pub task_type: TaskType,
292    /// Complexity level assigned to this task.
293    pub complexity: TaskComplexity,
294    /// Agent that executed the task.
295    pub agent_id: String,
296    /// Trust score of the agent at the time of billing.
297    pub agent_trust_score: f64,
298    /// Price charged in micro-credits.
299    pub price_micro_credits: i64,
300    /// Task outcome.
301    pub outcome: TaskOutcome,
302    /// When the task was accepted.
303    pub accepted_at: DateTime<Utc>,
304    /// When the task was completed (or timed out).
305    pub completed_at: DateTime<Utc>,
306    /// Whether a refund was issued.
307    pub refunded: bool,
308    /// Refund amount in micro-credits (0 if no refund).
309    pub refund_amount_micro_credits: i64,
310}
311
312// ---------------------------------------------------------------------------
313// Default contracts
314// ---------------------------------------------------------------------------
315
316/// Create the default contract for code review tasks.
317///
318/// Price range: $2 - $5 per PR (2,000,000 - 5,000,000 micro-credits).
319pub fn default_code_review_contract() -> TaskContract {
320    TaskContract {
321        contract_id: "contract-code-review-v1".into(),
322        task_type: TaskType::CodeReview,
323        name: "Code Review".into(),
324        price_floor_micro_credits: 2_000_000,
325        price_ceiling_micro_credits: 5_000_000,
326        success_criteria: vec![
327            SuccessCriterion::TestsPassed {
328                scope: "unit".into(),
329            },
330            SuccessCriterion::ManualApproval {
331                approver: "reviewer".into(),
332            },
333        ],
334        refund_policy: RefundPolicy {
335            sla_seconds: 7200, // 2 hours
336            ..Default::default()
337        },
338        min_trust_score: 0.3,
339        custom_label: None,
340        created_at: Utc::now(),
341    }
342}
343
344/// Create the default contract for data pipeline tasks.
345///
346/// Price range: $5 - $20 per pipeline run.
347pub fn default_data_pipeline_contract() -> TaskContract {
348    TaskContract {
349        contract_id: "contract-data-pipeline-v1".into(),
350        task_type: TaskType::DataPipeline,
351        name: "Data Pipeline Run".into(),
352        price_floor_micro_credits: 5_000_000,
353        price_ceiling_micro_credits: 20_000_000,
354        success_criteria: vec![SuccessCriterion::DataValidated {
355            rule_id: "pipeline-output-schema".into(),
356        }],
357        refund_policy: RefundPolicy {
358            sla_seconds: 3600, // 1 hour
359            ..Default::default()
360        },
361        min_trust_score: 0.5,
362        custom_label: None,
363        created_at: Utc::now(),
364    }
365}
366
367/// Create the default contract for support ticket resolution.
368///
369/// Price range: $0.50 - $2.00 per ticket.
370pub fn default_support_ticket_contract() -> TaskContract {
371    TaskContract {
372        contract_id: "contract-support-ticket-v1".into(),
373        task_type: TaskType::SupportTicket,
374        name: "Support Ticket Resolution".into(),
375        price_floor_micro_credits: 500_000,
376        price_ceiling_micro_credits: 2_000_000,
377        success_criteria: vec![SuccessCriterion::Custom {
378            description: "Customer marked ticket as resolved".into(),
379        }],
380        refund_policy: RefundPolicy {
381            sla_seconds: 1800, // 30 minutes
382            ..Default::default()
383        },
384        min_trust_score: 0.3,
385        custom_label: None,
386        created_at: Utc::now(),
387    }
388}
389
390/// Create the default contract for document generation.
391///
392/// Price range: $1 - $10 per document.
393pub fn default_document_generation_contract() -> TaskContract {
394    TaskContract {
395        contract_id: "contract-doc-gen-v1".into(),
396        task_type: TaskType::DocumentGeneration,
397        name: "Document Generation".into(),
398        price_floor_micro_credits: 1_000_000,
399        price_ceiling_micro_credits: 10_000_000,
400        success_criteria: vec![SuccessCriterion::DataValidated {
401            rule_id: "document-schema".into(),
402        }],
403        refund_policy: RefundPolicy {
404            sla_seconds: 3600,
405            ..Default::default()
406        },
407        min_trust_score: 0.3,
408        custom_label: None,
409        created_at: Utc::now(),
410    }
411}
412
413// ---------------------------------------------------------------------------
414// Tests
415// ---------------------------------------------------------------------------
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420
421    #[test]
422    fn task_type_display() {
423        assert_eq!(TaskType::CodeReview.to_string(), "code_review");
424        assert_eq!(TaskType::DataPipeline.to_string(), "data_pipeline");
425        assert_eq!(TaskType::SupportTicket.to_string(), "support_ticket");
426        assert_eq!(
427            TaskType::DocumentGeneration.to_string(),
428            "document_generation"
429        );
430        assert_eq!(TaskType::Custom.to_string(), "custom");
431    }
432
433    #[test]
434    fn task_type_serde_roundtrip() {
435        let tt = TaskType::CodeReview;
436        let json = serde_json::to_string(&tt).unwrap();
437        assert_eq!(json, "\"code_review\"");
438        let back: TaskType = serde_json::from_str(&json).unwrap();
439        assert_eq!(back, tt);
440    }
441
442    #[test]
443    fn resolve_price_simple_no_trust() {
444        let contract = default_code_review_contract();
445        // Simple + 0.0 trust → floor price
446        let price = contract.resolve_price(TaskComplexity::Simple, 0.0);
447        assert_eq!(price, 2_000_000);
448    }
449
450    #[test]
451    fn resolve_price_critical_no_trust() {
452        let contract = default_code_review_contract();
453        // Critical + 0.0 trust → ceiling price
454        let price = contract.resolve_price(TaskComplexity::Critical, 0.0);
455        assert_eq!(price, 5_000_000);
456    }
457
458    #[test]
459    fn resolve_price_simple_max_trust() {
460        let contract = default_code_review_contract();
461        // Simple + 1.0 trust → floor - 20% discount, clamped to floor
462        let price = contract.resolve_price(TaskComplexity::Simple, 1.0);
463        // floor + 0 - 600_000 → 1_400_000 clamped to 2_000_000
464        assert_eq!(price, 2_000_000);
465    }
466
467    #[test]
468    fn resolve_price_critical_max_trust() {
469        let contract = default_code_review_contract();
470        // Critical + 1.0 trust → ceiling - 20% discount
471        let price = contract.resolve_price(TaskComplexity::Critical, 1.0);
472        // floor(2M) + range(3M) - discount(600k) = 4_400_000
473        assert_eq!(price, 4_400_000);
474    }
475
476    #[test]
477    fn resolve_price_standard_mid_trust() {
478        let contract = default_code_review_contract();
479        // Standard + 0.5 trust
480        let price = contract.resolve_price(TaskComplexity::Standard, 0.5);
481        // range = 3M, complexity_offset = 3M * 0.33 = 990_000
482        // trust_discount = 3M * 0.5 * 0.2 = 300_000
483        // price = 2M + 990k - 300k = 2_690_000
484        assert_eq!(price, 2_690_000);
485    }
486
487    #[test]
488    fn resolve_price_flat_range() {
489        // When floor == ceiling, price is always the floor.
490        let contract = TaskContract {
491            contract_id: "flat".into(),
492            task_type: TaskType::Custom,
493            name: "Flat Price".into(),
494            price_floor_micro_credits: 1_000_000,
495            price_ceiling_micro_credits: 1_000_000,
496            success_criteria: vec![],
497            refund_policy: RefundPolicy::default(),
498            min_trust_score: 0.0,
499            custom_label: Some("flat".into()),
500            created_at: Utc::now(),
501        };
502        assert_eq!(
503            contract.resolve_price(TaskComplexity::Critical, 1.0),
504            1_000_000
505        );
506    }
507
508    #[test]
509    fn derive_outcome_all_pass() {
510        let results = vec![
511            CriterionResult {
512                criterion: SuccessCriterion::TestsPassed {
513                    scope: "unit".into(),
514                },
515                passed: true,
516                details: None,
517                checked_at: Utc::now(),
518            },
519            CriterionResult {
520                criterion: SuccessCriterion::DataValidated {
521                    rule_id: "schema-1".into(),
522                },
523                passed: true,
524                details: None,
525                checked_at: Utc::now(),
526            },
527        ];
528        assert_eq!(
529            OutcomeVerification::derive_outcome(&results),
530            TaskOutcome::Success
531        );
532    }
533
534    #[test]
535    fn derive_outcome_partial() {
536        let results = vec![
537            CriterionResult {
538                criterion: SuccessCriterion::TestsPassed {
539                    scope: "unit".into(),
540                },
541                passed: true,
542                details: None,
543                checked_at: Utc::now(),
544            },
545            CriterionResult {
546                criterion: SuccessCriterion::ManualApproval {
547                    approver: "reviewer".into(),
548                },
549                passed: false,
550                details: Some("reviewer rejected".into()),
551                checked_at: Utc::now(),
552            },
553        ];
554        assert_eq!(
555            OutcomeVerification::derive_outcome(&results),
556            TaskOutcome::PartialSuccess
557        );
558    }
559
560    #[test]
561    fn derive_outcome_all_fail() {
562        let results = vec![CriterionResult {
563            criterion: SuccessCriterion::TestsPassed {
564                scope: "e2e".into(),
565            },
566            passed: false,
567            details: Some("3 tests failed".into()),
568            checked_at: Utc::now(),
569        }];
570        assert_eq!(
571            OutcomeVerification::derive_outcome(&results),
572            TaskOutcome::Failure
573        );
574    }
575
576    #[test]
577    fn derive_outcome_empty() {
578        assert_eq!(
579            OutcomeVerification::derive_outcome(&[]),
580            TaskOutcome::Failure
581        );
582    }
583
584    #[test]
585    fn default_contracts_have_valid_ranges() {
586        let contracts = vec![
587            default_code_review_contract(),
588            default_data_pipeline_contract(),
589            default_support_ticket_contract(),
590            default_document_generation_contract(),
591        ];
592        for c in &contracts {
593            assert!(
594                c.price_floor_micro_credits <= c.price_ceiling_micro_credits,
595                "contract {} has floor > ceiling",
596                c.contract_id
597            );
598            assert!(
599                !c.success_criteria.is_empty(),
600                "contract {} has no success criteria",
601                c.contract_id
602            );
603            assert!(c.min_trust_score >= 0.0 && c.min_trust_score <= 1.0);
604        }
605    }
606
607    #[test]
608    fn refund_policy_default() {
609        let policy = RefundPolicy::default();
610        assert!(policy.auto_refund);
611        assert_eq!(policy.sla_seconds, 3600);
612        assert_eq!(policy.refund_percentage, 100);
613        assert_eq!(policy.grace_period_seconds, 300);
614    }
615
616    #[test]
617    fn task_contract_serde_roundtrip() {
618        let contract = default_code_review_contract();
619        let json = serde_json::to_string(&contract).unwrap();
620        let back: TaskContract = serde_json::from_str(&json).unwrap();
621        assert_eq!(back.contract_id, contract.contract_id);
622        assert_eq!(back.task_type, contract.task_type);
623        assert_eq!(
624            back.price_floor_micro_credits,
625            contract.price_floor_micro_credits
626        );
627    }
628
629    #[test]
630    fn outcome_record_serde_roundtrip() {
631        let record = OutcomeRecord {
632            task_id: "task-1".into(),
633            contract_id: "contract-code-review-v1".into(),
634            task_type: TaskType::CodeReview,
635            complexity: TaskComplexity::Standard,
636            agent_id: "agent-1".into(),
637            agent_trust_score: 0.8,
638            price_micro_credits: 3_000_000,
639            outcome: TaskOutcome::Success,
640            accepted_at: Utc::now(),
641            completed_at: Utc::now(),
642            refunded: false,
643            refund_amount_micro_credits: 0,
644        };
645        let json = serde_json::to_string(&record).unwrap();
646        let back: OutcomeRecord = serde_json::from_str(&json).unwrap();
647        assert_eq!(back.task_id, "task-1");
648        assert_eq!(back.outcome, TaskOutcome::Success);
649    }
650
651    #[test]
652    fn support_ticket_pricing_range() {
653        let contract = default_support_ticket_contract();
654        let min = contract.resolve_price(TaskComplexity::Simple, 1.0);
655        let max = contract.resolve_price(TaskComplexity::Critical, 0.0);
656        // Min should be >= floor
657        assert!(min >= 500_000, "min = {min}");
658        // Max should be <= ceiling
659        assert!(max <= 2_000_000, "max = {max}");
660    }
661
662    #[test]
663    fn data_pipeline_pricing_range() {
664        let contract = default_data_pipeline_contract();
665        let min = contract.resolve_price(TaskComplexity::Simple, 1.0);
666        let max = contract.resolve_price(TaskComplexity::Critical, 0.0);
667        assert!(min >= 5_000_000, "min = {min}");
668        assert!(max <= 20_000_000, "max = {max}");
669    }
670}