Skip to main content

forge_pilot/
orient.rs

1//! Orient phase of the OODA loop.
2//!
3//! Extracts verification targets from an observation snapshot, scores
4//! them by urgency, and selects an execution plan for each candidate.
5
6use crate::act::{AdvisoryPlan, PlanKind};
7use crate::config::LoopConfig;
8use crate::history::PilotHistory;
9use crate::observe::{Observation, ObservationDisposition};
10use crate::targets::TargetKind;
11use crate::types::TargetNormalization;
12use std::cmp::Ordering;
13use std::collections::BTreeSet;
14
15/// A scored verification target with its selected execution plan.
16#[derive(Debug, Clone)]
17pub struct TargetCandidate {
18    pub target: TargetKind,
19    pub stable_key: String,
20    pub urgency: f64,
21    pub rationale: String,
22    pub check_hints: Vec<String>,
23    pub plan: PlanKind,
24    pub normalization: TargetNormalization,
25}
26
27/// Extracts target kinds worth considering from an observation snapshot.
28pub fn extract_targets(observation: &Observation, config: &LoopConfig) -> Vec<TargetKind> {
29    let mut targets = Vec::new();
30
31    if let Some(scheduled) = &observation.scheduled {
32        for syndrome in &scheduled.execution.syndromes {
33            targets.push(TargetKind::ActiveSyndrome {
34                signature: syndrome.signature.clone(),
35            });
36        }
37
38        for belief in &scheduled.execution.node_beliefs {
39            if belief.node_id.starts_with("nuisance:") {
40                continue;
41            }
42            if belief.belief_micros < 900_000 {
43                targets.push(TargetKind::FragileNode {
44                    node_id: belief.node_id.clone(),
45                    belief_micros: belief.belief_micros,
46                });
47            }
48        }
49    }
50
51    if let Some(compiled) = &observation.compiled {
52        for marker in &compiled.degradations {
53            if matches!(
54                marker,
55                constraint_compiler::ConstraintDegradation::ThinExport
56            ) {
57                targets.push(TargetKind::ThinExport {
58                    marker: "thin_export".into(),
59                });
60            }
61        }
62    }
63
64    for degradation in &observation.degradations {
65        if degradation.kind == "thin_export" {
66            targets.push(TargetKind::ThinExport {
67                marker: degradation.detail.clone(),
68            });
69        }
70        if degradation.kind == "scope_stale" {
71            targets.push(TargetKind::ScopeStale {
72                last_import_at: observation
73                    .import_log
74                    .as_ref()
75                    .map(|row| row.imported_at.clone()),
76            });
77        }
78    }
79
80    if matches!(
81        observation.status.disposition,
82        ObservationDisposition::ImportRequired
83    ) {
84        targets.push(TargetKind::ScopeStale {
85            last_import_at: None,
86        });
87    }
88
89    for claim in &observation.claim_versions {
90        if claim.claim_state == "pending_review" {
91            targets.push(TargetKind::UnverifiedClaimVersion {
92                claim_version_id: claim.claim_version_id.clone(),
93            });
94        }
95        if let Some(supersedes_claim_version_id) = &claim.supersedes_claim_version_id {
96            targets.push(TargetKind::SupersessionVerification {
97                claim_version_id: claim.claim_version_id.clone(),
98                supersedes_claim_version_id: supersedes_claim_version_id.clone(),
99            });
100        }
101    }
102
103    if let Some(explanation) = &observation.explanation {
104        if !matches!(
105            explanation.causal_refutation_outcome.as_str(),
106            "flip_witness" | "supported"
107        ) {
108            let promoted_claim_version_id = observation
109                .claim_versions
110                .iter()
111                .find(|claim| {
112                    claim
113                        .metadata
114                        .as_ref()
115                        .and_then(|metadata| metadata.get("promotion_state"))
116                        .and_then(|promotion_state| promotion_state.get("state"))
117                        .and_then(|state| state.as_str())
118                        == Some("promoted")
119                })
120                .map(|claim| claim.claim_version_id.clone());
121            for node_id in &explanation.witness_node_ids {
122                targets.push(TargetKind::RefutationGap {
123                    target_node_id: node_id.clone(),
124                    claim_version_id: promoted_claim_version_id.clone(),
125                });
126            }
127        }
128        for marker in &explanation.calibration_caveats {
129            targets.push(TargetKind::CalibrationCaveat {
130                marker: marker.clone(),
131            });
132        }
133    }
134
135    let comparability_versions = observation
136        .recent_imports
137        .iter()
138        .filter_map(|row| row.comparability_snapshot_version.clone())
139        .collect::<BTreeSet<_>>();
140    if comparability_versions.len() > 1 {
141        targets.push(TargetKind::ComparabilityDrift {
142            detail: comparability_versions
143                .into_iter()
144                .collect::<Vec<_>>()
145                .join(","),
146        });
147    }
148
149    dedupe_targets(targets, config)
150}
151
152/// Scores concrete target candidates from an observation and pilot history.
153pub fn score_targets(
154    observation: &Observation,
155    history: &PilotHistory,
156    config: &LoopConfig,
157) -> Vec<TargetCandidate> {
158    let mut candidates = extract_targets(observation, config)
159        .into_iter()
160        .map(|target| {
161            let stable_key = target.stable_key();
162            let prior_attempts = history.retry_count(&stable_key);
163            let plan = choose_plan(observation, config, &target);
164            let mut urgency = score_target(observation, &target, prior_attempts);
165            if matches!(plan, PlanKind::PairedPatch { .. }) {
166                // An explicit patch seed is an operator-selected execution path and should
167                // outrank generic oracle fallback candidates for the same observation.
168                urgency = 1.1;
169            }
170            TargetCandidate {
171                rationale: build_rationale(observation, &target, prior_attempts),
172                check_hints: build_check_hints(&target),
173                normalization: normalize_target(observation, &target),
174                target,
175                stable_key,
176                urgency,
177                plan,
178            }
179        })
180        .collect::<Vec<_>>();
181
182    candidates.sort_by(compare_candidates);
183    candidates
184}
185
186fn normalize_target(observation: &Observation, target: &TargetKind) -> TargetNormalization {
187    TargetNormalization {
188        stable_target_key: target.stable_key(),
189        canonical_case_class: target.canonical_case_class(),
190        bounded_region_digest: bounded_region_digest(observation, target),
191        required_artifact_families: target.required_artifact_families(),
192        budget_class: target.budget_class(),
193        comparability_required: matches!(target, TargetKind::ComparabilityDrift { .. }),
194        nuisance_sensitive: observation
195            .compiled
196            .as_ref()
197            .map(|compiled| {
198                compiled
199                    .nodes
200                    .iter()
201                    .any(|node| node.kind == "nuisance_state")
202            })
203            .unwrap_or(false),
204        missing_falsifier: matches!(
205            target,
206            TargetKind::RefutationGap {
207                claim_version_id: None,
208                ..
209            }
210        ),
211    }
212}
213
214// LIB-LOW-001: replaced DefaultHasher with blake3 for deterministic digest
215fn bounded_region_digest(observation: &Observation, target: &TargetKind) -> String {
216    let mut hasher = blake3::Hasher::new();
217    hasher.update(observation.scope_key.namespace.as_bytes());
218    if let Some(ref domain) = observation.scope_key.domain {
219        hasher.update(domain.as_bytes());
220    }
221    hasher.update(target.stable_key().as_bytes());
222    if let Some(ref row) = observation.import_log {
223        hasher.update(row.imported_at.as_bytes());
224    }
225    for claim in &observation.claim_versions {
226        hasher.update(claim.claim_version_id.as_str().as_bytes());
227    }
228    format!("region:{}", &hasher.finalize().to_hex()[..16])
229}
230
231fn dedupe_targets(targets: Vec<TargetKind>, _config: &LoopConfig) -> Vec<TargetKind> {
232    let mut seen = BTreeSet::new();
233    let mut deduped = Vec::new();
234    for target in targets {
235        let key = target.stable_key();
236        if seen.insert(key) {
237            deduped.push(target);
238        }
239    }
240    deduped
241}
242
243fn score_target(observation: &Observation, target: &TargetKind, prior_attempts: u32) -> f64 {
244    let mut urgency = match target {
245        TargetKind::ActiveSyndrome { .. } => 0.95,
246        TargetKind::FragileNode { belief_micros, .. } => {
247            1.0 - (*belief_micros as f64 / 1_000_000.0)
248        }
249        TargetKind::ThinExport { .. } => 0.85,
250        TargetKind::UnverifiedClaimVersion { .. } => 0.75,
251        TargetKind::RefutationGap {
252            claim_version_id: Some(_),
253            ..
254        } => 0.92,
255        TargetKind::RefutationGap { .. } => 0.72,
256        TargetKind::SupersessionVerification { .. } => 0.68,
257        TargetKind::ComparabilityDrift { .. } => 0.60,
258        TargetKind::CalibrationCaveat { .. } => 0.50,
259        TargetKind::ScopeStale { last_import_at } => {
260            if last_import_at.is_none() {
261                0.40
262            } else {
263                0.25
264            }
265        }
266    };
267
268    if observation
269        .risk_gate
270        .as_ref()
271        .is_some_and(|risk_gate| risk_gate.status == "blocked")
272    {
273        urgency += 0.05;
274    }
275    if matches!(target, TargetKind::ThinExport { .. }) && observation.thin_export_active() {
276        urgency += 0.05;
277    }
278    if observation.explanation.as_ref().is_some_and(|explanation| {
279        explanation.residual_count > 0 && explanation.certificate_count == 0
280    }) {
281        urgency += 0.03;
282    }
283    if prior_attempts > 0 {
284        urgency -= 0.5_f64.powi(prior_attempts as i32);
285    }
286    urgency.clamp(0.0, 1.0)
287}
288
289fn compare_candidates(left: &TargetCandidate, right: &TargetCandidate) -> Ordering {
290    right
291        .urgency
292        .partial_cmp(&left.urgency)
293        .unwrap_or(Ordering::Equal)
294        .then_with(|| left.target.priority().cmp(&right.target.priority()))
295        .then_with(|| left.stable_key.cmp(&right.stable_key))
296}
297
298fn choose_plan(observation: &Observation, config: &LoopConfig, target: &TargetKind) -> PlanKind {
299    let stable_key = target.stable_key();
300    if let Some(seed) = config.patch_seed_for(&stable_key) {
301        return PlanKind::PairedPatch {
302            fixture_path: seed.fixture_path.clone(),
303            patch: seed.patch.clone(),
304            experiment_config: seed.experiment_config.clone(),
305            description: if seed.description.is_empty() {
306                format!("paired patch plan for {}", target.stable_key())
307            } else {
308                seed.description.clone()
309            },
310        };
311    }
312
313    match target {
314        TargetKind::ActiveSyndrome { .. } => {
315            if let Some(oracle) = &observation.oracle {
316                PlanKind::OracleExactBounded {
317                    oracle_slice_id: oracle.slice_id.clone(),
318                }
319            } else {
320                PlanKind::OracleConservative
321            }
322        }
323        TargetKind::UnverifiedClaimVersion { claim_version_id } => {
324            if claim_is_promoted(observation, claim_version_id) {
325                PlanKind::OracleCausalRefuter {
326                    target_node_id: observation
327                        .explanation
328                        .as_ref()
329                        .and_then(|explanation| explanation.witness_node_ids.first().cloned())
330                        .or_else(|| claim_subject_entity_id(observation, claim_version_id))
331                        .unwrap_or_else(|| claim_version_id.as_str().to_string()),
332                    max_removed_nodes: config.minimal_perturbation_budget,
333                }
334            } else if let Some(oracle) = &observation.oracle {
335                PlanKind::OracleExactBounded {
336                    oracle_slice_id: oracle.slice_id.clone(),
337                }
338            } else {
339                PlanKind::OracleConservative
340            }
341        }
342        TargetKind::FragileNode { node_id, .. } => PlanKind::OracleMinimalPerturbation {
343            target_node_id: node_id.clone(),
344            max_removed_nodes: config.minimal_perturbation_budget,
345        },
346        TargetKind::RefutationGap { target_node_id, .. } => PlanKind::OracleCausalRefuter {
347            target_node_id: target_node_id.clone(),
348            max_removed_nodes: config.minimal_perturbation_budget,
349        },
350        TargetKind::SupersessionVerification { .. } | TargetKind::ComparabilityDrift { .. } => {
351            PlanKind::OracleTemporalReplay {
352                cutoff_recorded_at: observation
353                    .import_log
354                    .as_ref()
355                    .map(|row| row.imported_at.clone())
356                    .unwrap_or_else(|| chrono::Utc::now().to_rfc3339()),
357            }
358        }
359        TargetKind::CalibrationCaveat { .. } => {
360            let changed_node_ids = observation
361                .explanation
362                .as_ref()
363                .map(|explanation| explanation.witness_node_ids.clone())
364                .unwrap_or_default();
365            PlanKind::OracleDeltaParity {
366                changed_node_ids,
367                max_iterations: config.oracle_max_iterations,
368            }
369        }
370        TargetKind::ThinExport { marker } => PlanKind::AdvisoryOnlyVerificationPlan(AdvisoryPlan {
371            description: format!("advisory-only plan for {marker}"),
372        }),
373        TargetKind::ScopeStale { last_import_at } => {
374            PlanKind::AdvisoryOnlyVerificationPlan(AdvisoryPlan {
375                description: if last_import_at.is_none()
376                    && matches!(
377                        observation.status.disposition,
378                        ObservationDisposition::ImportRequired
379                    ) {
380                    format!("bootstrap advisory: {}", observation.status.exact_next_step)
381                } else {
382                    format!(
383                        "advisory-only plan for stale scope {}",
384                        last_import_at.as_deref().unwrap_or("never")
385                    )
386                },
387            })
388        }
389    }
390}
391
392fn claim_is_promoted(
393    observation: &Observation,
394    claim_version_id: &stack_ids::ClaimVersionId,
395) -> bool {
396    observation.claim_versions.iter().any(|claim| {
397        claim.claim_version_id == *claim_version_id
398            && claim
399                .metadata
400                .as_ref()
401                .and_then(|metadata| metadata.get("promotion_state"))
402                .and_then(|promotion_state| promotion_state.get("state"))
403                .and_then(|state| state.as_str())
404                == Some("promoted")
405    })
406}
407
408fn claim_subject_entity_id(
409    observation: &Observation,
410    claim_version_id: &stack_ids::ClaimVersionId,
411) -> Option<String> {
412    observation
413        .claim_versions
414        .iter()
415        .find(|claim| claim.claim_version_id == *claim_version_id)
416        .map(|claim| claim.subject_entity_id.as_str().to_string())
417}
418
419fn build_rationale(observation: &Observation, target: &TargetKind, prior_attempts: u32) -> String {
420    let degradation_hint = if observation.thin_export_active() {
421        "degradation active"
422    } else if matches!(
423        observation.status.disposition,
424        ObservationDisposition::ImportRequired
425    ) {
426        "canonical import missing"
427    } else {
428        "full kernel payload available"
429    };
430    format!(
431        "target={} prior_attempts={} {}",
432        target.stable_key(),
433        prior_attempts,
434        degradation_hint
435    )
436}
437
438fn build_check_hints(target: &TargetKind) -> Vec<String> {
439    match target {
440        TargetKind::ActiveSyndrome { signature } => vec![format!("inspect syndrome {signature}")],
441        TargetKind::FragileNode { node_id, .. } => vec![format!("perturb node {node_id}")],
442        TargetKind::ThinExport { marker } => vec![format!("inspect export thinness {marker}")],
443        TargetKind::UnverifiedClaimVersion { claim_version_id } => {
444            vec![format!("verify claim {}", claim_version_id.as_str())]
445        }
446        TargetKind::RefutationGap { target_node_id, .. } => {
447            vec![format!("run refuter for {target_node_id}")]
448        }
449        TargetKind::SupersessionVerification {
450            claim_version_id,
451            supersedes_claim_version_id,
452        } => vec![format!(
453            "replay supersession {} -> {}",
454            claim_version_id.as_str(),
455            supersedes_claim_version_id.as_str()
456        )],
457        TargetKind::ComparabilityDrift { detail } => {
458            vec![format!("inspect comparability versions {detail}")]
459        }
460        TargetKind::CalibrationCaveat { marker } => vec![format!("review calibration {marker}")],
461        TargetKind::ScopeStale { last_import_at } => vec![format!(
462            "scope import stale at {}",
463            last_import_at.as_deref().unwrap_or("never")
464        )],
465    }
466}