parlov-elicit 0.5.0

Elicitation engine: strategy selection and probe plan generation for parlov.
Documentation
//! Core elicitation types: risk classification, probe specifications, and strategy metadata.

use parlov_core::{ProbeDefinition, Technique};
use serde::{Deserialize, Serialize};

/// Provenance for Phase 2 chain-derived `ProbeSpec`s.
///
/// Attached by `generate_dag_chained_plan` so downstream consumers can trace
/// the harvested signal that produced this probe — e.g. an `If-Match` probe
/// whose value was harvested from an `ETag` response header carries
/// `producer_kind = "Etag"` and `producer_value = "W/\"abc\""`.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct ChainProvenance {
    /// Discriminant from `ProducerOutputKind` as a stable string, e.g. `"Etag"`.
    pub producer_kind: String,
    /// Serialized representation of the harvested value, e.g. `"W/\"abc123\""`.
    pub producer_value: String,
}

/// Risk classification for a probing strategy.
///
/// Controls whether a strategy is eligible for execution given the operator's
/// `max_risk` ceiling in `ScanContext`. Variants are ordered from safest to most
/// destructive so that `<=` comparisons work correctly for ceiling enforcement.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum RiskLevel {
    /// Read-only probing. No state mutation; safe on any target.
    Safe,
    /// Uses non-idempotent HTTP methods (e.g. POST, PATCH, PUT) that may have
    /// side-effects, but the strategy avoids permanent data loss.
    MethodDestructive,
    /// May trigger irreversible server-side state changes (e.g. DELETE, account
    /// closure, resource exhaustion).
    OperationDestructive,
}

/// Metadata describing a strategy: identity and risk rating.
///
/// Carried on every `ProbeSpec` so the scheduler and output layer can attribute
/// each probe pair back to the strategy that generated it without holding a
/// reference to the strategy object.
#[derive(Debug, Clone)]
pub struct StrategyMetadata {
    /// e.g. `"existence-get-200-404"`
    pub strategy_id: &'static str,
    /// e.g. `"GET 200/404 existence"`
    pub strategy_name: &'static str,
    /// Risk classification.
    pub risk: RiskLevel,
}

/// A baseline + probe pair for standard differential analysis.
///
/// The scheduler executes `baseline` and `probe` the required number of times,
/// collecting `ResponseSurface` values into a `DifferentialSet` for analysis.
#[derive(Debug, Clone)]
pub struct ProbePair {
    /// Control (known-existing).
    pub baseline: ProbeDefinition,
    /// Suspect (unknown).
    pub probe: ProbeDefinition,
    /// Optional unmutated baseline for control-integrity verification.
    ///
    /// Strategies that mutate the resource identifier or path (`case_normalize`,
    /// `trailing_slash`) populate this with the original unmutated request. The runner
    /// dispatches it as a third request; the result feeds into `control_integrity`.
    /// Strategies that don't mutate the path leave it `None` (the gate is then inert
    /// for those pairs).
    pub canonical_baseline: Option<ProbeDefinition>,
    /// Generating strategy.
    pub metadata: StrategyMetadata,
    /// Confidence calibration and evidence labeling.
    pub technique: Technique,
    /// Phase-2 chain provenance — `None` for phase-1 specs, `Some` when this
    /// pair was generated by `generate_dag_chained_plan` from a harvested signal.
    pub chain_provenance: Option<ChainProvenance>,
}

/// A burst probe: N requests to baseline URL, then N to probe URL.
///
/// Used for timing oracles where multiple samples are required for statistical
/// significance. `burst_count` sets the minimum sample size per side; the
/// adaptive timing analyzer may request additional rounds.
#[derive(Debug, Clone)]
pub struct BurstSpec {
    /// Control (known-existing).
    pub baseline: ProbeDefinition,
    /// Suspect (unknown).
    pub probe: ProbeDefinition,
    /// Minimum samples per side before analysis.
    pub burst_count: usize,
    /// Generating strategy.
    pub metadata: StrategyMetadata,
    /// Confidence calibration and evidence labeling.
    pub technique: Technique,
    /// Phase-2 chain provenance — `None` for phase-1 specs, `Some` when this
    /// burst was generated by `generate_dag_chained_plan` from a harvested signal.
    pub chain_provenance: Option<ChainProvenance>,
}

/// The unit of work handed to the probe scheduler.
///
/// Each variant encodes a different execution and collection strategy. The
/// scheduler dispatches on the variant; the analysis layer receives a `DifferentialSet`
/// regardless of which variant produced it.
#[derive(Debug, Clone)]
pub enum ProbeSpec {
    /// Standard adaptive loop: one baseline request, one probe request.
    Pair(ProbePair),
    /// Send `burst_count` requests to the baseline URL, then `burst_count` to
    /// the probe URL, for statistical timing analysis.
    Burst(BurstSpec),
    /// One baseline + one probe; compare full response header sets rather than
    /// bodies or status codes.
    HeaderDiff(ProbePair),
}

impl ProbeSpec {
    /// Technique metadata.
    #[must_use]
    pub fn technique(&self) -> &Technique {
        match self {
            Self::Pair(p) | Self::HeaderDiff(p) => &p.technique,
            Self::Burst(b) => &b.technique,
        }
    }

    /// Chain provenance — `Some` when generated by phase-2 DAG walking, else `None`.
    #[must_use]
    pub fn chain_provenance(&self) -> Option<&ChainProvenance> {
        match self {
            Self::Pair(p) | Self::HeaderDiff(p) => p.chain_provenance.as_ref(),
            Self::Burst(b) => b.chain_provenance.as_ref(),
        }
    }

    /// Returns a clone of this spec with `chain_provenance` set on whichever
    /// variant it carries. Used by `generate_dag_chained_plan` to attach
    /// provenance after a consumer returns generic specs.
    #[must_use]
    pub fn with_chain_provenance(self, prov: ChainProvenance) -> Self {
        match self {
            Self::Pair(mut p) => {
                p.chain_provenance = Some(prov);
                Self::Pair(p)
            }
            Self::HeaderDiff(mut p) => {
                p.chain_provenance = Some(prov);
                Self::HeaderDiff(p)
            }
            Self::Burst(mut b) => {
                b.chain_provenance = Some(prov);
                Self::Burst(b)
            }
        }
    }
}

#[cfg(test)]
#[path = "types_tests.rs"]
mod tests;