1use serde::{Deserialize, Serialize};
12
13use crate::common::ValidationError;
14use crate::context_gateway::{QualityRecoveryV1, RetentionCountsV1};
15
16pub const CONTEXT_POLICY_EVIDENCE_VERSION: u32 = 1;
17pub const MAX_EVIDENCE_RECORDS: usize = 4_096;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum WorkloadTaskClassV1 {
24 BugFix,
25 Refactor,
26 TestAddition,
27 Documentation,
28 Investigation,
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case")]
34pub enum WorkloadLanguageV1 {
35 Rust,
36 Python,
37 TypeScript,
38 JavaScript,
39 Go,
40 Java,
41 C,
42 Cpp,
43 CSharp,
44 Swift,
45 Kotlin,
46 Ruby,
47 Php,
48 Shell,
49 Other,
50 None,
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
56#[serde(rename_all = "snake_case")]
57pub enum WorkloadSizeV1 {
58 Tiny,
60 Small,
62 Medium,
64 Large,
66 VeryLarge,
67}
68
69impl WorkloadSizeV1 {
70 #[must_use]
71 pub fn from_budget_tokens(tokens: u64) -> Self {
72 match tokens {
73 0..=2_048 => Self::Tiny,
74 2_049..=8_192 => Self::Small,
75 8_193..=32_768 => Self::Medium,
76 32_769..=131_072 => Self::Large,
77 _ => Self::VeryLarge,
78 }
79 }
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
83#[serde(deny_unknown_fields)]
84pub struct WorkloadV1 {
85 pub task_class: WorkloadTaskClassV1,
86 pub language: WorkloadLanguageV1,
87 pub size: WorkloadSizeV1,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92#[serde(rename_all = "snake_case", tag = "state", deny_unknown_fields)]
93pub enum QualityEvidenceV1 {
94 Measured {
95 retention: RetentionCountsV1,
97 recovery: QualityRecoveryV1,
98 },
99 Unmeasured,
100}
101
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
106#[serde(rename_all = "snake_case", tag = "state", deny_unknown_fields)]
107pub enum SecurityEvidenceV1 {
108 Measured { regressions: u64 },
109 Unmeasured,
110}
111
112#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
116#[serde(rename_all = "snake_case")]
117pub enum ReadStrategyV1 {
118 Full,
119 Map,
120 Signatures,
121 Aggressive,
122 Entropy,
123 Task,
124 Reference,
125 Diff,
126 Lines,
127 Auto,
128 Other,
129}
130
131impl ReadStrategyV1 {
132 #[must_use]
134 pub fn from_mode(mode: &str) -> Self {
135 match mode {
136 "full" => Self::Full,
137 "map" => Self::Map,
138 "signatures" => Self::Signatures,
139 "aggressive" => Self::Aggressive,
140 "entropy" => Self::Entropy,
141 "task" => Self::Task,
142 "reference" => Self::Reference,
143 "diff" => Self::Diff,
144 "auto" => Self::Auto,
145 _ if mode == "lines" || mode.starts_with("lines:") => Self::Lines,
146 _ => Self::Other,
147 }
148 }
149
150 #[must_use]
151 pub fn as_str(self) -> &'static str {
152 match self {
153 Self::Full => "full",
154 Self::Map => "map",
155 Self::Signatures => "signatures",
156 Self::Aggressive => "aggressive",
157 Self::Entropy => "entropy",
158 Self::Task => "task",
159 Self::Reference => "reference",
160 Self::Diff => "diff",
161 Self::Lines => "lines",
162 Self::Auto => "auto",
163 Self::Other => "other",
164 }
165 }
166}
167
168#[allow(clippy::trivially_copy_pass_by_ref)] fn is_zero(value: &u64) -> bool {
170 *value == 0
171}
172
173#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
175#[serde(deny_unknown_fields)]
176pub struct StrategyOutcomeRecordV1 {
177 pub workload: WorkloadV1,
178 pub strategy: ReadStrategyV1,
179 pub observed_day: u64,
182 pub samples: u64,
184 pub accepted: u64,
185 pub rejected: u64,
186 pub explicit_overrides: u64,
188 pub token_samples: u64,
190 #[serde(default, skip_serializing_if = "is_zero")]
194 pub signal_samples: u64,
195 #[serde(default, skip_serializing_if = "is_zero")]
197 pub bounce_tasks: u64,
198 #[serde(default, skip_serializing_if = "is_zero")]
200 pub expand_tasks: u64,
201 #[serde(default, skip_serializing_if = "is_zero")]
203 pub edit_failure_tasks: u64,
204 pub tokens_original: u64,
205 pub tokens_delivered: u64,
206 pub quality: QualityEvidenceV1,
207 pub security: SecurityEvidenceV1,
208}
209
210impl StrategyOutcomeRecordV1 {
211 pub fn validate(&self) -> Result<(), ValidationError> {
212 if self.samples == 0 {
213 return Err(ValidationError::new("record without samples"));
214 }
215 if self.accepted.checked_add(self.rejected) != Some(self.samples) {
216 return Err(ValidationError::new(
217 "accepted + rejected must equal samples",
218 ));
219 }
220 if self.explicit_overrides > self.samples {
221 return Err(ValidationError::new("more overrides than samples"));
222 }
223 if self.token_samples > self.samples {
224 return Err(ValidationError::new("more token samples than samples"));
225 }
226 if self.signal_samples > self.samples
227 || [
228 self.bounce_tasks,
229 self.expand_tasks,
230 self.edit_failure_tasks,
231 ]
232 .iter()
233 .any(|count| *count > self.signal_samples)
234 {
235 return Err(ValidationError::new(
236 "signal counts exceed their attributed tasks",
237 ));
238 }
239 if self.token_samples == 0 && (self.tokens_original > 0 || self.tokens_delivered > 0) {
240 return Err(ValidationError::new("tokens without token samples"));
241 }
242 if self.tokens_delivered > self.tokens_original {
243 return Err(ValidationError::new("delivered tokens exceed original"));
244 }
245 if let QualityEvidenceV1::Measured { recovery, .. } = &self.quality
246 && (recovery.handles_verified > recovery.handles_emitted
247 || recovery.failures > recovery.handles_emitted
248 || recovery.critical_failures > recovery.failures)
249 {
250 return Err(ValidationError::new("impossible recovery counts"));
251 }
252 Ok(())
253 }
254
255 fn key(&self) -> (&WorkloadV1, ReadStrategyV1, u64) {
256 (&self.workload, self.strategy, self.observed_day)
257 }
258}
259
260#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
262#[serde(rename_all = "snake_case")]
263pub enum EvaluationVerdictV1 {
264 Improved,
265 NonInferior,
266 Regressed,
267 Underpowered,
268}
269
270#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
274#[serde(deny_unknown_fields)]
275pub struct StrategyEvaluationV1 {
276 pub strategy: ReadStrategyV1,
277 pub evidence_tier: crate::context_gateway::QualityEvidenceTierV1,
278 pub verdict: EvaluationVerdictV1,
279 pub pairs: u64,
280 pub powered: bool,
281 pub delta_milli: i64,
282 pub ci_low_milli: i64,
283 pub ci_high_milli: i64,
284 pub margin_milli: i64,
285}
286
287impl StrategyEvaluationV1 {
288 pub fn validate(&self) -> Result<(), ValidationError> {
289 if self.strategy == ReadStrategyV1::Other {
290 return Err(ValidationError::new("an evaluation names a known strategy"));
291 }
292 if self.ci_low_milli > self.ci_high_milli || self.margin_milli < 0 {
293 return Err(ValidationError::new("impossible evaluation interval"));
294 }
295 if self.powered && self.pairs == 0 {
296 return Err(ValidationError::new("a powered evaluation has pairs"));
297 }
298 Ok(())
299 }
300}
301
302#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
305#[serde(deny_unknown_fields)]
306pub struct ContextPolicyEvidenceV1 {
307 pub schema_version: u32,
308 pub records: Vec<StrategyOutcomeRecordV1>,
309 #[serde(default, skip_serializing_if = "Vec::is_empty")]
314 pub evaluations: Vec<StrategyEvaluationV1>,
315}
316
317impl ContextPolicyEvidenceV1 {
318 #[must_use]
321 pub fn without_v1_1_fields(&self) -> Self {
322 let mut legacy = self.clone();
323 legacy.evaluations.clear();
324 for record in &mut legacy.records {
325 record.signal_samples = 0;
326 record.bounce_tasks = 0;
327 record.expand_tasks = 0;
328 record.edit_failure_tasks = 0;
329 }
330 legacy
331 }
332
333 pub fn validate(&self) -> Result<(), ValidationError> {
334 if self.schema_version != CONTEXT_POLICY_EVIDENCE_VERSION {
335 return Err(ValidationError::new("unsupported evidence version"));
336 }
337 if self.records.len() > MAX_EVIDENCE_RECORDS {
338 return Err(ValidationError::new("too many evidence records"));
339 }
340 for record in &self.records {
341 record.validate()?;
342 }
343 if !self
344 .records
345 .windows(2)
346 .all(|pair| pair[0].key() < pair[1].key())
347 {
348 return Err(ValidationError::new(
349 "records must be strictly sorted by workload, strategy and day",
350 ));
351 }
352 for evaluation in &self.evaluations {
353 evaluation.validate()?;
354 }
355 if !self
356 .evaluations
357 .windows(2)
358 .all(|pair| pair[0].strategy < pair[1].strategy)
359 {
360 return Err(ValidationError::new(
361 "evaluations must be one per strategy, sorted",
362 ));
363 }
364 Ok(())
365 }
366}
367
368pub const CONTEXT_POLICY_VERSION: u32 = 1;
370pub const MAX_POLICY_ENTRIES: usize = 4_096;
372
373#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
375#[serde(deny_unknown_fields)]
376pub struct ContextPolicyEntryV1 {
377 pub workload: WorkloadV1,
378 pub strategy: ReadStrategyV1,
379}
380
381#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
386#[serde(deny_unknown_fields)]
387pub struct ContextPolicyV1 {
388 pub schema_version: u32,
389 pub version: u64,
390 #[serde(default, skip_serializing_if = "Option::is_none")]
391 pub parent_version: Option<u64>,
392 pub entries: Vec<ContextPolicyEntryV1>,
393 pub learner_digest: String,
395}
396
397impl ContextPolicyV1 {
398 pub fn validate(&self) -> Result<(), ValidationError> {
399 if self.schema_version != CONTEXT_POLICY_VERSION {
400 return Err(ValidationError::new("unsupported policy version"));
401 }
402 if self.entries.len() > MAX_POLICY_ENTRIES {
403 return Err(ValidationError::new("too many policy entries"));
404 }
405 if self
406 .parent_version
407 .is_some_and(|parent| parent >= self.version)
408 {
409 return Err(ValidationError::new(
410 "a policy must be newer than its parent",
411 ));
412 }
413 if self.entries.iter().any(|entry| {
414 matches!(
415 entry.strategy,
416 ReadStrategyV1::Other | ReadStrategyV1::Auto | ReadStrategyV1::Lines
417 )
418 }) {
419 return Err(ValidationError::new(
420 "a policy names only strategies the planner can apply",
421 ));
422 }
423 if !self
424 .entries
425 .windows(2)
426 .all(|pair| pair[0].workload < pair[1].workload)
427 {
428 return Err(ValidationError::new(
429 "policy entries must be one per workload, sorted",
430 ));
431 }
432 if self.learner_digest.len() != 64
433 || !self
434 .learner_digest
435 .bytes()
436 .all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
437 {
438 return Err(ValidationError::new("invalid learner digest"));
439 }
440 Ok(())
441 }
442
443 #[must_use]
445 pub fn strategy_for(&self, workload: &WorkloadV1) -> Option<ReadStrategyV1> {
446 self.entries
447 .binary_search_by(|entry| entry.workload.cmp(workload))
448 .ok()
449 .map(|index| self.entries[index].strategy)
450 }
451}
452
453#[cfg(test)]
454mod tests {
455 use super::*;
456
457 fn record(strategy: ReadStrategyV1, day: u64) -> StrategyOutcomeRecordV1 {
458 StrategyOutcomeRecordV1 {
459 workload: WorkloadV1 {
460 task_class: WorkloadTaskClassV1::BugFix,
461 language: WorkloadLanguageV1::Rust,
462 size: WorkloadSizeV1::Tiny,
463 },
464 strategy,
465 observed_day: day,
466 samples: 3,
467 accepted: 2,
468 rejected: 1,
469 explicit_overrides: 0,
470 token_samples: 3,
471 signal_samples: 0,
472 bounce_tasks: 0,
473 expand_tasks: 0,
474 edit_failure_tasks: 0,
475 tokens_original: 900,
476 tokens_delivered: 300,
477 quality: QualityEvidenceV1::Unmeasured,
478 security: SecurityEvidenceV1::Unmeasured,
479 }
480 }
481
482 #[test]
483 fn budget_buckets_have_inclusive_upper_bounds() {
484 assert_eq!(WorkloadSizeV1::from_budget_tokens(0), WorkloadSizeV1::Tiny);
485 assert_eq!(
486 WorkloadSizeV1::from_budget_tokens(2_048),
487 WorkloadSizeV1::Tiny
488 );
489 assert_eq!(
490 WorkloadSizeV1::from_budget_tokens(2_049),
491 WorkloadSizeV1::Small
492 );
493 assert_eq!(
494 WorkloadSizeV1::from_budget_tokens(131_072),
495 WorkloadSizeV1::Large
496 );
497 assert_eq!(
498 WorkloadSizeV1::from_budget_tokens(131_073),
499 WorkloadSizeV1::VeryLarge
500 );
501 }
502
503 #[test]
504 fn modes_map_to_a_closed_set_and_never_pass_text_through() {
505 assert_eq!(ReadStrategyV1::from_mode("map"), ReadStrategyV1::Map);
506 assert_eq!(
507 ReadStrategyV1::from_mode("lines:10-20"),
508 ReadStrategyV1::Lines
509 );
510 assert_eq!(
511 ReadStrategyV1::from_mode("internal-payments"),
512 ReadStrategyV1::Other
513 );
514 let json = serde_json::to_string(&ReadStrategyV1::from_mode("internal-payments"))
515 .expect("serializes");
516 assert_eq!(json, r#""other""#);
517 for strategy in [
518 ReadStrategyV1::Full,
519 ReadStrategyV1::Lines,
520 ReadStrategyV1::Other,
521 ] {
522 assert_eq!(
523 serde_json::to_value(strategy).expect("serializes"),
524 strategy.as_str()
525 );
526 }
527 }
528
529 #[test]
530 fn evidence_must_be_consistent_and_sorted() {
531 let mut evidence = ContextPolicyEvidenceV1 {
532 schema_version: CONTEXT_POLICY_EVIDENCE_VERSION,
533 records: vec![
534 record(ReadStrategyV1::Full, 20_000),
535 record(ReadStrategyV1::Full, 20_001),
536 record(ReadStrategyV1::Map, 19_000),
537 ],
538 evaluations: Vec::new(),
539 };
540 let evaluation = |strategy| StrategyEvaluationV1 {
541 strategy,
542 evidence_tier: crate::context_gateway::QualityEvidenceTierV1::LiveTaskEvaluation,
543 verdict: EvaluationVerdictV1::NonInferior,
544 pairs: 30,
545 powered: true,
546 delta_milli: 5,
547 ci_low_milli: -10,
548 ci_high_milli: 20,
549 margin_milli: 30,
550 };
551 evidence.evaluations = vec![
552 evaluation(ReadStrategyV1::Full),
553 evaluation(ReadStrategyV1::Map),
554 ];
555 assert!(evidence.validate().is_ok());
556 evidence.evaluations.swap(0, 1);
557 assert!(evidence.validate().is_err(), "evaluations unsorted");
558 evidence.evaluations = vec![evaluation(ReadStrategyV1::Other)];
559 assert!(
560 evidence.validate().is_err(),
561 "an unknown strategy is no evaluation"
562 );
563 evidence.evaluations.clear();
564 assert!(evidence.validate().is_ok());
565 evidence.records.swap(0, 1);
566 assert!(evidence.validate().is_err(), "days unsorted");
567 evidence.records.swap(0, 1);
568 evidence.records[0].rejected = 2;
569 assert!(evidence.validate().is_err(), "outcomes do not add up");
570 evidence.records[0].rejected = 1;
571 evidence.records[0].token_samples = 0;
572 assert!(evidence.validate().is_err(), "tokens without token samples");
573 evidence.records[0].token_samples = 4;
574 assert!(
575 evidence.validate().is_err(),
576 "more token samples than samples"
577 );
578 evidence.records[0].token_samples = 3;
579 evidence.records[0].quality = QualityEvidenceV1::Measured {
580 retention: RetentionCountsV1 {
581 retained: 1,
582 recoverable: 0,
583 lost: 0,
584 },
585 recovery: QualityRecoveryV1 {
586 handles_emitted: 1,
587 handles_verified: 1,
588 failures: 0,
589 critical_failures: 1,
590 },
591 };
592 assert!(evidence.validate().is_err(), "impossible recovery counts");
593 }
594
595 #[test]
596 fn unmeasured_is_explicit_on_the_wire() {
597 let json = serde_json::to_string(&record(ReadStrategyV1::Map, 1)).expect("round trip");
598 assert!(
599 json.contains(r#""quality":{"state":"unmeasured"}"#),
600 "{json}"
601 );
602 assert!(
603 json.contains(r#""security":{"state":"unmeasured"}"#),
604 "{json}"
605 );
606 let back: StrategyOutcomeRecordV1 = serde_json::from_str(&json).expect("round trip");
607 assert_eq!(back, record(ReadStrategyV1::Map, 1));
608
609 assert!(!json.contains("signal_samples"), "{json}");
612 let mut signalled = record(ReadStrategyV1::Map, 1);
613 signalled.signal_samples = 2;
614 signalled.bounce_tasks = 1;
615 let evidence = ContextPolicyEvidenceV1 {
616 schema_version: CONTEXT_POLICY_EVIDENCE_VERSION,
617 records: vec![signalled],
618 evaluations: vec![StrategyEvaluationV1 {
619 strategy: ReadStrategyV1::Map,
620 evidence_tier: crate::context_gateway::QualityEvidenceTierV1::LiveTaskEvaluation,
621 verdict: EvaluationVerdictV1::NonInferior,
622 pairs: 10,
623 powered: true,
624 delta_milli: 0,
625 ci_low_milli: -5,
626 ci_high_milli: 5,
627 margin_milli: 30,
628 }],
629 };
630 let current = serde_json::to_string(&evidence).expect("json");
631 assert!(current.contains("\"bounce_tasks\":1") && current.contains("evaluations"));
632 let legacy = serde_json::to_string(&evidence.without_v1_1_fields()).expect("json");
633 assert!(
634 !legacy.contains("signal_samples")
635 && !legacy.contains("bounce_tasks")
636 && !legacy.contains("evaluations"),
637 "{legacy}"
638 );
639 }
640
641 #[test]
642 fn policies_name_only_applicable_strategies_once_per_workload() {
643 let workload = |size| WorkloadV1 {
644 task_class: WorkloadTaskClassV1::BugFix,
645 language: WorkloadLanguageV1::Rust,
646 size,
647 };
648 let mut policy = ContextPolicyV1 {
649 schema_version: CONTEXT_POLICY_VERSION,
650 version: 2,
651 parent_version: Some(1),
652 entries: vec![
653 ContextPolicyEntryV1 {
654 workload: workload(WorkloadSizeV1::Tiny),
655 strategy: ReadStrategyV1::Full,
656 },
657 ContextPolicyEntryV1 {
658 workload: workload(WorkloadSizeV1::Large),
659 strategy: ReadStrategyV1::Map,
660 },
661 ],
662 learner_digest: "a".repeat(64),
663 };
664 policy.validate().expect("valid policy");
665 assert_eq!(
666 policy.strategy_for(&workload(WorkloadSizeV1::Large)),
667 Some(ReadStrategyV1::Map)
668 );
669 assert_eq!(policy.strategy_for(&workload(WorkloadSizeV1::Medium)), None);
670
671 let mut invalid = policy.clone();
672 invalid.entries[1].strategy = ReadStrategyV1::Auto;
673 assert!(
674 invalid.validate().is_err(),
675 "auto is not a learned strategy"
676 );
677 let mut invalid = policy.clone();
678 invalid.entries.swap(0, 1);
679 assert!(invalid.validate().is_err(), "unsorted entries");
680 let mut invalid = policy.clone();
681 invalid.parent_version = Some(2);
682 assert!(invalid.validate().is_err(), "parent not older");
683 policy.learner_digest = "A".repeat(64);
684 assert!(policy.validate().is_err(), "uppercase digest");
685 }
686}