1use 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#[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
27pub 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
152pub 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 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
214fn 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}