1use std::collections::{BTreeMap, BTreeSet};
2
3use termivar_core::EntityId;
4
5use crate::{
6 knowledge::{KnowledgeBase, KnowledgeSnapshot},
7 rules::ExpressionEvaluation,
8};
9
10use crate::planner::{
11 model::{
12 AttackAction, AttackPlan, ExcludedAction, ExclusionReason, PlanStep, PlannerWrite,
13 PlanningContext, ResolvedVerificationTarget,
14 },
15 policy::{ActionSuppressionContext, ScheduledActionAuthorizationError},
16 scoring::UtilityBreakdown,
17 PlannerError,
18};
19
20#[derive(Debug, Clone)]
21struct EligibleCandidate {
22 action: AttackAction,
23 confidence_hypothesis_id: String,
24 verification_target: ResolvedVerificationTarget,
25 requirements: ExpressionEvaluation,
26 utility: UtilityBreakdown,
27}
28
29type CandidateEligibility = Result<EligibleCandidate, ExclusionReason>;
30
31#[derive(Debug, Clone, Default)]
33pub struct AttackPlanner {
34 actions: BTreeMap<String, AttackAction>,
35}
36
37impl AttackPlanner {
38 pub fn new() -> Self {
40 Self::default()
41 }
42
43 pub fn register(&mut self, action: AttackAction) -> Result<PlannerWrite, PlannerError> {
45 if let Some(existing) = self.actions.get(action.id()) {
46 return if existing == &action {
47 Ok(PlannerWrite::Unchanged)
48 } else {
49 Err(PlannerError::ActionIdentityConflict {
50 id: action.id().to_owned(),
51 })
52 };
53 }
54 self.actions.insert(action.id().to_owned(), action);
55 Ok(PlannerWrite::Inserted)
56 }
57
58 pub fn action(&self, action_id: &str) -> Option<&AttackAction> {
60 self.actions.get(action_id)
61 }
62
63 pub fn len(&self) -> usize {
65 self.actions.len()
66 }
67
68 pub fn is_empty(&self) -> bool {
70 self.actions.is_empty()
71 }
72
73 pub fn plan(
75 &self,
76 knowledge: &KnowledgeBase,
77 subject: &EntityId,
78 context: PlanningContext,
79 ) -> Result<AttackPlan, PlannerError> {
80 let snapshot = knowledge.snapshot_for_subject(subject);
81 self.plan_snapshot(&snapshot, context)
82 }
83
84 pub fn plan_with_suppressed(
86 &self,
87 knowledge: &KnowledgeBase,
88 subject: &EntityId,
89 context: PlanningContext,
90 suppressed_actions: &BTreeSet<String>,
91 ) -> Result<AttackPlan, PlannerError> {
92 let snapshot = knowledge.snapshot_for_subject(subject);
93 self.plan_snapshot_with_suppressed(&snapshot, context, suppressed_actions)
94 }
95
96 pub fn plan_snapshot(
98 &self,
99 snapshot: &KnowledgeSnapshot,
100 context: PlanningContext,
101 ) -> Result<AttackPlan, PlannerError> {
102 self.plan_snapshot_with_suppressed(snapshot, context, &BTreeSet::new())
103 }
104
105 pub fn plan_snapshot_with_suppressed(
107 &self,
108 snapshot: &KnowledgeSnapshot,
109 context: PlanningContext,
110 suppressed_actions: &BTreeSet<String>,
111 ) -> Result<AttackPlan, PlannerError> {
112 self.plan_snapshot_with_action_suppressions(
113 snapshot,
114 context,
115 &ActionSuppressionContext::policy_only(suppressed_actions),
116 )
117 }
118
119 pub fn plan_snapshot_with_defense_suppressed(
128 &self,
129 snapshot: &KnowledgeSnapshot,
130 context: PlanningContext,
131 policy_suppressed_actions: &BTreeSet<String>,
132 defense_suppressed_actions: &BTreeSet<String>,
133 ) -> Result<AttackPlan, PlannerError> {
134 self.plan_snapshot_with_action_suppressions(
135 snapshot,
136 context,
137 &ActionSuppressionContext::new(
138 policy_suppressed_actions.clone(),
139 defense_suppressed_actions.clone(),
140 ),
141 )
142 }
143
144 pub(crate) fn plan_snapshot_with_action_suppressions(
145 &self,
146 snapshot: &KnowledgeSnapshot,
147 context: PlanningContext,
148 suppressions: &ActionSuppressionContext,
149 ) -> Result<AttackPlan, PlannerError> {
150 self.plan_snapshot_with_action_suppressions_and_baseline(snapshot, context, suppressions)
151 .map(|(_, filtered)| filtered)
152 }
153
154 pub(crate) fn plan_snapshot_with_action_suppressions_and_baseline(
155 &self,
156 snapshot: &KnowledgeSnapshot,
157 context: PlanningContext,
158 suppressions: &ActionSuppressionContext,
159 ) -> Result<(AttackPlan, AttackPlan), PlannerError> {
160 let baseline = self.plan_snapshot_with_policy_suppressed(
161 snapshot,
162 context,
163 suppressions.policy_suppressed_actions(),
164 )?;
165 let filtered = baseline
166 .clone()
167 .into_defense_filtered(suppressions.defense_suppressed_actions());
168 Ok((baseline, filtered))
169 }
170
171 fn plan_snapshot_with_policy_suppressed(
172 &self,
173 snapshot: &KnowledgeSnapshot,
174 context: PlanningContext,
175 policy_suppressed_actions: &BTreeSet<String>,
176 ) -> Result<AttackPlan, PlannerError> {
177 self.validate_dependencies()?;
178
179 let mut eligible = BTreeMap::<String, EligibleCandidate>::new();
180 let mut exclusions = BTreeMap::<String, ExclusionReason>::new();
181 for action in self.actions.values() {
182 let suppression = policy_suppressed_actions
183 .contains(action.id())
184 .then_some(ExclusionReason::PolicySuppressed);
185 match evaluate_candidate(action, snapshot, context, suppression)? {
186 Ok(candidate) => {
187 eligible.insert(action.id.clone(), candidate);
188 },
189 Err(reason) => {
190 exclusions.insert(action.id.clone(), reason);
191 },
192 }
193 }
194
195 let mut ranked: Vec<String> = eligible.keys().cloned().collect();
196 ranked.sort_by(|left, right| {
197 eligible[right]
198 .utility
199 .score
200 .cmp(&eligible[left].utility.score)
201 .then_with(|| left.cmp(right))
202 });
203
204 let mut selected = BTreeSet::<String>::new();
205 let mut ordered = Vec::<String>::new();
206 let mut total_cost = 0_u64;
207 for action_id in ranked {
208 if selected.contains(&action_id) {
209 continue;
210 }
211 let mut closure = Vec::new();
212 let mut visiting = BTreeSet::new();
213 if let Some(unavailable) = build_eligible_closure(
214 &action_id,
215 &eligible,
216 &selected,
217 &mut visiting,
218 &mut closure,
219 ) {
220 exclusions.insert(
221 action_id.clone(),
222 ExclusionReason::DependencyUnavailable {
223 prerequisite: unavailable,
224 },
225 );
226 continue;
227 }
228 let required = closure.iter().fold(0_u64, |sum, id| {
229 sum + u64::from(eligible[id].action.cost.units())
230 });
231 let remaining = context.budget.saturating_sub(total_cost);
232 if required > remaining {
233 exclusions.insert(
234 action_id,
235 ExclusionReason::BudgetExceeded {
236 required,
237 remaining,
238 },
239 );
240 continue;
241 }
242 for id in closure {
243 if selected.insert(id.clone()) {
244 total_cost += u64::from(eligible[&id].action.cost.units());
245 ordered.push(id);
246 }
247 }
248 }
249
250 let steps = ordered
251 .into_iter()
252 .enumerate()
253 .map(|(position, id)| plan_step(position, &eligible[&id]))
254 .collect();
255 let mut excluded = Vec::new();
256 for id in self.actions.keys().filter(|id| !selected.contains(*id)) {
257 let reason = exclusions
258 .remove(id)
259 .ok_or_else(|| PlannerError::IncompleteDecision {
260 action_id: id.clone(),
261 })?;
262 excluded.push(ExcludedAction {
263 action_id: id.clone(),
264 reason,
265 });
266 }
267
268 Ok(AttackPlan {
269 subject: snapshot.subject().clone(),
270 context,
271 total_cost,
272 steps,
273 excluded,
274 })
275 }
276
277 #[cfg(test)]
289 pub(crate) fn authorize_scheduled_action(
290 &self,
291 snapshot: &KnowledgeSnapshot,
292 context: PlanningContext,
293 policy_suppressed_actions: &BTreeSet<String>,
294 action_id: &str,
295 ) -> Result<PlanStep, ScheduledActionAuthorizationError> {
296 self.authorize_scheduled_action_with_context(
297 snapshot,
298 context,
299 &ActionSuppressionContext::policy_only(policy_suppressed_actions),
300 action_id,
301 )
302 }
303
304 pub(crate) fn authorize_scheduled_action_with_context(
305 &self,
306 snapshot: &KnowledgeSnapshot,
307 context: PlanningContext,
308 suppressions: &ActionSuppressionContext,
309 action_id: &str,
310 ) -> Result<PlanStep, ScheduledActionAuthorizationError> {
311 self.validate_dependencies()?;
312 let action = self.actions.get(action_id).ok_or_else(|| {
313 ScheduledActionAuthorizationError::Unregistered {
314 action_id: action_id.to_owned(),
315 }
316 })?;
317 let suppression = if suppressions
318 .defense_suppressed_actions()
319 .contains(action_id)
320 {
321 Some(ExclusionReason::DefenseSuppressed)
322 } else if suppressions.policy_suppressed_actions().contains(action_id) {
323 Some(ExclusionReason::PolicySuppressed)
324 } else {
325 None
326 };
327 let candidate = match evaluate_candidate(action, snapshot, context, suppression)? {
328 Ok(candidate) => candidate,
329 Err(reason) => {
330 return Err(ScheduledActionAuthorizationError::Excluded {
331 action_id: action_id.to_owned(),
332 reason,
333 })
334 },
335 };
336 if !candidate.action.prerequisites.is_empty() {
337 return Err(ScheduledActionAuthorizationError::HasPrerequisites {
338 action_id: action_id.to_owned(),
339 });
340 }
341 let required = u64::from(candidate.action.cost.units());
342 if required > context.budget {
343 return Err(ScheduledActionAuthorizationError::Excluded {
344 action_id: action_id.to_owned(),
345 reason: ExclusionReason::BudgetExceeded {
346 required,
347 remaining: context.budget,
348 },
349 });
350 }
351 Ok(plan_step(0, &candidate))
352 }
353
354 fn validate_dependencies(&self) -> Result<(), PlannerError> {
355 for action in self.actions.values() {
356 for prerequisite in &action.prerequisites {
357 if !self.actions.contains_key(prerequisite) {
358 return Err(PlannerError::UnknownPrerequisite {
359 action_id: action.id.clone(),
360 prerequisite: prerequisite.clone(),
361 });
362 }
363 }
364 }
365 let mut visiting = BTreeSet::new();
366 let mut visited = BTreeSet::new();
367 for action_id in self.actions.keys() {
368 visit_dependency(action_id, &self.actions, &mut visiting, &mut visited)?;
369 }
370 Ok(())
371 }
372}
373
374fn evaluate_candidate(
375 action: &AttackAction,
376 snapshot: &KnowledgeSnapshot,
377 context: PlanningContext,
378 suppression: Option<ExclusionReason>,
379) -> Result<CandidateEligibility, PlannerError> {
380 if let Some(reason) = suppression {
381 return Ok(Err(reason));
382 }
383 let requirements = action.requirements.evaluate(snapshot)?;
384 if !requirements.matched() {
385 return Ok(Err(ExclusionReason::RequirementsNotMet));
386 }
387 if action.risk > context.maximum_risk {
388 return Ok(Err(ExclusionReason::RiskLimitExceeded {
389 actual: action.risk,
390 maximum: context.maximum_risk,
391 }));
392 }
393 let Some(hypothesis) = action.confidence_source.select(snapshot.hypotheses()) else {
394 return Ok(Err(ExclusionReason::NoEligibleHypothesis));
395 };
396 let Some(verification_target) = action
397 .verification_target
398 .resolve(snapshot.hypotheses(), hypothesis.id())
399 else {
400 return Ok(Err(ExclusionReason::NoEligibleVerificationTarget));
401 };
402 let utility = UtilityBreakdown::calculate(
403 action.gain,
404 hypothesis.posterior(),
405 context.business_value,
406 action.cost,
407 action.risk,
408 );
409 if utility.score < context.minimum_utility {
410 return Ok(Err(ExclusionReason::BelowMinimumUtility {
411 actual: utility.score,
412 minimum: context.minimum_utility,
413 }));
414 }
415 Ok(Ok(EligibleCandidate {
416 action: action.clone(),
417 confidence_hypothesis_id: hypothesis.id().to_owned(),
418 verification_target,
419 requirements,
420 utility,
421 }))
422}
423
424fn plan_step(position: usize, candidate: &EligibleCandidate) -> PlanStep {
425 PlanStep {
426 position,
427 action_id: candidate.action.id.clone(),
428 executor: candidate.action.executor.clone(),
429 payload_strategy: candidate.action.payload_strategy.clone(),
430 prerequisites: candidate.action.prerequisites.clone(),
431 confidence_hypothesis_id: candidate.confidence_hypothesis_id.clone(),
432 verification_target: candidate.verification_target.clone(),
433 requirements: candidate.requirements.clone(),
434 utility: candidate.utility,
435 }
436}
437
438fn visit_dependency(
439 action_id: &str,
440 actions: &BTreeMap<String, AttackAction>,
441 visiting: &mut BTreeSet<String>,
442 visited: &mut BTreeSet<String>,
443) -> Result<(), PlannerError> {
444 if visited.contains(action_id) {
445 return Ok(());
446 }
447 if !visiting.insert(action_id.to_owned()) {
448 return Err(PlannerError::DependencyCycle {
449 action_id: action_id.to_owned(),
450 });
451 }
452 for prerequisite in &actions[action_id].prerequisites {
453 visit_dependency(prerequisite, actions, visiting, visited)?;
454 }
455 visiting.remove(action_id);
456 visited.insert(action_id.to_owned());
457 Ok(())
458}
459
460fn build_eligible_closure(
461 action_id: &str,
462 eligible: &BTreeMap<String, EligibleCandidate>,
463 selected: &BTreeSet<String>,
464 visiting: &mut BTreeSet<String>,
465 ordered: &mut Vec<String>,
466) -> Option<String> {
467 if selected.contains(action_id) || ordered.iter().any(|id| id == action_id) {
468 return None;
469 }
470 let Some(candidate) = eligible.get(action_id) else {
471 return Some(action_id.to_owned());
472 };
473 visiting.insert(action_id.to_owned());
474 for prerequisite in &candidate.action.prerequisites {
475 if !eligible.contains_key(prerequisite) {
476 return Some(prerequisite.clone());
477 }
478 if !visiting.contains(prerequisite) {
479 if let Some(unavailable) =
480 build_eligible_closure(prerequisite, eligible, selected, visiting, ordered)
481 {
482 return Some(unavailable);
483 }
484 }
485 }
486 visiting.remove(action_id);
487 ordered.push(action_id.to_owned());
488 None
489}