polyc-facts 2026.8.3

Shared semantic-fold library: decode-to-fact functions reused by every consumer that reads the event log, so a payment receipt or a tool call means the same thing everywhere it's read.
//! `model_call` event decode (#1579).
//!
//! Before this module existed, `model_call` payload bytes decoded to meaning
//! in two independent places: the control plane's replay-determinism record
//! (`crates/control-plane/src/grpc/mod.rs`'s `decode_model_call_payload`,
//! which restores every field for a faithful forensics replay) and the query
//! engine's `model_call` typed table
//! (`crates/query/src/decode/model_call.rs`, which deliberately keeps only
//! `provider`/`model`/`captured_clock_unix_ms` — see that module's own
//! "Column selection" doc for why the rest isn't queryable yet). Both called
//! through the same `try_decode_event_payload::<ModelCallEvent>` step, each
//! independently re-deriving the mechanical field mapping.
//! [`fold_model_call_event`] is now the one place that does it, decoding
//! every field either consumer might want.
//!
//! # What stays out of this fact
//!
//! `persona_block_placement` decodes here as the RAW wire `u32`
//! ([`ModelCallFact::persona_block_placement_wire`]), not the parsed
//! `PersonaBlockPlacement` enum control-plane's replay path uses: whether an
//! unrecognized value should FAIL the whole decode is control-plane's own
//! replay-fidelity policy (a record from a future, unknown layout must not
//! replay under a guessed one) — query doesn't read this field at all, so
//! baking that failure mode into the shared fact would make an unrelated
//! consumer's row silently disappear over a field it never asked for. Same
//! reasoning as [`crate::attribution`]'s "the fold is the decode primitive,
//! policy stays with the caller" line. `clear_trigger_bytes`/
//! `clear_keep_recent`/`clear_marker` stay as their raw wire values for the
//! same reason — control-plane interprets a zero `clear_trigger_bytes` as
//! "clearing was not in force at dispatch", but that interpretation is its
//! own replay concern, not a fact query needs.

use polyc_proto::proto::polychrome::events::v1::ModelCallEvent;

/// Decode parameters as the wire actually encodes them.
///
/// Each numeric parameter is paired with its own presence flag, so
/// "explicit value" and "unset — provider default" are distinguishable.
/// `reasoning_level` uses the wire's own "empty string means unset"
/// convention directly (there is no separate presence flag for it).
#[derive(Debug, Clone, Default, PartialEq)]
pub struct DecodeParamsFact {
    /// Sampling temperature; `None` → provider default.
    pub temperature: Option<f64>,
    /// Nucleus-sampling cutoff; `None` → provider default.
    pub top_p: Option<f64>,
    /// Generation ceiling; `None` → provider default.
    pub max_tokens: Option<u32>,
    /// Reasoning/thinking effort label (e.g. `"low"`); `None` → provider
    /// default.
    pub reasoning_level: Option<String>,
}

/// Decoded `model_call` fact: every field
/// `polychrome.events.v1.ModelCallEvent` carries, named and typed for a
/// caller to pick from.
///
/// See the module docs for why `persona_block_placement_wire`/`clear_*`
/// stay raw rather than control-plane's own parsed/interpreted shapes.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ModelCallFact {
    /// The provider the turn's model belongs to; empty when the selection
    /// deferred to the harness's default provider.
    pub provider: String,
    /// The model id the turn ran; empty when the selection deferred to the
    /// harness's default model.
    pub model: String,
    /// Decode parameters governing generation.
    pub decode_params: DecodeParamsFact,
    /// Stable reference (digest) to the system/safety configuration in
    /// force; empty when no system framing applied.
    pub system_config_ref: String,
    /// Wall-clock value captured at turn dispatch, in Unix milliseconds.
    pub captured_clock_unix_ms: u64,
    /// Raw stale-tool-result-clearing trigger threshold; `0` means clearing
    /// was not in force at dispatch (see the module docs).
    pub clear_trigger_bytes: u64,
    /// Raw stale-tool-result-clearing keep-recent count.
    pub clear_keep_recent: u32,
    /// Raw stale-tool-result-clearing marker text.
    pub clear_marker: String,
    /// Raw wire value for where the persona context block rode in the
    /// assembled prompt at dispatch — see the module docs for why this stays
    /// unparsed here.
    pub persona_block_placement_wire: u32,
}

