1use std::collections::{BTreeMap, BTreeSet};
8use std::sync::atomic::{AtomicBool, Ordering};
9use std::sync::{Arc, Mutex};
10use std::time::{Duration, Instant};
11
12use async_trait::async_trait;
13use futures::StreamExt;
14use serde::{Deserialize, Serialize};
15use sha2::{Digest, Sha256};
16use tokio::sync::Semaphore;
17
18use super::compose::verdict_strictness;
19use crate::flow::{InvariantBlockError, InvariantResult, PredicateHash, Slice};
20
21const DEFAULT_DETERMINISTIC_BUDGET: Duration = Duration::from_millis(50);
22const DEFAULT_SEMANTIC_BUDGET: Duration = Duration::from_secs(2);
23const DEFAULT_SEMANTIC_TOKEN_CAP: u64 = 1024;
24const DEFAULT_MAX_DETERMINISTIC_LANES: usize = 16;
25const DEFAULT_MAX_SEMANTIC_LANES: usize = 2;
26const DEFAULT_MAX_DETERMINISTIC_LANES_PER_SLICE: usize = usize::MAX;
27const DEFAULT_MAX_SEMANTIC_LANES_PER_SLICE: usize = 1;
28const DEFAULT_SLICE_DETERMINISTIC_ENVELOPE: Duration = Duration::from_secs(5);
29const DEFAULT_SLICE_SEMANTIC_ENVELOPE: Duration = Duration::from_secs(20);
30
31#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case")]
34pub enum PredicateKind {
35 Deterministic,
37 Semantic,
40}
41
42#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(rename_all = "snake_case")]
45pub enum SemanticFallbackPolicy {
46 #[default]
48 Enforce,
49 Advisory,
52}
53
54#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
56pub struct CheapJudgeRequest {
57 pub prompt: String,
58 pub evidence_key: String,
59 pub evidence: String,
60}
61
62#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
64pub struct CheapJudgeResponse {
65 pub passes: bool,
66 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub reason: Option<String>,
68 #[serde(default)]
69 pub input_tokens: u64,
70 #[serde(default)]
71 pub output_tokens: u64,
72 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub provider_id: Option<String>,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub model_id: Option<String>,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub cheap_judge_version: Option<String>,
78}
79
80#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
82pub struct SemanticReplayAuditMetadata {
83 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub provider_id: Option<String>,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub model_id: Option<String>,
87 pub prompt_hash: String,
88 pub evidence_hashes: BTreeMap<String, String>,
89 pub token_cap: u64,
90 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub cheap_judge_version: Option<String>,
92}
93
94#[async_trait]
96pub trait CheapJudge: Send + Sync {
97 async fn cheap_judge(
98 &self,
99 request: CheapJudgeRequest,
100 ) -> Result<CheapJudgeResponse, InvariantBlockError>;
101}
102
103#[async_trait]
105pub trait PredicateRunner: Send + Sync {
106 fn hash(&self) -> PredicateHash;
107 fn name(&self) -> String {
108 self.hash().as_str().to_string()
109 }
110 fn kind(&self) -> PredicateKind;
111 fn fallback_hash(&self) -> Option<PredicateHash> {
112 None
113 }
114 fn fallback_policy(&self) -> SemanticFallbackPolicy {
115 SemanticFallbackPolicy::Enforce
116 }
117 fn fallback_diagnostic(&self) -> Option<InvariantBlockError> {
118 None
119 }
120 fn raw_result(&self) -> Option<serde_json::Value> {
121 None
122 }
123 fn enforced(&self) -> bool {
124 true
125 }
126
127 fn evidence(&self) -> BTreeMap<String, String> {
131 BTreeMap::new()
132 }
133
134 async fn evaluate(&self, context: PredicateContext) -> InvariantResult;
135}
136
137#[derive(Clone)]
139pub struct PredicateContext {
140 inner: Arc<PredicateContextInner>,
141}
142
143struct PredicateContextInner {
144 slice: Arc<Slice>,
145 kind: PredicateKind,
146 evidence: BTreeMap<String, String>,
147 cheap_judge: Option<Arc<dyn CheapJudge>>,
148 semantic_token_cap: u64,
149 cancel_token: Arc<AtomicBool>,
150 judge_state: Mutex<JudgeBudgetState>,
151}
152
153#[derive(Default)]
154struct JudgeBudgetState {
155 calls: u64,
156 tokens: u64,
157 block_error: Option<InvariantBlockError>,
158 semantic_audit: Option<SemanticReplayAuditMetadata>,
159}
160
161impl PredicateContext {
162 fn new(
163 slice: Arc<Slice>,
164 kind: PredicateKind,
165 evidence: BTreeMap<String, String>,
166 cheap_judge: Option<Arc<dyn CheapJudge>>,
167 semantic_token_cap: u64,
168 cancel_token: Arc<AtomicBool>,
169 ) -> Self {
170 Self {
171 inner: Arc::new(PredicateContextInner {
172 slice,
173 kind,
174 evidence,
175 cheap_judge,
176 semantic_token_cap,
177 cancel_token,
178 judge_state: Mutex::new(JudgeBudgetState::default()),
179 }),
180 }
181 }
182
183 pub fn slice(&self) -> &Slice {
184 &self.inner.slice
185 }
186
187 pub fn kind(&self) -> PredicateKind {
188 self.inner.kind
189 }
190
191 pub fn evidence(&self, key: &str) -> Option<&str> {
192 self.inner.evidence.get(key).map(String::as_str)
193 }
194
195 pub fn is_cancelled(&self) -> bool {
196 self.inner.cancel_token.load(Ordering::SeqCst)
197 }
198
199 pub async fn cheap_judge(
205 &self,
206 prompt: impl Into<String>,
207 evidence_key: impl Into<String>,
208 ) -> Result<CheapJudgeResponse, InvariantBlockError> {
209 if self.inner.kind != PredicateKind::Semantic {
210 return Err(self.record_block(InvariantBlockError::new(
211 "side_effect_denied",
212 "deterministic predicates cannot invoke cheap_judge",
213 )));
214 }
215
216 let prompt = prompt.into();
217 let evidence_key = evidence_key.into();
218 let Some(evidence) = self.inner.evidence.get(&evidence_key).cloned() else {
219 return Err(self.record_block(InvariantBlockError::new(
220 "evidence_missing",
221 format!(
222 "semantic predicate requested evidence key '{evidence_key}' that was not pre-baked"
223 ),
224 )));
225 };
226
227 let estimated_tokens = estimate_tokens(&prompt).saturating_add(estimate_tokens(&evidence));
228 {
229 let mut state = self
230 .inner
231 .judge_state
232 .lock()
233 .expect("predicate judge state lock");
234 if state.calls >= 1 {
235 let error = InvariantBlockError::budget_exceeded(
236 "semantic predicate exceeded one cheap_judge call",
237 );
238 state.block_error = Some(error.clone());
239 return Err(error);
240 }
241 if state.tokens.saturating_add(estimated_tokens) > self.inner.semantic_token_cap {
242 let error = InvariantBlockError::budget_exceeded(format!(
243 "semantic predicate cheap_judge request exceeds token cap {}",
244 self.inner.semantic_token_cap
245 ));
246 state.block_error = Some(error.clone());
247 return Err(error);
248 }
249 state.calls += 1;
250 state.tokens = state.tokens.saturating_add(estimated_tokens);
251 }
252
253 let Some(judge) = self.inner.cheap_judge.clone() else {
254 return Err(self.record_block(InvariantBlockError::new(
255 "llm_unavailable",
256 "semantic predicate cheap_judge was requested but no judge is installed",
257 )));
258 };
259
260 let response = match judge
261 .cheap_judge(CheapJudgeRequest {
262 prompt: prompt.clone(),
263 evidence_key: evidence_key.clone(),
264 evidence: evidence.clone(),
265 })
266 .await
267 {
268 Ok(response) => response,
269 Err(error) => return Err(self.record_block(error)),
270 };
271 {
272 let mut state = self
273 .inner
274 .judge_state
275 .lock()
276 .expect("predicate judge state lock");
277 state.semantic_audit = Some(SemanticReplayAuditMetadata {
278 provider_id: response.provider_id.clone(),
279 model_id: response.model_id.clone(),
280 prompt_hash: stable_hash(prompt.as_bytes()),
281 evidence_hashes: self
282 .inner
283 .evidence
284 .iter()
285 .map(|(key, value)| (key.clone(), stable_hash(value.as_bytes())))
286 .collect(),
287 token_cap: self.inner.semantic_token_cap,
288 cheap_judge_version: response.cheap_judge_version.clone(),
289 });
290 }
291 let response_tokens = response.input_tokens.saturating_add(response.output_tokens);
292 {
293 let mut state = self
294 .inner
295 .judge_state
296 .lock()
297 .expect("predicate judge state lock");
298 state.tokens = state.tokens.saturating_add(response_tokens);
299 if state.tokens > self.inner.semantic_token_cap {
300 let error = InvariantBlockError::budget_exceeded(format!(
301 "semantic predicate cheap_judge response exceeded token cap {}",
302 self.inner.semantic_token_cap
303 ));
304 state.block_error = Some(error.clone());
305 return Err(error);
306 }
307 }
308 Ok(response)
309 }
310
311 fn cancel(&self) {
312 self.inner.cancel_token.store(true, Ordering::SeqCst);
313 }
314
315 fn block_error(&self) -> Option<InvariantBlockError> {
316 self.inner
317 .judge_state
318 .lock()
319 .expect("predicate judge state lock")
320 .block_error
321 .clone()
322 }
323
324 fn semantic_audit(&self) -> Option<SemanticReplayAuditMetadata> {
325 self.inner
326 .judge_state
327 .lock()
328 .expect("predicate judge state lock")
329 .semantic_audit
330 .clone()
331 }
332
333 fn record_block(&self, error: InvariantBlockError) -> InvariantBlockError {
334 self.inner
335 .judge_state
336 .lock()
337 .expect("predicate judge state lock")
338 .block_error = Some(error.clone());
339 error
340 }
341}
342
343#[derive(Clone, Debug)]
365pub struct PredicateSchedulerConfig {
366 pub max_deterministic_lanes: usize,
369 pub max_semantic_lanes: usize,
372 pub max_deterministic_lanes_per_slice: usize,
374 pub max_semantic_lanes_per_slice: usize,
378 pub slice_deterministic_envelope: Duration,
384 pub slice_semantic_envelope: Duration,
388}
389
390impl Default for PredicateSchedulerConfig {
391 fn default() -> Self {
392 Self {
393 max_deterministic_lanes: DEFAULT_MAX_DETERMINISTIC_LANES,
394 max_semantic_lanes: DEFAULT_MAX_SEMANTIC_LANES,
395 max_deterministic_lanes_per_slice: DEFAULT_MAX_DETERMINISTIC_LANES_PER_SLICE,
396 max_semantic_lanes_per_slice: DEFAULT_MAX_SEMANTIC_LANES_PER_SLICE,
397 slice_deterministic_envelope: DEFAULT_SLICE_DETERMINISTIC_ENVELOPE,
398 slice_semantic_envelope: DEFAULT_SLICE_SEMANTIC_ENVELOPE,
399 }
400 }
401}
402
403#[derive(Clone, Debug)]
405pub struct PredicateExecutorConfig {
406 pub deterministic_budget: Duration,
407 pub semantic_budget: Duration,
408 pub semantic_token_cap: u64,
409 pub replay_deterministic: bool,
411 pub scheduler: PredicateSchedulerConfig,
413}
414
415impl Default for PredicateExecutorConfig {
416 fn default() -> Self {
417 Self {
418 deterministic_budget: DEFAULT_DETERMINISTIC_BUDGET,
419 semantic_budget: DEFAULT_SEMANTIC_BUDGET,
420 semantic_token_cap: DEFAULT_SEMANTIC_TOKEN_CAP,
421 replay_deterministic: true,
422 scheduler: PredicateSchedulerConfig::default(),
423 }
424 }
425}
426
427#[derive(Clone)]
429pub struct PredicateExecutor {
430 config: PredicateExecutorConfig,
431 cheap_judge: Option<Arc<dyn CheapJudge>>,
432}
433
434impl PredicateExecutor {
435 pub fn new(config: PredicateExecutorConfig) -> Self {
436 Self {
437 config,
438 cheap_judge: None,
439 }
440 }
441
442 pub fn with_cheap_judge(
443 config: PredicateExecutorConfig,
444 cheap_judge: Arc<dyn CheapJudge>,
445 ) -> Self {
446 Self {
447 config,
448 cheap_judge: Some(cheap_judge),
449 }
450 }
451
452 pub async fn execute_slice(
458 &self,
459 slice: &Slice,
460 predicates: &[Arc<dyn PredicateRunner>],
461 ) -> PredicateExecutionReport {
462 let mut reports = self
463 .execute_slices(vec![(slice.clone(), predicates.to_vec())])
464 .await;
465 reports.pop().unwrap_or(PredicateExecutionReport {
466 records: Vec::new(),
467 skipped: Vec::new(),
468 })
469 }
470
471 pub async fn execute_slice_serial(
474 &self,
475 slice: &Slice,
476 predicates: &[Arc<dyn PredicateRunner>],
477 ) -> PredicateExecutionReport {
478 let scheduler = &self.config.scheduler;
479 let lanes = SliceLanes::new(
480 Arc::new(Semaphore::new(1)),
481 Arc::new(Semaphore::new(1)),
482 1,
483 1,
484 );
485 let envelope = SliceEnvelope::new(
486 scheduler.slice_deterministic_envelope,
487 scheduler.slice_semantic_envelope,
488 );
489 let slice = Arc::new(slice.clone());
490 let mut records = Vec::with_capacity(predicates.len());
491 for runner in predicates {
492 records.push(
493 self.execute_one(
494 slice.clone(),
495 runner.clone(),
496 lanes.clone(),
497 envelope.clone(),
498 )
499 .await,
500 );
501 }
502 self.apply_semantic_fallbacks(&mut records);
503 records.sort_by(|left, right| left.predicate_hash.cmp(&right.predicate_hash));
504 PredicateExecutionReport {
505 records,
506 skipped: Vec::new(),
507 }
508 }
509
510 pub async fn execute_named_slice_serial(
515 &self,
516 slice: &Slice,
517 predicates: &[Arc<dyn PredicateRunner>],
518 requested_names: Option<&BTreeSet<String>>,
519 include_semantic: bool,
520 ) -> PredicateExecutionReport {
521 let directly_selected = predicates
522 .iter()
523 .filter(|runner| {
524 requested_names
525 .map(|names| names.contains(&runner.name()))
526 .unwrap_or(true)
527 && (include_semantic || runner.kind() != PredicateKind::Semantic)
528 })
529 .map(|runner| runner.hash())
530 .collect::<BTreeSet<_>>();
531 let by_hash = predicates
532 .iter()
533 .map(|runner| (runner.hash(), runner.clone()))
534 .collect::<BTreeMap<_, _>>();
535 let mut selected = directly_selected.clone();
536 for runner in predicates {
537 if directly_selected.contains(&runner.hash())
538 && runner.kind() == PredicateKind::Semantic
539 {
540 if let Some(fallback_hash) = runner.fallback_hash() {
541 if by_hash.contains_key(&fallback_hash) {
542 selected.insert(fallback_hash);
543 }
544 }
545 }
546 }
547
548 let selected_runners = predicates
549 .iter()
550 .filter(|runner| selected.contains(&runner.hash()))
551 .cloned()
552 .collect::<Vec<_>>();
553 let skipped = predicates
554 .iter()
555 .filter(|runner| !selected.contains(&runner.hash()))
556 .map(|runner| PredicateExecutionSkip {
557 name: runner.name(),
558 predicate_hash: runner.hash(),
559 kind: runner.kind(),
560 reason: if runner.kind() == PredicateKind::Semantic
561 && requested_names
562 .map(|names| names.contains(&runner.name()))
563 .unwrap_or(true)
564 && !include_semantic
565 {
566 "semantic predicates require an explicit include_semantic option".to_string()
567 } else {
568 "not selected".to_string()
569 },
570 })
571 .collect::<Vec<_>>();
572
573 let mut report = self.execute_slice_serial(slice, &selected_runners).await;
574 for record in &mut report.records {
575 record.enforced &= directly_selected.contains(&record.predicate_hash);
576 }
577 report.skipped = skipped;
578 report
579 }
580
581 pub async fn execute_slices(
595 &self,
596 slices: Vec<(Slice, Vec<Arc<dyn PredicateRunner>>)>,
597 ) -> Vec<PredicateExecutionReport> {
598 let scheduler = &self.config.scheduler;
599 let det_global = Arc::new(Semaphore::new(clamp_permits(
600 scheduler.max_deterministic_lanes,
601 )));
602 let sem_global = Arc::new(Semaphore::new(clamp_permits(scheduler.max_semantic_lanes)));
603
604 let slice_futures = slices
605 .into_iter()
606 .map(|(slice, predicates)| {
607 let executor = self.clone();
608 let det_global = det_global.clone();
609 let sem_global = sem_global.clone();
610 async move {
611 executor
612 .execute_slice_inner(slice, predicates, det_global, sem_global)
613 .await
614 }
615 })
616 .collect::<Vec<_>>();
617
618 futures::future::join_all(slice_futures).await
619 }
620
621 async fn execute_slice_inner(
622 &self,
623 slice: Slice,
624 predicates: Vec<Arc<dyn PredicateRunner>>,
625 det_global: Arc<Semaphore>,
626 sem_global: Arc<Semaphore>,
627 ) -> PredicateExecutionReport {
628 let scheduler = &self.config.scheduler;
629 let slice_rc = Arc::new(slice);
630 let lanes = SliceLanes::new(
631 det_global,
632 sem_global,
633 scheduler.max_deterministic_lanes_per_slice,
634 scheduler.max_semantic_lanes_per_slice,
635 );
636 let envelope = SliceEnvelope::new(
637 scheduler.slice_deterministic_envelope,
638 scheduler.slice_semantic_envelope,
639 );
640
641 let buffer = predicates.len().max(1);
646
647 let mut records = futures::stream::iter(predicates)
648 .map(|runner| {
649 let executor = self.clone();
650 let slice = slice_rc.clone();
651 let lanes = lanes.clone();
652 let envelope = envelope.clone();
653 async move { executor.execute_one(slice, runner, lanes, envelope).await }
654 })
655 .buffer_unordered(buffer)
656 .collect::<Vec<_>>()
657 .await;
658
659 self.apply_semantic_fallbacks(&mut records);
660 records.sort_by(|left, right| left.predicate_hash.cmp(&right.predicate_hash));
661 PredicateExecutionReport {
662 records,
663 skipped: Vec::new(),
664 }
665 }
666
667 async fn execute_one(
668 &self,
669 slice: Arc<Slice>,
670 runner: Arc<dyn PredicateRunner>,
671 lanes: SliceLanes,
672 envelope: SliceEnvelope,
673 ) -> PredicateExecutionRecord {
674 let started = Instant::now();
675 let predicate_hash = runner.hash();
676 let name = runner.name();
677 let kind = runner.kind();
678 let first = self
679 .run_attempt(slice.clone(), runner.as_ref(), &lanes, &envelope)
680 .await;
681 let first_hash = hash_result(&first.result);
682 let mut result = first.result;
683 let mut attempts = 1;
684 let mut second_hash = None;
685 let semantic_replay_audit = first.semantic_audit;
686
687 if self.config.replay_deterministic
688 && kind == PredicateKind::Deterministic
689 && !result.is_blocking()
690 {
691 let second = self
692 .run_attempt(slice, runner.as_ref(), &lanes, &envelope)
693 .await;
694 attempts = 2;
695 let replay_hash = hash_result(&second.result);
696 second_hash = replay_hash.clone();
697 if second.result.is_blocking() {
698 result = second.result;
699 } else {
700 match (first_hash.as_ref(), replay_hash.as_ref()) {
701 (Some(left), Some(right)) if left == right => {}
702 (Some(left), Some(right)) => {
703 result = InvariantResult::block(InvariantBlockError::nondeterministic_drift(
704 format!(
705 "deterministic predicate result drifted across replay: {left} != {right}"
706 ),
707 ));
708 }
709 _ => {
710 result = InvariantResult::block(InvariantBlockError::new(
711 "result_hash_failed",
712 "failed to hash deterministic predicate replay result",
713 ));
714 }
715 }
716 }
717 }
718
719 PredicateExecutionRecord {
720 name,
721 predicate_hash,
722 kind,
723 fallback_hash: runner.fallback_hash(),
724 fallback_policy: runner.fallback_policy(),
725 fallback_diagnostic: runner.fallback_diagnostic(),
726 result,
727 raw_result: runner.raw_result(),
728 enforced: runner.enforced(),
729 elapsed_ms: started.elapsed().as_millis() as u64,
730 attempts,
731 replayable: kind == PredicateKind::Deterministic,
732 first_result_hash: first_hash,
733 second_result_hash: second_hash,
734 semantic_replay_audit,
735 }
736 }
737
738 async fn run_attempt(
739 &self,
740 slice: Arc<Slice>,
741 runner: &dyn PredicateRunner,
742 lanes: &SliceLanes,
743 envelope: &SliceEnvelope,
744 ) -> PredicateAttempt {
745 let kind = runner.kind();
746 let timeout = match kind {
747 PredicateKind::Deterministic => self.config.deterministic_budget,
748 PredicateKind::Semantic => self.config.semantic_budget,
749 };
750
751 if let Some(attempt) =
752 envelope_exhausted_attempt(envelope, kind, "before this predicate started")
753 {
754 return attempt;
755 }
756
757 let _permits = lanes.acquire(kind).await;
758
759 if let Some(attempt) =
762 envelope_exhausted_attempt(envelope, kind, "while waiting for a lane")
763 {
764 return attempt;
765 }
766
767 let context = PredicateContext::new(
768 slice,
769 kind,
770 runner.evidence(),
771 self.cheap_judge.clone(),
772 self.config.semantic_token_cap,
773 Arc::new(AtomicBool::new(false)),
774 );
775 let started = Instant::now();
776 let attempt = match tokio::time::timeout(timeout, runner.evaluate(context.clone())).await {
777 Ok(result) => PredicateAttempt {
778 result: context
779 .block_error()
780 .map(InvariantResult::block)
781 .unwrap_or(result),
782 semantic_audit: context.semantic_audit(),
783 },
784 Err(_) => {
785 context.cancel();
786 PredicateAttempt {
787 result: InvariantResult::block(InvariantBlockError::budget_exceeded(format!(
788 "{kind:?} predicate exceeded {}ms budget",
789 timeout.as_millis()
790 ))),
791 semantic_audit: context.semantic_audit(),
792 }
793 }
794 };
795 envelope.charge(kind, started.elapsed());
796 attempt
797 }
798
799 fn apply_semantic_fallbacks(&self, records: &mut [PredicateExecutionRecord]) {
800 let by_hash = records
801 .iter()
802 .map(|record| {
803 (
804 record.predicate_hash.clone(),
805 (record.kind, record.result.clone()),
806 )
807 })
808 .collect::<BTreeMap<_, _>>();
809
810 for record in records {
811 if record.kind != PredicateKind::Semantic {
812 continue;
813 }
814 if let Some(diagnostic) = record.fallback_diagnostic.take() {
815 record.result = InvariantResult::block(diagnostic);
816 continue;
817 }
818 let Some(fallback_hash) = record.fallback_hash.as_ref() else {
819 record.result = InvariantResult::block(InvariantBlockError::new(
820 "fallback_missing",
821 "semantic predicate did not declare a deterministic fallback",
822 ));
823 continue;
824 };
825 let Some((fallback_kind, fallback_result)) = by_hash.get(fallback_hash) else {
826 record.result = InvariantResult::block(InvariantBlockError::new(
827 "fallback_unselected",
828 format!(
829 "semantic predicate fallback {} was not selected for evaluation",
830 fallback_hash.as_str()
831 ),
832 ));
833 continue;
834 };
835 if *fallback_kind != PredicateKind::Deterministic {
836 record.result = InvariantResult::block(InvariantBlockError::new(
837 "fallback_not_deterministic",
838 format!(
839 "semantic predicate fallback {} is not deterministic",
840 fallback_hash.as_str()
841 ),
842 ));
843 continue;
844 }
845 if record.fallback_policy == SemanticFallbackPolicy::Enforce {
846 record.result = stricter_result(&record.result, fallback_result);
847 }
848 }
849 }
850}
851
852impl Default for PredicateExecutor {
853 fn default() -> Self {
854 Self::new(PredicateExecutorConfig::default())
855 }
856}
857
858#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
860pub struct PredicateExecutionRecord {
861 pub name: String,
862 #[serde(rename = "hash")]
863 pub predicate_hash: PredicateHash,
864 pub kind: PredicateKind,
865 #[serde(default, skip_serializing_if = "Option::is_none")]
866 pub fallback_hash: Option<PredicateHash>,
867 #[serde(default)]
868 pub fallback_policy: SemanticFallbackPolicy,
869 #[serde(default, skip_serializing_if = "Option::is_none")]
870 pub fallback_diagnostic: Option<InvariantBlockError>,
871 pub result: InvariantResult,
872 #[serde(default, skip_serializing_if = "Option::is_none")]
873 pub raw_result: Option<serde_json::Value>,
874 #[serde(default = "default_true")]
877 pub enforced: bool,
878 pub elapsed_ms: u64,
879 pub attempts: u8,
880 pub replayable: bool,
881 #[serde(default, skip_serializing_if = "Option::is_none")]
882 pub first_result_hash: Option<String>,
883 #[serde(default, skip_serializing_if = "Option::is_none")]
884 pub second_result_hash: Option<String>,
885 #[serde(default, skip_serializing_if = "Option::is_none")]
886 pub semantic_replay_audit: Option<SemanticReplayAuditMetadata>,
887}
888
889#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
891pub struct PredicateExecutionReport {
892 pub records: Vec<PredicateExecutionRecord>,
893 #[serde(default)]
894 pub skipped: Vec<PredicateExecutionSkip>,
895}
896
897#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
898pub struct PredicateExecutionSkip {
899 pub name: String,
900 #[serde(rename = "hash")]
901 pub predicate_hash: PredicateHash,
902 pub kind: PredicateKind,
903 pub reason: String,
904}
905
906impl PredicateExecutionReport {
907 pub fn invariants_applied(&self) -> Vec<(PredicateHash, InvariantResult)> {
908 self.records
909 .iter()
910 .map(|record| (record.predicate_hash.clone(), record.result.clone()))
911 .collect()
912 }
913
914 pub fn is_allowed(&self) -> bool {
915 self.records
916 .iter()
917 .filter(|record| record.enforced)
918 .all(|record| !record.result.is_blocking())
919 }
920}
921
922fn hash_result(result: &InvariantResult) -> Option<String> {
923 let bytes = serde_json::to_vec(result).ok()?;
924 Some(hex::encode(Sha256::digest(bytes)))
925}
926
927fn stable_hash(bytes: &[u8]) -> String {
928 format!("sha256:{}", hex::encode(Sha256::digest(bytes)))
929}
930
931fn stricter_result(left: &InvariantResult, right: &InvariantResult) -> InvariantResult {
932 if verdict_strictness(&left.verdict) >= verdict_strictness(&right.verdict) {
933 left.clone()
934 } else {
935 right.clone()
936 }
937}
938
939fn estimate_tokens(value: &str) -> u64 {
940 value.split_whitespace().count().max(1) as u64
941}
942
943fn default_true() -> bool {
944 true
945}
946
947fn clamp_permits(value: usize) -> usize {
948 value.clamp(1, Semaphore::MAX_PERMITS)
949}
950
951fn envelope_exhausted_attempt(
955 envelope: &SliceEnvelope,
956 kind: PredicateKind,
957 when: &str,
958) -> Option<PredicateAttempt> {
959 let remaining = envelope.remaining(kind)?;
960 if !remaining.is_zero() {
961 return None;
962 }
963 Some(PredicateAttempt {
964 result: InvariantResult::block(InvariantBlockError::budget_exceeded(format!(
965 "slice {kind:?} envelope exhausted {when}"
966 ))),
967 semantic_audit: None,
968 })
969}
970
971struct PredicateAttempt {
972 result: InvariantResult,
973 semantic_audit: Option<SemanticReplayAuditMetadata>,
974}
975
976#[derive(Clone)]
979struct SliceLanes {
980 deterministic_global: Arc<Semaphore>,
981 deterministic_local: Arc<Semaphore>,
982 semantic_global: Arc<Semaphore>,
983 semantic_local: Arc<Semaphore>,
984}
985
986impl SliceLanes {
987 fn new(
988 deterministic_global: Arc<Semaphore>,
989 semantic_global: Arc<Semaphore>,
990 deterministic_per_slice: usize,
991 semantic_per_slice: usize,
992 ) -> Self {
993 Self {
994 deterministic_global,
995 deterministic_local: Arc::new(Semaphore::new(clamp_permits(deterministic_per_slice))),
996 semantic_global,
997 semantic_local: Arc::new(Semaphore::new(clamp_permits(semantic_per_slice))),
998 }
999 }
1000
1001 async fn acquire(&self, kind: PredicateKind) -> LaneTickets {
1002 let (global, local) = match kind {
1003 PredicateKind::Deterministic => (&self.deterministic_global, &self.deterministic_local),
1004 PredicateKind::Semantic => (&self.semantic_global, &self.semantic_local),
1005 };
1006 let local_ticket = local
1010 .clone()
1011 .acquire_owned()
1012 .await
1013 .expect("predicate lane semaphore closed");
1014 let global_ticket = global
1015 .clone()
1016 .acquire_owned()
1017 .await
1018 .expect("predicate lane semaphore closed");
1019 LaneTickets {
1020 _local: local_ticket,
1021 _global: global_ticket,
1022 }
1023 }
1024}
1025
1026struct LaneTickets {
1028 _local: tokio::sync::OwnedSemaphorePermit,
1029 _global: tokio::sync::OwnedSemaphorePermit,
1030}
1031
1032#[derive(Clone)]
1034struct SliceEnvelope {
1035 deterministic_used: Arc<Mutex<Duration>>,
1036 semantic_used: Arc<Mutex<Duration>>,
1037 deterministic_budget: Duration,
1038 semantic_budget: Duration,
1039}
1040
1041impl SliceEnvelope {
1042 fn new(deterministic_budget: Duration, semantic_budget: Duration) -> Self {
1043 Self {
1044 deterministic_used: Arc::new(Mutex::new(Duration::ZERO)),
1045 semantic_used: Arc::new(Mutex::new(Duration::ZERO)),
1046 deterministic_budget,
1047 semantic_budget,
1048 }
1049 }
1050
1051 fn counter(&self, kind: PredicateKind) -> &Mutex<Duration> {
1052 match kind {
1053 PredicateKind::Deterministic => &self.deterministic_used,
1054 PredicateKind::Semantic => &self.semantic_used,
1055 }
1056 }
1057
1058 fn budget(&self, kind: PredicateKind) -> Duration {
1059 match kind {
1060 PredicateKind::Deterministic => self.deterministic_budget,
1061 PredicateKind::Semantic => self.semantic_budget,
1062 }
1063 }
1064
1065 fn remaining(&self, kind: PredicateKind) -> Option<Duration> {
1066 let budget = self.budget(kind);
1067 if budget.is_zero() {
1068 return None;
1069 }
1070 let used = *self.counter(kind).lock().expect("slice envelope lock");
1071 Some(budget.saturating_sub(used))
1072 }
1073
1074 fn charge(&self, kind: PredicateKind, elapsed: Duration) {
1075 let mut used = self.counter(kind).lock().expect("slice envelope lock");
1076 *used = used.saturating_add(elapsed);
1077 }
1078}
1079
1080#[cfg(test)]
1081#[path = "executor_tests.rs"]
1082mod tests;