1use std::collections::{BTreeMap, BTreeSet};
2
3use serde::{de::IgnoredAny, Deserialize, Deserializer, Serialize};
4use termivar_core::{
5 EntityId, EvidenceValue, Hypothesis, HypothesisState, HypothesisStrength, KnowledgePredicate,
6 Probability,
7};
8
9use crate::{
10 payload_strategy::PayloadStrategyRef,
11 rules::{Expression, ExpressionEvaluation},
12};
13
14use crate::planner::{
15 scoring::{ActionCost, BenefitScore, RiskScore, UtilityBreakdown, UtilityScore},
16 PlannerError,
17};
18
19fn is_false(value: &bool) -> bool {
20 !*value
21}
22
23fn non_empty(value: impl Into<String>, field: &'static str) -> Result<String, PlannerError> {
24 let value = value.into();
25 if value.trim().is_empty() {
26 return Err(PlannerError::EmptyValue { field });
27 }
28 Ok(value)
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case")]
34#[non_exhaustive]
35pub enum RequiredStrength {
36 Any,
38 Strong,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44pub struct HypothesisSelector {
45 predicate: KnowledgePredicate,
46 value: EvidenceValue,
47 minimum_posterior: Probability,
48 required_strength: RequiredStrength,
49}
50
51impl HypothesisSelector {
52 pub fn new(
54 predicate: KnowledgePredicate,
55 value: EvidenceValue,
56 minimum_posterior: Probability,
57 required_strength: RequiredStrength,
58 ) -> Self {
59 Self {
60 predicate,
61 value,
62 minimum_posterior,
63 required_strength,
64 }
65 }
66
67 pub fn predicate(&self) -> &KnowledgePredicate {
69 &self.predicate
70 }
71
72 pub fn value(&self) -> &EvidenceValue {
74 &self.value
75 }
76
77 pub fn minimum_posterior(&self) -> Probability {
79 self.minimum_posterior
80 }
81
82 pub fn required_strength(&self) -> RequiredStrength {
84 self.required_strength
85 }
86
87 pub(crate) fn select<'a>(&self, hypotheses: &'a [Hypothesis]) -> Option<&'a Hypothesis> {
88 let mut selected: Option<&Hypothesis> = None;
89 for hypothesis in hypotheses.iter().filter(|hypothesis| {
90 hypothesis.predicate() == &self.predicate
91 && hypothesis.value() == &self.value
92 && matches!(
93 hypothesis.state(),
94 HypothesisState::Supported | HypothesisState::Confirmed
95 )
96 && hypothesis.posterior() >= self.minimum_posterior
97 && matches!(
98 (self.required_strength, hypothesis.strength()),
99 (RequiredStrength::Any, _)
100 | (RequiredStrength::Strong, HypothesisStrength::Strong)
101 )
102 }) {
103 if selected.is_none_or(|current| hypothesis.posterior() > current.posterior()) {
104 selected = Some(hypothesis);
105 }
106 }
107 selected
108 }
109}
110
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
119#[serde(rename_all = "snake_case")]
120#[non_exhaustive]
121pub enum VerificationTarget {
122 #[default]
124 Motivation,
125 Distinct(HypothesisSelector),
128 KnowledgeOnly,
132}
133
134impl VerificationTarget {
135 fn is_motivation(&self) -> bool {
136 matches!(self, Self::Motivation)
137 }
138
139 pub(crate) fn resolve(
140 &self,
141 hypotheses: &[Hypothesis],
142 motivation_hypothesis_id: &str,
143 ) -> Option<ResolvedVerificationTarget> {
144 match self {
145 Self::Motivation => Some(ResolvedVerificationTarget::Hypothesis(
146 motivation_hypothesis_id.to_owned(),
147 )),
148 Self::Distinct(selector) => selector
149 .select(hypotheses)
150 .filter(|hypothesis| hypothesis.id() != motivation_hypothesis_id)
151 .map(|hypothesis| {
152 ResolvedVerificationTarget::Hypothesis(hypothesis.id().to_owned())
153 }),
154 Self::KnowledgeOnly => Some(ResolvedVerificationTarget::KnowledgeOnly),
155 }
156 }
157}
158
159#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
167#[serde(rename_all = "snake_case")]
168#[non_exhaustive]
169pub enum ResolvedVerificationTarget {
170 Hypothesis(String),
172 KnowledgeOnly,
174}
175
176impl ResolvedVerificationTarget {
177 pub fn hypothesis_id(&self) -> Option<&str> {
179 match self {
180 Self::Hypothesis(id) => Some(id),
181 Self::KnowledgeOnly => None,
182 }
183 }
184
185 pub fn applies_hypothesis_transition(&self) -> bool {
187 matches!(self, Self::Hypothesis(_))
188 }
189}
190
191#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
193pub struct AttackAction {
194 pub(super) id: String,
195 pub(super) executor: String,
196 #[serde(skip_serializing_if = "Option::is_none")]
197 pub(super) payload_strategy: Option<PayloadStrategyRef>,
198 pub(super) requirements: Expression,
199 pub(super) confidence_source: HypothesisSelector,
200 pub(super) gain: BenefitScore,
201 pub(super) cost: ActionCost,
202 pub(super) risk: RiskScore,
203 pub(super) prerequisites: BTreeSet<String>,
204 #[serde(default, skip_serializing_if = "VerificationTarget::is_motivation")]
205 pub(super) verification_target: VerificationTarget,
206 #[serde(
210 default,
211 rename = "payload_claim_policy_guard",
212 skip_serializing_if = "is_false"
213 )]
214 claim_policy_guard: bool,
215}
216
217impl AttackAction {
218 #[allow(clippy::too_many_arguments)]
220 pub fn new(
221 id: impl Into<String>,
222 executor: impl Into<String>,
223 requirements: Expression,
224 confidence_source: HypothesisSelector,
225 gain: BenefitScore,
226 cost: ActionCost,
227 risk: RiskScore,
228 prerequisites: BTreeSet<String>,
229 ) -> Result<Self, PlannerError> {
230 let id = non_empty(id, "action id")?;
231 let executor = non_empty(executor, "action executor")?;
232 for prerequisite in &prerequisites {
233 non_empty(prerequisite.clone(), "action prerequisite")?;
234 if prerequisite == &id {
235 return Err(PlannerError::SelfDependency {
236 action_id: id.clone(),
237 });
238 }
239 }
240 Ok(Self {
241 id,
242 executor,
243 payload_strategy: None,
244 requirements,
245 confidence_source,
246 gain,
247 cost,
248 risk,
249 prerequisites,
250 verification_target: VerificationTarget::Motivation,
251 claim_policy_guard: false,
252 })
253 }
254
255 pub fn with_verification_target(mut self, target: VerificationTarget) -> Self {
258 self.claim_policy_guard = !target.is_motivation();
259 self.verification_target = target;
260 self
261 }
262
263 pub fn verification_target(&self) -> &VerificationTarget {
265 &self.verification_target
266 }
267
268 pub fn id(&self) -> &str {
270 &self.id
271 }
272
273 pub fn executor(&self) -> &str {
275 &self.executor
276 }
277
278 pub fn with_payload_strategy(mut self, strategy: PayloadStrategyRef) -> Self {
280 self.payload_strategy = Some(strategy);
281 self
282 }
283
284 pub const fn payload_strategy(&self) -> Option<&PayloadStrategyRef> {
286 self.payload_strategy.as_ref()
287 }
288
289 pub fn requirements(&self) -> &Expression {
291 &self.requirements
292 }
293
294 pub fn confidence_source(&self) -> &HypothesisSelector {
296 &self.confidence_source
297 }
298
299 pub fn gain(&self) -> BenefitScore {
301 self.gain
302 }
303
304 pub fn cost(&self) -> ActionCost {
306 self.cost
307 }
308
309 pub fn risk(&self) -> RiskScore {
311 self.risk
312 }
313
314 pub fn prerequisites(&self) -> &BTreeSet<String> {
316 &self.prerequisites
317 }
318}
319
320impl<'de> Deserialize<'de> for AttackAction {
321 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
322 where
323 D: Deserializer<'de>,
324 {
325 #[derive(Deserialize)]
326 struct WireAction {
327 id: String,
328 executor: String,
329 #[serde(default)]
330 payload_strategy: Option<PayloadStrategyRef>,
331 requirements: Expression,
332 confidence_source: HypothesisSelector,
333 gain: BenefitScore,
334 cost: ActionCost,
335 risk: RiskScore,
336 prerequisites: BTreeSet<String>,
337 #[serde(default)]
338 verification_target: VerificationTarget,
339 #[serde(default)]
340 payload_claim_policy_guard: bool,
341 #[serde(flatten)]
342 extensions: BTreeMap<String, IgnoredAny>,
343 }
344
345 let wire = WireAction::deserialize(deserializer)?;
346 if wire
347 .extensions
348 .keys()
349 .any(|field| field.starts_with("payload_") || field.starts_with("verification_"))
350 {
351 return Err(serde::de::Error::custom("unknown reserved action field"));
352 }
353 if wire.payload_claim_policy_guard == wire.verification_target.is_motivation() {
354 return Err(serde::de::Error::custom(
355 "verification target compatibility guard is missing or inconsistent",
356 ));
357 }
358 let action = Self::new(
359 wire.id,
360 wire.executor,
361 wire.requirements,
362 wire.confidence_source,
363 wire.gain,
364 wire.cost,
365 wire.risk,
366 wire.prerequisites,
367 )
368 .map_err(serde::de::Error::custom)?;
369 let action = action.with_verification_target(wire.verification_target);
370 Ok(match wire.payload_strategy {
371 Some(strategy) => action.with_payload_strategy(strategy),
372 None => action,
373 })
374 }
375}
376
377#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
379pub struct PlanningContext {
380 pub(super) business_value: BenefitScore,
381 pub(super) budget: u64,
382 pub(super) maximum_risk: RiskScore,
383 pub(super) minimum_utility: UtilityScore,
384}
385
386impl PlanningContext {
387 pub fn new(business_value: BenefitScore, budget: u64, maximum_risk: RiskScore) -> Self {
389 Self {
390 business_value,
391 budget,
392 maximum_risk,
393 minimum_utility: UtilityScore::MIN_POSITIVE,
394 }
395 }
396
397 pub fn with_minimum_utility(mut self, minimum_utility: UtilityScore) -> Self {
399 self.minimum_utility = minimum_utility;
400 self
401 }
402
403 pub fn business_value(&self) -> BenefitScore {
405 self.business_value
406 }
407
408 pub fn budget(&self) -> u64 {
410 self.budget
411 }
412
413 pub fn maximum_risk(&self) -> RiskScore {
415 self.maximum_risk
416 }
417
418 pub fn minimum_utility(&self) -> UtilityScore {
420 self.minimum_utility
421 }
422}
423
424#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
426#[serde(tag = "reason", rename_all = "snake_case")]
427#[non_exhaustive]
428pub enum ExclusionReason {
429 PolicySuppressed,
431 DefenseSuppressed,
434 RequirementsNotMet,
436 NoEligibleHypothesis,
438 NoEligibleVerificationTarget,
441 RiskLimitExceeded {
443 actual: RiskScore,
445 maximum: RiskScore,
447 },
448 BelowMinimumUtility {
450 actual: UtilityScore,
452 minimum: UtilityScore,
454 },
455 DependencyUnavailable {
457 prerequisite: String,
459 },
460 BudgetExceeded {
462 required: u64,
464 remaining: u64,
466 },
467}
468
469#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
471pub struct ExcludedAction {
472 pub(super) action_id: String,
473 pub(super) reason: ExclusionReason,
474}
475
476impl ExcludedAction {
477 pub fn action_id(&self) -> &str {
479 &self.action_id
480 }
481
482 pub fn reason(&self) -> &ExclusionReason {
484 &self.reason
485 }
486}
487
488#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
490pub struct PlanStep {
491 pub(super) position: usize,
492 pub(super) action_id: String,
493 pub(super) executor: String,
494 #[serde(skip_serializing_if = "Option::is_none")]
495 pub(super) payload_strategy: Option<PayloadStrategyRef>,
496 pub(super) prerequisites: BTreeSet<String>,
497 pub(super) confidence_hypothesis_id: String,
498 #[serde(skip)]
499 pub(super) verification_target: ResolvedVerificationTarget,
500 pub(super) requirements: ExpressionEvaluation,
501 pub(super) utility: UtilityBreakdown,
502}
503
504impl PlanStep {
505 pub fn position(&self) -> usize {
507 self.position
508 }
509
510 pub fn action_id(&self) -> &str {
512 &self.action_id
513 }
514
515 pub fn executor(&self) -> &str {
517 &self.executor
518 }
519
520 pub const fn payload_strategy(&self) -> Option<&PayloadStrategyRef> {
522 self.payload_strategy.as_ref()
523 }
524
525 pub fn prerequisites(&self) -> &BTreeSet<String> {
527 &self.prerequisites
528 }
529
530 pub fn confidence_hypothesis_id(&self) -> &str {
532 &self.confidence_hypothesis_id
533 }
534
535 pub fn verification_target(&self) -> &ResolvedVerificationTarget {
537 &self.verification_target
538 }
539
540 pub fn requirements(&self) -> &ExpressionEvaluation {
542 &self.requirements
543 }
544
545 pub fn utility(&self) -> &UtilityBreakdown {
547 &self.utility
548 }
549}
550
551#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
553pub struct AttackPlan {
554 pub(super) subject: EntityId,
555 pub(super) context: PlanningContext,
556 pub(super) total_cost: u64,
557 pub(super) steps: Vec<PlanStep>,
558 pub(super) excluded: Vec<ExcludedAction>,
559}
560
561impl AttackPlan {
562 pub fn subject(&self) -> &EntityId {
564 &self.subject
565 }
566
567 pub fn context(&self) -> PlanningContext {
569 self.context
570 }
571
572 pub fn total_cost(&self) -> u64 {
574 self.total_cost
575 }
576
577 pub fn steps(&self) -> &[PlanStep] {
579 &self.steps
580 }
581
582 pub fn excluded(&self) -> &[ExcludedAction] {
584 &self.excluded
585 }
586
587 pub(crate) fn into_defense_filtered(
595 self,
596 defense_suppressed_actions: &BTreeSet<String>,
597 ) -> Self {
598 if defense_suppressed_actions.is_empty() {
599 return self;
600 }
601 let affects_step = self
602 .steps
603 .iter()
604 .any(|step| defense_suppressed_actions.contains(step.action_id()));
605 if !affects_step {
606 return self;
607 }
608
609 let mut retained_ids = BTreeSet::new();
610 let mut retained_steps = Vec::new();
611 let mut removed = BTreeMap::new();
612 for mut step in self.steps {
613 let reason = if defense_suppressed_actions.contains(step.action_id()) {
614 Some(ExclusionReason::DefenseSuppressed)
615 } else {
616 step.prerequisites
617 .iter()
618 .find(|prerequisite| !retained_ids.contains(*prerequisite))
619 .map(|prerequisite| ExclusionReason::DependencyUnavailable {
620 prerequisite: prerequisite.clone(),
621 })
622 };
623 if let Some(reason) = reason {
624 removed.insert(step.action_id.clone(), reason);
625 continue;
626 }
627 step.position = retained_steps.len();
628 retained_ids.insert(step.action_id.clone());
629 retained_steps.push(step);
630 }
631
632 let mut excluded: BTreeMap<String, ExclusionReason> = self
633 .excluded
634 .into_iter()
635 .map(|excluded| (excluded.action_id, excluded.reason))
636 .collect();
637 excluded.extend(removed);
638 let total_cost = retained_steps
639 .iter()
640 .map(|step| u64::from(step.utility.cost.units()))
641 .sum();
642
643 Self {
644 subject: self.subject,
645 context: self.context,
646 total_cost,
647 steps: retained_steps,
648 excluded: excluded
649 .into_iter()
650 .map(|(action_id, reason)| ExcludedAction { action_id, reason })
651 .collect(),
652 }
653 }
654}
655
656#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
658#[serde(rename_all = "snake_case")]
659#[non_exhaustive]
660pub enum PlannerWrite {
661 Inserted,
663 Unchanged,
665}