impl From<ModelCallEvent> for ModelCallFact {
    fn from(event: ModelCallEvent) -> Self {
        let dp = event.decode_params.into_option().unwrap_or_default();
        Self {
            provider: event.provider,
            model: event.model,
            decode_params: DecodeParamsFact {
                temperature: dp.has_temperature.then_some(dp.temperature),
                top_p: dp.has_top_p.then_some(dp.top_p),
                max_tokens: dp.has_max_tokens.then_some(dp.max_tokens),
                reasoning_level: (!dp.reasoning_level.is_empty()).then_some(dp.reasoning_level),
            },
            system_config_ref: event.system_config_ref,
            captured_clock_unix_ms: event.captured_clock_unix_ms,
            clear_trigger_bytes: event.clear_trigger_bytes,
            clear_keep_recent: event.clear_keep_recent,
            clear_marker: event.clear_marker,
            persona_block_placement_wire: event.persona_block_placement,
        }
    }
}

/// Decode a `model_call` event payload into its [`ModelCallFact`].
///
/// # Errors
///
/// Returns the underlying decode error on a structurally malformed
/// (non-empty) payload. An EMPTY payload decodes cleanly to
/// `ModelCallFact::default()` (proto3 elides all-default scalar fields) —
/// a legitimate row, not a decode failure.
pub fn fold_model_call_event(payload: &[u8]) -> Result<ModelCallFact, buffa::DecodeError> {
    polyc_proto::events_decode::try_decode_event_payload::<ModelCallEvent>(payload)
        .map(ModelCallFact::from)
}

#[cfg(test)]
mod tests {
    #![allow(clippy::pedantic, clippy::nursery, missing_docs, clippy::unwrap_used)]

    use buffa::Message as _;
    use polyc_proto::proto::polychrome::events::v1::DecodeParams;

    use super::*;

    fn full_event() -> ModelCallEvent {
        ModelCallEvent {
            provider: "vertex".to_owned(),
            model: "fable-pro".to_owned(),
            decode_params: buffa::MessageField::some(DecodeParams {
                has_temperature: true,
                temperature: 0.7,
                has_top_p: false,
                top_p: 0.0,
                has_max_tokens: true,
                max_tokens: 4096,
                reasoning_level: "low".to_owned(),
                __buffa_unknown_fields: buffa::UnknownFields::default(),
            }),
            system_config_ref: "abc123".to_owned(),
            captured_clock_unix_ms: 1_700_000_000_123,
            clear_trigger_bytes: 4096,
            clear_keep_recent: 3,
            clear_marker: "-- cleared --".to_owned(),
            persona_block_placement: 1,
            __buffa_unknown_fields: buffa::UnknownFields::default(),
        }
    }

    #[test]
    fn round_trips_every_field() {
        let bytes = full_event().encode_to_vec();
        let fact = fold_model_call_event(&bytes).expect("decode model_call");
        assert_eq!(fact.provider, "vertex");
        assert_eq!(fact.model, "fable-pro");
        assert_eq!(fact.decode_params.temperature, Some(0.7));
        assert_eq!(fact.decode_params.top_p, None, "has_top_p was false");
        assert_eq!(fact.decode_params.max_tokens, Some(4096));
        assert_eq!(fact.decode_params.reasoning_level.as_deref(), Some("low"));
        assert_eq!(fact.system_config_ref, "abc123");
        assert_eq!(fact.captured_clock_unix_ms, 1_700_000_000_123);
        assert_eq!(fact.clear_trigger_bytes, 4096);
        assert_eq!(fact.clear_keep_recent, 3);
        assert_eq!(fact.clear_marker, "-- cleared --");
        assert_eq!(fact.persona_block_placement_wire, 1);
    }

    #[test]
    fn empty_payload_decodes_to_defaults() {
        let fact = fold_model_call_event(&[]).expect("empty payload decodes");
        assert_eq!(fact, ModelCallFact::default());
    }

    #[test]
    fn empty_reasoning_level_decodes_to_none() {
        let mut event = full_event();
        event.decode_params = buffa::MessageField::some(DecodeParams {
            has_temperature: true,
            temperature: 0.7,
            has_top_p: false,
            top_p: 0.0,
            has_max_tokens: true,
            max_tokens: 4096,
            reasoning_level: String::new(),
            __buffa_unknown_fields: buffa::UnknownFields::default(),
        });
        let bytes = event.encode_to_vec();
        let fact = fold_model_call_event(&bytes).expect("decode");
        assert_eq!(fact.decode_params.reasoning_level, None);
    }

    #[test]
    fn garbage_bytes_return_an_error() {
        assert!(fold_model_call_event(&[0xFF, 0xFE, 0xFD]).is_err());
    }
}