Skip to main content

termivar_scanner/planner/
model.rs

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/// Required qualitative strength for an action's confidence source.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case")]
34#[non_exhaustive]
35pub enum RequiredStrength {
36    /// A weak or strong supported hypothesis is acceptable.
37    Any,
38    /// Only a strong supported hypothesis is acceptable.
39    Strong,
40}
41
42/// Selects the Bayesian hypothesis that supplies action confidence.
43#[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    /// Creates an exact claim selector and minimum confidence threshold.
53    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    /// Returns the selected claim predicate.
68    pub fn predicate(&self) -> &KnowledgePredicate {
69        &self.predicate
70    }
71
72    /// Returns the selected claim value.
73    pub fn value(&self) -> &EvidenceValue {
74        &self.value
75    }
76
77    /// Returns the minimum accepted posterior.
78    pub fn minimum_posterior(&self) -> Probability {
79        self.minimum_posterior
80    }
81
82    /// Returns the required rule-assigned strength.
83    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/// What a conclusive outcome may transition, kept distinct from the confidence
112/// hypothesis that motivated planning the action.
113///
114/// The default, [`Self::Motivation`], preserves the historical behavior where a
115/// `Success` confirms the same hypothesis the planner used for confidence. The
116/// other variants let an action's *justification for running* differ from the
117/// *claim its result verifies* — the core of claim discipline.
118#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
119#[serde(rename_all = "snake_case")]
120#[non_exhaustive]
121pub enum VerificationTarget {
122    /// Confirm the confidence (motivation) hypothesis. Historical default.
123    #[default]
124    Motivation,
125    /// Confirm a distinct, already-supported result hypothesis instead of the
126    /// motivation hypothesis.
127    Distinct(HypothesisSelector),
128    /// Record the outcome (which may be `Success`) without transitioning any
129    /// hypothesis state. "The action's objective was achieved" is not "the
130    /// motivating hypothesis was conclusively verified".
131    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/// Plan-time resolution of the claim an action outcome may transition.
160///
161/// The planner resolves both [`VerificationTarget::Motivation`] and
162/// [`VerificationTarget::Distinct`] to an existing hypothesis identity.
163/// [`Self::KnowledgeOnly`] deliberately resolves to no transition target; the
164/// motivating hypothesis remains available separately on [`PlanStep`] for
165/// utility provenance and audit correlation.
166#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
167#[serde(rename_all = "snake_case")]
168#[non_exhaustive]
169pub enum ResolvedVerificationTarget {
170    /// A conclusive outcome may transition this pre-existing hypothesis.
171    Hypothesis(String),
172    /// The action outcome is auditable but cannot transition a hypothesis.
173    KnowledgeOnly,
174}
175
176impl ResolvedVerificationTarget {
177    /// Returns the hypothesis a conclusive outcome may transition, if any.
178    pub fn hypothesis_id(&self) -> Option<&str> {
179        match self {
180            Self::Hypothesis(id) => Some(id),
181            Self::KnowledgeOnly => None,
182        }
183    }
184
185    /// Returns whether this target authorizes a hypothesis-state transition.
186    pub fn applies_hypothesis_transition(&self) -> bool {
187        matches!(self, Self::Hypothesis(_))
188    }
189}
190
191/// Declarative executable candidate considered by the planner.
192#[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    // This sentinel deliberately uses the only namespace that legacy readers
207    // already reject. It prevents an older binary from silently discarding a
208    // non-default verification target and reconstructing it as Motivation.
209    #[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    /// Creates a validated action without executing or resolving dependencies.
219    #[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    /// Sets what a conclusive outcome may transition. Defaults to
256    /// [`VerificationTarget::Motivation`] (confirm the confidence hypothesis).
257    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    /// Returns what a conclusive outcome may transition.
264    pub fn verification_target(&self) -> &VerificationTarget {
265        &self.verification_target
266    }
267
268    /// Returns the stable action identity.
269    pub fn id(&self) -> &str {
270        &self.id
271    }
272
273    /// Returns the plugin or module executor identity.
274    pub fn executor(&self) -> &str {
275        &self.executor
276    }
277
278    /// Selects a versioned payload strategy without exposing its implementation.
279    pub fn with_payload_strategy(mut self, strategy: PayloadStrategyRef) -> Self {
280        self.payload_strategy = Some(strategy);
281        self
282    }
283
284    /// Returns the planner-selected payload strategy, when this action uses one.
285    pub const fn payload_strategy(&self) -> Option<&PayloadStrategyRef> {
286        self.payload_strategy.as_ref()
287    }
288
289    /// Returns the rule expression gating this action.
290    pub fn requirements(&self) -> &Expression {
291        &self.requirements
292    }
293
294    /// Returns the hypothesis selector supplying Bayesian confidence.
295    pub fn confidence_source(&self) -> &HypothesisSelector {
296        &self.confidence_source
297    }
298
299    /// Returns expected gain.
300    pub fn gain(&self) -> BenefitScore {
301        self.gain
302    }
303
304    /// Returns estimated execution cost.
305    pub fn cost(&self) -> ActionCost {
306        self.cost
307    }
308
309    /// Returns operational risk.
310    pub fn risk(&self) -> RiskScore {
311        self.risk
312    }
313
314    /// Returns prerequisite action identities in stable order.
315    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/// Inputs shared by every candidate in one planning cycle.
378#[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    /// Creates a planning context requiring positive utility.
388    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    /// Sets the minimum utility required for a candidate and its dependencies.
398    pub fn with_minimum_utility(mut self, minimum_utility: UtilityScore) -> Self {
399        self.minimum_utility = minimum_utility;
400        self
401    }
402
403    /// Returns target business value.
404    pub fn business_value(&self) -> BenefitScore {
405        self.business_value
406    }
407
408    /// Returns the maximum total action cost.
409    pub fn budget(&self) -> u64 {
410        self.budget
411    }
412
413    /// Returns the maximum accepted action risk.
414    pub fn maximum_risk(&self) -> RiskScore {
415        self.maximum_risk
416    }
417
418    /// Returns the minimum accepted utility.
419    pub fn minimum_utility(&self) -> UtilityScore {
420        self.minimum_utility
421    }
422}
423
424/// Reason a registered action was not selected.
425#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
426#[serde(tag = "reason", rename_all = "snake_case")]
427#[non_exhaustive]
428pub enum ExclusionReason {
429    /// An adaptive or operator policy suppressed this action.
430    PolicySuppressed,
431    /// Observed defensive posture suppressed this action, distinct from an
432    /// adaptive or operator policy suppression so the two never conflate.
433    DefenseSuppressed,
434    /// The action's expression did not match the snapshot.
435    RequirementsNotMet,
436    /// No supported hypothesis met the selector threshold.
437    NoEligibleHypothesis,
438    /// A distinct verification target did not resolve to a pre-existing,
439    /// supported hypothesis in the planning snapshot.
440    NoEligibleVerificationTarget,
441    /// Action risk exceeded the planning context limit.
442    RiskLimitExceeded {
443        /// Action risk.
444        actual: RiskScore,
445        /// Maximum accepted risk.
446        maximum: RiskScore,
447    },
448    /// Calculated utility was below the context threshold.
449    BelowMinimumUtility {
450        /// Calculated action utility.
451        actual: UtilityScore,
452        /// Minimum accepted utility.
453        minimum: UtilityScore,
454    },
455    /// A prerequisite was not eligible for selection.
456    DependencyUnavailable {
457        /// Unavailable prerequisite identity.
458        prerequisite: String,
459    },
460    /// The action and its unselected dependencies did not fit the budget.
461    BudgetExceeded {
462        /// Additional cost needed to select the dependency closure.
463        required: u64,
464        /// Budget remaining when the action was considered.
465        remaining: u64,
466    },
467}
468
469/// Explainable record for a candidate omitted from the plan.
470#[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    /// Returns the omitted action identity.
478    pub fn action_id(&self) -> &str {
479        &self.action_id
480    }
481
482    /// Returns why the planner omitted the action.
483    pub fn reason(&self) -> &ExclusionReason {
484        &self.reason
485    }
486}
487
488/// One dependency-safe step selected for execution.
489#[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    /// Returns the zero-based execution position.
506    pub fn position(&self) -> usize {
507        self.position
508    }
509
510    /// Returns the selected action identity.
511    pub fn action_id(&self) -> &str {
512        &self.action_id
513    }
514
515    /// Returns the plugin or module executor identity.
516    pub fn executor(&self) -> &str {
517        &self.executor
518    }
519
520    /// Returns the exact payload strategy revision selected with this action.
521    pub const fn payload_strategy(&self) -> Option<&PayloadStrategyRef> {
522        self.payload_strategy.as_ref()
523    }
524
525    /// Returns prerequisite action identities.
526    pub fn prerequisites(&self) -> &BTreeSet<String> {
527        &self.prerequisites
528    }
529
530    /// Returns the hypothesis selected as the confidence source.
531    pub fn confidence_hypothesis_id(&self) -> &str {
532        &self.confidence_hypothesis_id
533    }
534
535    /// Returns the separately resolved claim this step may transition.
536    pub fn verification_target(&self) -> &ResolvedVerificationTarget {
537        &self.verification_target
538    }
539
540    /// Returns the requirement evaluation trace.
541    pub fn requirements(&self) -> &ExpressionEvaluation {
542        &self.requirements
543    }
544
545    /// Returns the complete utility calculation.
546    pub fn utility(&self) -> &UtilityBreakdown {
547        &self.utility
548    }
549}
550
551/// Immutable output of one deterministic planning cycle.
552#[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    /// Returns the planned subject.
563    pub fn subject(&self) -> &EntityId {
564        &self.subject
565    }
566
567    /// Returns the context used to score and constrain candidates.
568    pub fn context(&self) -> PlanningContext {
569        self.context
570    }
571
572    /// Returns the sum of selected action costs.
573    pub fn total_cost(&self) -> u64 {
574        self.total_cost
575    }
576
577    /// Returns selected actions in dependency-safe execution order.
578    pub fn steps(&self) -> &[PlanStep] {
579        &self.steps
580    }
581
582    /// Returns omitted actions in stable action-ID order.
583    pub fn excluded(&self) -> &[ExcludedAction] {
584        &self.excluded
585    }
586
587    /// Removes defense-suppressed steps from this already-authorized plan.
588    ///
589    /// This is deliberately a filter, not a second planning pass. Retained
590    /// steps preserve their exact metadata and relative order; removing a
591    /// prerequisite also removes every dependent step so the result remains a
592    /// dependency-safe subsequence. Candidates excluded from the baseline can
593    /// never enter through budget freed by defense suppression.
594    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/// Result of registering an action identity.
657#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
658#[serde(rename_all = "snake_case")]
659#[non_exhaustive]
660pub enum PlannerWrite {
661    /// A new action was registered.
662    Inserted,
663    /// The identical action was already registered.
664    Unchanged,
665}