1use std::collections::BTreeMap;
8
9use thiserror::Error;
10
11use crate::RillError;
12#[cfg(feature = "serde")]
13use crate::ValidateState;
14
15pub const DECISION_LEDGER_STATE_VERSION: u32 = 1;
17pub const DEFAULT_MAX_CONTEXT_BYTES: usize = 1024 * 1024;
19pub const DEFAULT_MAX_ACTION_BYTES: usize = 64 * 1024;
21
22pub trait BoundedDecisionValue {
24 fn validate_bounded(&self, max_bytes: usize) -> Result<(), DecisionLedgerError>;
26}
27
28impl BoundedDecisionValue for Vec<f64> {
29 fn validate_bounded(&self, max_bytes: usize) -> Result<(), DecisionLedgerError> {
30 if self
31 .len()
32 .checked_mul(std::mem::size_of::<f64>())
33 .is_none_or(|size| size > max_bytes)
34 {
35 return Err(DecisionLedgerError::ValueTooLarge);
36 }
37 if self.iter().any(|value| !value.is_finite()) {
38 return Err(DecisionLedgerError::InvalidValue);
39 }
40 Ok(())
41 }
42}
43
44impl BoundedDecisionValue for Vec<u8> {
45 fn validate_bounded(&self, max_bytes: usize) -> Result<(), DecisionLedgerError> {
46 if self.len() > max_bytes {
47 return Err(DecisionLedgerError::ValueTooLarge);
48 }
49 Ok(())
50 }
51}
52
53impl BoundedDecisionValue for String {
54 fn validate_bounded(&self, max_bytes: usize) -> Result<(), DecisionLedgerError> {
55 if self.len() > max_bytes {
56 return Err(DecisionLedgerError::ValueTooLarge);
57 }
58 Ok(())
59 }
60}
61
62macro_rules! impl_fixed_decision_value {
63 ($($type:ty),+ $(,)?) => {
64 $(impl BoundedDecisionValue for $type {
65 fn validate_bounded(&self, max_bytes: usize) -> Result<(), DecisionLedgerError> {
66 if std::mem::size_of::<$type>() > max_bytes {
67 return Err(DecisionLedgerError::ValueTooLarge);
68 }
69 Ok(())
70 }
71 })+
72 };
73}
74
75impl_fixed_decision_value!((), bool, usize, u64, u32, u16, u8, i64, i32, i16, i8);
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
79#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
80pub struct DecisionId(pub u128);
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
85#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
86#[non_exhaustive]
87pub struct DecisionLedgerConfig {
88 pub max_pending: usize,
90 pub max_completed: usize,
92 pub max_context_bytes: usize,
94 pub max_action_bytes: usize,
96}
97
98impl DecisionLedgerConfig {
99 pub fn new(max_pending: usize, max_completed: usize) -> Result<Self, DecisionLedgerError> {
101 if max_pending == 0 || max_completed == 0 {
102 return Err(DecisionLedgerError::InvalidCapacity);
103 }
104 Ok(Self {
105 max_pending,
106 max_completed,
107 max_context_bytes: DEFAULT_MAX_CONTEXT_BYTES,
108 max_action_bytes: DEFAULT_MAX_ACTION_BYTES,
109 })
110 }
111
112 pub fn with_value_limits(
114 mut self,
115 max_context_bytes: usize,
116 max_action_bytes: usize,
117 ) -> Result<Self, DecisionLedgerError> {
118 if max_context_bytes == 0 || max_action_bytes == 0 {
119 return Err(DecisionLedgerError::InvalidCapacity);
120 }
121 self.max_context_bytes = max_context_bytes;
122 self.max_action_bytes = max_action_bytes;
123 Ok(self)
124 }
125}
126
127impl Default for DecisionLedgerConfig {
128 fn default() -> Self {
129 Self {
130 max_pending: 1_024,
131 max_completed: 4_096,
132 max_context_bytes: DEFAULT_MAX_CONTEXT_BYTES,
133 max_action_bytes: DEFAULT_MAX_ACTION_BYTES,
134 }
135 }
136}
137
138#[derive(Debug, Clone, PartialEq)]
140#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
141#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
142pub struct PendingDecision<C, A> {
143 pub id: DecisionId,
145 pub context: C,
147 pub action: A,
149 pub created_at: u64,
151 pub expires_at: u64,
153 pub model_generation: u64,
155}
156
157impl<C, A> PendingDecision<C, A> {
158 pub const fn new(
160 id: DecisionId,
161 context: C,
162 action: A,
163 created_at: u64,
164 expires_at: u64,
165 model_generation: u64,
166 ) -> Self {
167 Self {
168 id,
169 context,
170 action,
171 created_at,
172 expires_at,
173 model_generation,
174 }
175 }
176}
177
178#[derive(Debug, Clone, PartialEq)]
180#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
181#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
182pub struct DecisionOutcome<A> {
183 pub decision_id: DecisionId,
185 pub action: A,
187 pub reward: f64,
189 pub observed_at: u64,
191 pub model_generation: u64,
193}
194
195#[derive(Debug, Clone, PartialEq)]
197#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
198#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
199pub struct CompletedDecision<C, A> {
200 pub decision: PendingDecision<C, A>,
202 pub reward: f64,
204 pub observed_at: u64,
206}
207
208#[derive(Debug, Clone, Copy, PartialEq, Eq)]
210pub struct DecisionReceipt {
211 pub id: DecisionId,
213 pub status: RegistrationStatus,
215}
216
217#[derive(Debug, Clone, Copy, PartialEq, Eq)]
219pub enum RegistrationStatus {
220 Registered,
222 PendingReplay,
224 CompletedReplay,
226}
227
228#[derive(Debug, Clone, Copy, PartialEq, Eq)]
230pub enum FeedbackStatus {
231 Applied,
233}
234
235#[derive(Debug, Error, Clone, PartialEq)]
237#[non_exhaustive]
238pub enum DecisionLedgerError {
239 #[error("decision ledger capacities must be greater than zero")]
241 InvalidCapacity,
242 #[error("decision expiry precedes its creation time")]
244 InvalidTimeRange,
245 #[error("decision reward must be finite")]
247 NonFiniteReward,
248 #[error("pending decision capacity is exhausted")]
250 PendingCapacityExceeded,
251 #[error("completed decision capacity is exhausted")]
253 CompletedCapacityExceeded,
254 #[error("decision context or action exceeds its configured byte limit")]
256 ValueTooLarge,
257 #[error("decision context or action is invalid")]
259 InvalidValue,
260 #[error("decision id already exists with different facts")]
262 DecisionConflict,
263 #[error("feedback references an unknown decision")]
265 UnknownDecision,
266 #[error("feedback was already applied")]
268 DuplicateFeedback,
269 #[error("feedback predates the decision")]
271 FeedbackBeforeDecision,
272 #[error("feedback arrived after decision expiry")]
274 FeedbackExpired,
275 #[error("feedback model generation does not match the decision")]
277 GenerationMismatch,
278 #[error("feedback action does not match the decision")]
280 ActionMismatch,
281}
282
283#[derive(Debug, Clone, PartialEq)]
285#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
286#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
287pub struct DecisionLedger<C, A> {
288 state_version: u32,
289 config: DecisionLedgerConfig,
290 pending: BTreeMap<DecisionId, PendingDecision<C, A>>,
291 completed: BTreeMap<DecisionId, CompletedDecision<C, A>>,
292}
293
294impl<C, A> DecisionLedger<C, A>
295where
296 C: PartialEq + BoundedDecisionValue,
297 A: PartialEq + BoundedDecisionValue,
298{
299 pub fn new(config: DecisionLedgerConfig) -> Result<Self, DecisionLedgerError> {
301 if config.max_pending == 0
302 || config.max_completed == 0
303 || config.max_context_bytes == 0
304 || config.max_action_bytes == 0
305 {
306 return Err(DecisionLedgerError::InvalidCapacity);
307 }
308 Ok(Self {
309 state_version: DECISION_LEDGER_STATE_VERSION,
310 config,
311 pending: BTreeMap::new(),
312 completed: BTreeMap::new(),
313 })
314 }
315
316 pub fn register(
318 &mut self,
319 decision: PendingDecision<C, A>,
320 ) -> Result<DecisionReceipt, DecisionLedgerError> {
321 if decision.expires_at < decision.created_at {
322 return Err(DecisionLedgerError::InvalidTimeRange);
323 }
324 decision
325 .context
326 .validate_bounded(self.config.max_context_bytes)?;
327 decision
328 .action
329 .validate_bounded(self.config.max_action_bytes)?;
330 if let Some(existing) = self.pending.get(&decision.id) {
331 return if existing == &decision {
332 Ok(DecisionReceipt {
333 id: decision.id,
334 status: RegistrationStatus::PendingReplay,
335 })
336 } else {
337 Err(DecisionLedgerError::DecisionConflict)
338 };
339 }
340 if let Some(existing) = self.completed.get(&decision.id) {
341 return if existing.decision == decision {
342 Ok(DecisionReceipt {
343 id: decision.id,
344 status: RegistrationStatus::CompletedReplay,
345 })
346 } else {
347 Err(DecisionLedgerError::DecisionConflict)
348 };
349 }
350 if self.pending.len() >= self.config.max_pending {
351 return Err(DecisionLedgerError::PendingCapacityExceeded);
352 }
353 let id = decision.id;
354 self.pending.insert(id, decision);
355 Ok(DecisionReceipt {
356 id,
357 status: RegistrationStatus::Registered,
358 })
359 }
360
361 pub fn apply_feedback(
363 &mut self,
364 outcome: DecisionOutcome<A>,
365 ) -> Result<FeedbackStatus, DecisionLedgerError> {
366 if !outcome.reward.is_finite() {
367 return Err(DecisionLedgerError::NonFiniteReward);
368 }
369 outcome
370 .action
371 .validate_bounded(self.config.max_action_bytes)?;
372 if self.completed.contains_key(&outcome.decision_id) {
373 return Err(DecisionLedgerError::DuplicateFeedback);
374 }
375 let pending = self
376 .pending
377 .get(&outcome.decision_id)
378 .ok_or(DecisionLedgerError::UnknownDecision)?;
379 if outcome.observed_at < pending.created_at {
380 return Err(DecisionLedgerError::FeedbackBeforeDecision);
381 }
382 if outcome.observed_at > pending.expires_at {
383 return Err(DecisionLedgerError::FeedbackExpired);
384 }
385 if outcome.model_generation != pending.model_generation {
386 return Err(DecisionLedgerError::GenerationMismatch);
387 }
388 if outcome.action != pending.action {
389 return Err(DecisionLedgerError::ActionMismatch);
390 }
391 if self.completed.len() >= self.config.max_completed {
392 return Err(DecisionLedgerError::CompletedCapacityExceeded);
393 }
394
395 let decision = self
398 .pending
399 .remove(&outcome.decision_id)
400 .ok_or(DecisionLedgerError::UnknownDecision)?;
401 self.completed.insert(
402 outcome.decision_id,
403 CompletedDecision {
404 decision,
405 reward: outcome.reward,
406 observed_at: outcome.observed_at,
407 },
408 );
409 Ok(FeedbackStatus::Applied)
410 }
411
412 pub fn pending(&self, id: DecisionId) -> Option<&PendingDecision<C, A>> {
414 self.pending.get(&id)
415 }
416
417 pub fn completed(&self, id: DecisionId) -> Option<&CompletedDecision<C, A>> {
419 self.completed.get(&id)
420 }
421
422 pub fn pending_len(&self) -> usize {
424 self.pending.len()
425 }
426
427 pub fn completed_len(&self) -> usize {
429 self.completed.len()
430 }
431
432 pub fn validate(&self) -> Result<(), RillError> {
438 self.validate_structure()
439 }
440
441 pub fn clear_expired(&mut self, now: u64) -> Vec<DecisionId> {
443 let ids = self
444 .pending
445 .iter()
446 .filter_map(|(&id, decision)| (decision.expires_at < now).then_some(id))
447 .collect::<Vec<_>>();
448 for id in &ids {
449 self.pending.remove(id);
450 }
451 ids
452 }
453
454 pub fn clear_completed_before(&mut self, before: u64) -> Vec<DecisionId> {
456 let ids = self
457 .completed
458 .iter()
459 .filter_map(|(&id, decision)| (decision.observed_at < before).then_some(id))
460 .collect::<Vec<_>>();
461 for id in &ids {
462 self.completed.remove(id);
463 }
464 ids
465 }
466
467 fn validate_structure(&self) -> Result<(), RillError> {
468 if self.state_version != DECISION_LEDGER_STATE_VERSION {
469 return Err(RillError::IncompatibleStateVersion {
470 expected: DECISION_LEDGER_STATE_VERSION,
471 actual: self.state_version,
472 });
473 }
474 if self.config.max_pending == 0
475 || self.config.max_completed == 0
476 || self.config.max_context_bytes == 0
477 || self.config.max_action_bytes == 0
478 {
479 return Err(RillError::InvalidState(
480 "decision ledger capacity must be non-zero".to_owned(),
481 ));
482 }
483 if self.pending.len() > self.config.max_pending
484 || self.completed.len() > self.config.max_completed
485 {
486 return Err(RillError::InvalidState(
487 "decision ledger exceeds its configured capacity".to_owned(),
488 ));
489 }
490 for (id, decision) in &self.pending {
491 if id != &decision.id || decision.expires_at < decision.created_at {
492 return Err(RillError::InvalidState(
493 "pending decision has inconsistent id or timestamps".to_owned(),
494 ));
495 }
496 if self.completed.contains_key(id) {
497 return Err(RillError::InvalidState(
498 "decision exists in pending and completed sets".to_owned(),
499 ));
500 }
501 decision
502 .context
503 .validate_bounded(self.config.max_context_bytes)
504 .map_err(|error| RillError::InvalidState(error.to_string()))?;
505 decision
506 .action
507 .validate_bounded(self.config.max_action_bytes)
508 .map_err(|error| RillError::InvalidState(error.to_string()))?;
509 }
510 for (id, completed) in &self.completed {
511 let decision = &completed.decision;
512 if id != &decision.id
513 || decision.expires_at < decision.created_at
514 || completed.observed_at < decision.created_at
515 || completed.observed_at > decision.expires_at
516 || !completed.reward.is_finite()
517 {
518 return Err(RillError::InvalidState(
519 "completed decision has inconsistent state".to_owned(),
520 ));
521 }
522 decision
523 .context
524 .validate_bounded(self.config.max_context_bytes)
525 .map_err(|error| RillError::InvalidState(error.to_string()))?;
526 decision
527 .action
528 .validate_bounded(self.config.max_action_bytes)
529 .map_err(|error| RillError::InvalidState(error.to_string()))?;
530 }
531 Ok(())
532 }
533
534 pub fn validate_state_with<FC, FA>(
536 &self,
537 mut validate_context: FC,
538 mut validate_action: FA,
539 ) -> Result<(), RillError>
540 where
541 FC: FnMut(&C) -> Result<(), RillError>,
542 FA: FnMut(&A) -> Result<(), RillError>,
543 {
544 self.validate_structure()?;
545 for decision in self.pending.values() {
546 validate_context(&decision.context)?;
547 validate_action(&decision.action)?;
548 }
549 for completed in self.completed.values() {
550 validate_context(&completed.decision.context)?;
551 validate_action(&completed.decision.action)?;
552 }
553 Ok(())
554 }
555}
556
557#[cfg(feature = "serde")]
558impl<C, A> ValidateState for DecisionLedger<C, A>
559where
560 C: PartialEq + BoundedDecisionValue,
561 A: PartialEq + BoundedDecisionValue,
562{
563 fn validate_state(&self) -> Result<(), RillError> {
564 self.validate()
565 }
566}
567
568#[cfg(feature = "bandit")]
570pub type DelayedContextualDecision = PendingDecision<Vec<f64>, usize>;
571
572#[cfg(feature = "bandit")]
579pub fn apply_contextual_outcome(
580 ledger: &mut DecisionLedger<Vec<f64>, usize>,
581 model: &mut crate::bandit::LinUcb,
582 outcome: DecisionOutcome<usize>,
583) -> Result<FeedbackStatus, RillError> {
584 use crate::bandit::ContextualBandit;
585
586 let pending = ledger
587 .pending(outcome.decision_id)
588 .ok_or_else(|| RillError::InvalidState("unknown delayed decision".to_owned()))?;
589 if pending.context.len() != model.feature_count() {
590 return Err(RillError::DimensionMismatch {
591 expected: model.feature_count(),
592 actual: pending.context.len(),
593 });
594 }
595 for &value in &pending.context {
596 if !value.is_finite() {
597 return Err(RillError::NonFiniteValue {
598 field: "decision context",
599 value,
600 });
601 }
602 }
603 let context = pending.context.clone();
604 let action = outcome.action;
605 let reward = outcome.reward;
606 let mut next_ledger = ledger.clone();
607 let mut next_model = model.clone();
608 let status = next_ledger
609 .apply_feedback(outcome)
610 .map_err(|error| RillError::InvalidState(error.to_string()))?;
611 next_model.update(action, &context, reward)?;
612 *ledger = next_ledger;
613 *model = next_model;
614 Ok(status)
615}
616
617#[cfg(test)]
618mod tests {
619 use super::*;
620 use proptest::prelude::*;
621
622 fn decision(id: u128) -> PendingDecision<Vec<f64>, usize> {
623 PendingDecision::new(DecisionId(id), vec![1.0, 2.0], 1, 10, 20, 7)
624 }
625
626 fn outcome(id: u128) -> DecisionOutcome<usize> {
627 DecisionOutcome {
628 decision_id: DecisionId(id),
629 action: 1,
630 reward: 0.5,
631 observed_at: 15,
632 model_generation: 7,
633 }
634 }
635
636 #[test]
637 fn registration_is_idempotent_but_conflicts_fail() {
638 let mut ledger = DecisionLedger::new(DecisionLedgerConfig::new(2, 2).unwrap()).unwrap();
639 assert_eq!(
640 ledger.register(decision(1)).unwrap().status,
641 RegistrationStatus::Registered
642 );
643 assert_eq!(
644 ledger.register(decision(1)).unwrap().status,
645 RegistrationStatus::PendingReplay
646 );
647 let mut conflict = decision(1);
648 conflict.action = 0;
649 assert_eq!(
650 ledger.register(conflict),
651 Err(DecisionLedgerError::DecisionConflict)
652 );
653 assert_eq!(ledger.pending_len(), 1);
654 }
655
656 #[test]
657 fn feedback_checks_are_atomic_and_tombstoned() {
658 let mut ledger = DecisionLedger::new(DecisionLedgerConfig::new(1, 1).unwrap()).unwrap();
659 ledger.register(decision(1)).unwrap();
660 let before = ledger.clone();
661 let mut bad = outcome(1);
662 bad.action = 0;
663 assert_eq!(
664 ledger.apply_feedback(bad),
665 Err(DecisionLedgerError::ActionMismatch)
666 );
667 assert_eq!(ledger, before);
668 assert_eq!(
669 ledger.apply_feedback(outcome(1)).unwrap(),
670 FeedbackStatus::Applied
671 );
672 assert_eq!(ledger.pending_len(), 0);
673 assert_eq!(ledger.completed_len(), 1);
674 assert_eq!(
675 ledger.apply_feedback(outcome(1)),
676 Err(DecisionLedgerError::DuplicateFeedback)
677 );
678 }
679
680 #[test]
681 fn expiry_boundary_is_inclusive_and_cleanup_is_explicit() {
682 let mut ledger = DecisionLedger::new(DecisionLedgerConfig::new(2, 2).unwrap()).unwrap();
683 ledger.register(decision(1)).unwrap();
684 let mut at_boundary = outcome(1);
685 at_boundary.observed_at = 20;
686 assert!(ledger.apply_feedback(at_boundary).is_ok());
687
688 ledger.register(decision(2)).unwrap();
689 assert!(ledger.clear_expired(20).is_empty());
690 assert_eq!(ledger.clear_expired(21), vec![DecisionId(2)]);
691 }
692
693 #[test]
694 fn capacity_never_evicts_live_or_completed_state() {
695 let mut ledger = DecisionLedger::new(DecisionLedgerConfig::new(1, 1).unwrap()).unwrap();
696 ledger.register(decision(1)).unwrap();
697 assert_eq!(
698 ledger.register(decision(2)),
699 Err(DecisionLedgerError::PendingCapacityExceeded)
700 );
701 ledger.apply_feedback(outcome(1)).unwrap();
702 ledger.register(decision(2)).unwrap();
703 assert_eq!(
704 ledger.apply_feedback(outcome(2)),
705 Err(DecisionLedgerError::CompletedCapacityExceeded)
706 );
707 assert!(ledger.pending(DecisionId(2)).is_some());
708 }
709
710 #[test]
711 fn per_decision_context_and_action_memory_is_bounded() {
712 let config = DecisionLedgerConfig::new(2, 2)
713 .unwrap()
714 .with_value_limits(8, std::mem::size_of::<usize>())
715 .unwrap();
716 let mut ledger = DecisionLedger::new(config).unwrap();
717 assert_eq!(
718 ledger.register(decision(1)),
719 Err(DecisionLedgerError::ValueTooLarge)
720 );
721 assert_eq!(ledger.pending_len(), 0);
722 }
723
724 #[test]
725 fn every_feedback_fact_is_checked_without_mutation() {
726 let mut ledger = DecisionLedger::new(DecisionLedgerConfig::new(2, 2).unwrap()).unwrap();
727 ledger.register(decision(1)).unwrap();
728 let cases = [
729 (
730 DecisionOutcome {
731 decision_id: DecisionId(9),
732 ..outcome(1)
733 },
734 DecisionLedgerError::UnknownDecision,
735 ),
736 (
737 DecisionOutcome {
738 observed_at: 9,
739 ..outcome(1)
740 },
741 DecisionLedgerError::FeedbackBeforeDecision,
742 ),
743 (
744 DecisionOutcome {
745 observed_at: 21,
746 ..outcome(1)
747 },
748 DecisionLedgerError::FeedbackExpired,
749 ),
750 (
751 DecisionOutcome {
752 model_generation: 8,
753 ..outcome(1)
754 },
755 DecisionLedgerError::GenerationMismatch,
756 ),
757 (
758 DecisionOutcome {
759 action: 0,
760 ..outcome(1)
761 },
762 DecisionLedgerError::ActionMismatch,
763 ),
764 ];
765 for (bad, expected) in cases {
766 let before = ledger.clone();
767 assert_eq!(ledger.apply_feedback(bad), Err(expected));
768 assert_eq!(ledger, before);
769 }
770 }
771
772 #[test]
773 fn completed_replay_and_explicit_tombstone_cleanup() {
774 let mut ledger = DecisionLedger::new(DecisionLedgerConfig::new(2, 2).unwrap()).unwrap();
775 ledger.register(decision(1)).unwrap();
776 ledger.apply_feedback(outcome(1)).unwrap();
777 assert_eq!(
778 ledger.register(decision(1)).unwrap().status,
779 RegistrationStatus::CompletedReplay
780 );
781 assert!(ledger.clear_completed_before(15).is_empty());
782 assert_eq!(ledger.clear_completed_before(16), vec![DecisionId(1)]);
783 assert_eq!(ledger.completed_len(), 0);
784 }
785
786 #[test]
787 fn caller_validator_can_enforce_domain_specific_context_rules() {
788 let mut ledger = DecisionLedger::new(DecisionLedgerConfig::new(2, 2).unwrap()).unwrap();
789 let mut invalid = decision(1);
790 invalid.context[0] = -1.0;
791 ledger.register(invalid).unwrap();
792 assert!(
793 ledger
794 .validate_state_with(
795 |context| {
796 for &value in context {
797 if value < 0.0 {
798 return Err(RillError::InvalidState(
799 "context values must be non-negative".to_owned(),
800 ));
801 }
802 }
803 Ok(())
804 },
805 |_| Ok(()),
806 )
807 .is_err()
808 );
809 }
810
811 #[cfg(feature = "bandit")]
812 #[test]
813 fn contextual_helper_updates_model_and_ledger_together() {
814 let mut ledger = DecisionLedger::new(DecisionLedgerConfig::new(2, 2).unwrap()).unwrap();
815 ledger
816 .register(PendingDecision::new(DecisionId(1), vec![1.0], 0, 10, 20, 7))
817 .unwrap();
818 let config = crate::bandit::LinUcbConfig {
819 arm_count: 1,
820 feature_count: 1,
821 alpha: 1.0,
822 };
823 let mut model = crate::bandit::LinUcb::new(config).unwrap();
824 apply_contextual_outcome(
825 &mut ledger,
826 &mut model,
827 DecisionOutcome {
828 decision_id: DecisionId(1),
829 action: 0,
830 reward: 1.0,
831 observed_at: 12,
832 model_generation: 7,
833 },
834 )
835 .unwrap();
836 assert_eq!(ledger.pending_len(), 0);
837 assert_eq!(ledger.completed_len(), 1);
838 assert_eq!(crate::bandit::ContextualBandit::samples_seen(&model), 1);
839 }
840
841 #[cfg(feature = "serde")]
842 #[test]
843 fn serde_roundtrip_and_corruption_validation() {
844 let mut ledger = DecisionLedger::new(DecisionLedgerConfig::new(2, 2).unwrap()).unwrap();
845 ledger.register(decision(1)).unwrap();
846 let json = serde_json::to_string(&ledger).unwrap();
847 let restored: DecisionLedger<Vec<f64>, usize> = serde_json::from_str(&json).unwrap();
848 assert_eq!(restored, ledger);
849 restored.validate_state().unwrap();
850
851 let corrupt = json.replace("\"state_version\":1", "\"state_version\":9");
852 let restored: DecisionLedger<Vec<f64>, usize> = serde_json::from_str(&corrupt).unwrap();
853 assert!(restored.validate_state().is_err());
854
855 let corrupt = json.replace("\"max_pending\":2", "\"max_pending\":0");
856 let restored: DecisionLedger<Vec<f64>, usize> = serde_json::from_str(&corrupt).unwrap();
857 assert!(restored.validate_state().is_err());
858 }
859
860 proptest! {
861 #[test]
862 fn arbitrary_invalid_feedback_never_removes_pending(
863 reward in prop_oneof![Just(f64::NAN), Just(f64::INFINITY)],
864 observed_at in any::<u64>(),
865 ) {
866 let mut ledger = DecisionLedger::new(DecisionLedgerConfig::new(1, 1).unwrap()).unwrap();
867 ledger.register(decision(1)).unwrap();
868 let before = ledger.clone();
869 let result = ledger.apply_feedback(DecisionOutcome {
870 decision_id: DecisionId(1), action: 1, reward, observed_at, model_generation: 7,
871 });
872 prop_assert!(result.is_err());
873 prop_assert_eq!(ledger, before);
874 }
875 }
876}