use std::collections::HashMap;
use polyc_eventlog_model::Event;
use polyc_proto::kinds;
use polyc_proto::proto::polychrome::events::v1::AttributionEvent;
use polyc_proto::proto::polychrome::persona::v1::ExternalIdentity;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AttributionEventKind {
Caller,
Participant,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AttributionScope {
CallerOnly,
ParticipantOnly,
Both,
}
impl AttributionScope {
const fn wants(self, kind: AttributionEventKind) -> bool {
matches!(
(self, kind),
(Self::CallerOnly, AttributionEventKind::Caller)
| (Self::ParticipantOnly, AttributionEventKind::Participant)
| (Self::Both, _)
)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct AttributionFact {
pub position: u64,
pub kind: AttributionEventKind,
pub turn_id: Option<uuid::Uuid>,
pub persona_id: String,
pub role: String,
pub identity: Option<ExternalIdentity>,
pub asserting_edge_id: String,
pub signer_pk_hex: String,
pub signature_hex: String,
}
#[must_use = "iterators are lazy — nothing decodes until you consume this"]
pub fn attribution_events(
events: &[Event],
scope: AttributionScope,
) -> impl Iterator<Item = AttributionFact> + '_ {
attribution_events_with_positions(
events.iter().enumerate().map(|(idx, ev)| (idx as u64, ev)),
scope,
)
}
#[must_use = "iterators are lazy — nothing decodes until you consume this"]
pub fn attribution_events_with_positions<'a>(
events: impl Iterator<Item = (u64, &'a Event)> + 'a,
scope: AttributionScope,
) -> impl Iterator<Item = AttributionFact> + 'a {
events.filter_map(move |(position, ev)| {
let (base, turn_id) = kinds::parse(&ev.kind);
let kind = if base == kinds::CALLER {
AttributionEventKind::Caller
} else if base == kinds::PARTICIPANT {
AttributionEventKind::Participant
} else {
return None;
};
if !scope.wants(kind) {
return None;
}
let decoded =
polyc_proto::events_decode::decode_event_payload::<AttributionEvent>(&ev.payload)?;
Some(AttributionFact {
position,
kind,
turn_id,
persona_id: decoded.persona_id,
role: decoded.role,
identity: decoded.identity.into_option(),
asserting_edge_id: decoded.asserting_edge_id,
signer_pk_hex: decoded.signer_pk_hex,
signature_hex: decoded.signature_hex,
})
})
}
#[must_use]
pub fn caller_by_turn_last_wins(
facts: impl Iterator<Item = AttributionFact>,
) -> HashMap<uuid::Uuid, String> {
let mut out = HashMap::new();
for fact in facts {
if let Some(turn) = fact.turn_id {
out.insert(turn, fact.persona_id);
}
}
out
}
#[cfg(test)]
mod tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs, clippy::unwrap_used)]
use buffa::Message as _;
use polyc_eventlog_model::Event;
use uuid::Uuid;
use super::*;
fn caller_event(turn: &Uuid, persona_id: &str) -> Event {
Event::new(
kinds::tagged(kinds::CALLER, turn),
AttributionEvent {
persona_id: persona_id.to_owned(),
role: "initiator".to_owned(),
..Default::default()
}
.encode_to_vec(),
)
}
fn participant_event(turn: &Uuid, persona_id: &str) -> Event {
Event::new(
kinds::tagged(kinds::PARTICIPANT, turn),
AttributionEvent {
persona_id: persona_id.to_owned(),
role: "participant".to_owned(),
..Default::default()
}
.encode_to_vec(),
)
}
fn caller_event_with_identity(
turn: &Uuid,
persona_id: &str,
identity: ExternalIdentity,
) -> Event {
Event::new(
kinds::tagged(kinds::CALLER, turn),
AttributionEvent {
persona_id: persona_id.to_owned(),
role: "initiator".to_owned(),
identity: buffa::MessageField::some(identity),
..Default::default()
}
.encode_to_vec(),
)
}
#[test]
fn caller_only_scope_excludes_participant_events() {
let turn = Uuid::now_v7();
let events = vec![
caller_event(&turn, "persona-caller"),
participant_event(&turn, "persona-participant"),
];
let facts: Vec<_> = attribution_events(&events, AttributionScope::CallerOnly).collect();
assert_eq!(facts.len(), 1);
assert_eq!(facts[0].kind, AttributionEventKind::Caller);
assert_eq!(facts[0].persona_id, "persona-caller");
}
#[test]
fn participant_only_scope_excludes_caller_events() {
let turn = Uuid::now_v7();
let events = vec![
caller_event(&turn, "persona-caller"),
participant_event(&turn, "persona-participant"),
];
let facts: Vec<_> =
attribution_events(&events, AttributionScope::ParticipantOnly).collect();
assert_eq!(facts.len(), 1);
assert_eq!(facts[0].kind, AttributionEventKind::Participant);
assert_eq!(facts[0].persona_id, "persona-participant");
}
#[test]
fn both_scope_decodes_caller_and_participant_and_skips_unrelated_kinds() {
let turn = Uuid::now_v7();
let events = vec![
caller_event(&turn, "persona-caller"),
participant_event(&turn, "persona-participant"),
Event::new(kinds::tagged(kinds::USER_MSG, &turn), Vec::new()),
];
let facts: Vec<_> = attribution_events(&events, AttributionScope::Both).collect();
assert_eq!(facts.len(), 2);
assert_eq!(facts[0].kind, AttributionEventKind::Caller);
assert_eq!(facts[1].kind, AttributionEventKind::Participant);
}
#[test]
fn turn_id_recovers_from_the_kind_suffix() {
let turn = Uuid::now_v7();
let events = vec![caller_event(&turn, "persona-1")];
let facts: Vec<_> = attribution_events(&events, AttributionScope::CallerOnly).collect();
assert_eq!(facts[0].turn_id, Some(turn));
}
#[test]
fn bare_kind_with_no_turn_suffix_yields_no_turn_id() {
let events = vec![Event::new(
kinds::CALLER.to_owned(),
AttributionEvent {
persona_id: "persona-1".to_owned(),
role: "initiator".to_owned(),
..Default::default()
}
.encode_to_vec(),
)];
let facts: Vec<_> = attribution_events(&events, AttributionScope::CallerOnly).collect();
assert_eq!(facts.len(), 1);
assert_eq!(facts[0].turn_id, None);
}
#[test]
fn decode_failure_is_skipped_not_surfaced() {
let turn = Uuid::now_v7();
let events = vec![
Event::new(kinds::tagged(kinds::CALLER, &turn), vec![0xFF, 0xFE, 0xFD]),
caller_event(&turn, "persona-good"),
];
let facts: Vec<_> = attribution_events(&events, AttributionScope::CallerOnly).collect();
assert_eq!(
facts.len(),
1,
"the undecodable payload is skipped, not errored"
);
assert_eq!(facts[0].persona_id, "persona-good");
}
#[test]
fn persona_id_passes_through_raw_including_empty() {
let turn = Uuid::now_v7();
let events = vec![caller_event(&turn, "")];
let facts: Vec<_> = attribution_events(&events, AttributionScope::CallerOnly).collect();
assert_eq!(
facts.len(),
1,
"an empty persona_id is not filtered by the primitive"
);
assert_eq!(facts[0].persona_id, "");
}
#[test]
fn identity_extracts_when_present_and_none_when_absent() {
let turn = Uuid::now_v7();
let identity = ExternalIdentity {
provider: "slack".to_owned(),
scope: "T1".to_owned(),
external_id: "U1".to_owned(),
display_name: "Alice".to_owned(),
..Default::default()
};
let events = vec![
caller_event_with_identity(&turn, "persona-1", identity.clone()),
caller_event(&turn, "persona-2"),
];
let facts: Vec<_> = attribution_events(&events, AttributionScope::CallerOnly).collect();
assert_eq!(facts[0].identity, Some(identity));
assert_eq!(facts[1].identity, None);
}
#[test]
fn position_reflects_index_in_the_given_slice() {
let turn = Uuid::now_v7();
let events = vec![
Event::new(kinds::tagged(kinds::USER_MSG, &turn), Vec::new()),
caller_event(&turn, "persona-1"),
];
let facts: Vec<_> = attribution_events(&events, AttributionScope::CallerOnly).collect();
assert_eq!(facts[0].position, 1);
}
#[test]
fn attribution_events_with_positions_uses_the_supplied_position_not_the_index() {
let turn = Uuid::now_v7();
let events = [
(
5_u64,
Event::new(kinds::tagged(kinds::USER_MSG, &turn), Vec::new()),
),
(9_u64, caller_event(&turn, "persona-1")),
];
let facts: Vec<_> = attribution_events_with_positions(
events.iter().map(|(pos, ev)| (*pos, ev)),
AttributionScope::CallerOnly,
)
.collect();
assert_eq!(facts.len(), 1);
assert_eq!(
facts[0].position, 9,
"position must come from the supplied pair, not the 0-based iterator index"
);
}
#[test]
fn role_is_raw_from_the_payload_not_derived_from_kind() {
let turn = Uuid::now_v7();
let events = vec![participant_event(&turn, "persona-1")];
let facts: Vec<_> =
attribution_events(&events, AttributionScope::ParticipantOnly).collect();
assert_eq!(facts[0].kind, AttributionEventKind::Participant);
assert_eq!(facts[0].role, "participant");
let bare = Event::new(
kinds::CALLER.to_owned(),
AttributionEvent {
persona_id: "persona-2".to_owned(),
role: String::new(),
..Default::default()
}
.encode_to_vec(),
);
let facts: Vec<_> = attribution_events(&[bare], AttributionScope::CallerOnly).collect();
assert_eq!(facts[0].kind, AttributionEventKind::Caller);
assert_eq!(facts[0].role, "", "role is not backfilled from kind");
}
#[test]
fn caller_by_turn_last_wins_keeps_the_later_fact() {
let turn = Uuid::now_v7();
let events = vec![
caller_event(&turn, "persona-first"),
caller_event(&turn, "persona-second"),
];
let map =
caller_by_turn_last_wins(attribution_events(&events, AttributionScope::CallerOnly));
assert_eq!(map.get(&turn), Some(&"persona-second".to_owned()));
}
#[test]
fn edge_provenance_fields_decode_when_present() {
let turn = Uuid::now_v7();
let events = vec![Event::new(
kinds::tagged(kinds::CALLER, &turn),
AttributionEvent {
persona_id: String::new(),
role: "edge".to_owned(),
asserting_edge_id: "trigger-edge".to_owned(),
signer_pk_hex: "deadbeef".to_owned(),
signature_hex: "cafef00d".to_owned(),
..Default::default()
}
.encode_to_vec(),
)];
let facts: Vec<_> = attribution_events(&events, AttributionScope::CallerOnly).collect();
assert_eq!(facts.len(), 1);
assert_eq!(facts[0].asserting_edge_id, "trigger-edge");
assert_eq!(facts[0].signer_pk_hex, "deadbeef");
assert_eq!(facts[0].signature_hex, "cafef00d");
}
#[test]
fn edge_provenance_fields_default_empty_when_absent() {
let turn = Uuid::now_v7();
let events = vec![caller_event(&turn, "persona-1")];
let facts: Vec<_> = attribution_events(&events, AttributionScope::CallerOnly).collect();
assert_eq!(facts[0].asserting_edge_id, "");
assert_eq!(facts[0].signer_pk_hex, "");
assert_eq!(facts[0].signature_hex, "");
}
#[test]
fn caller_by_turn_last_wins_tracks_multiple_turns_independently() {
let turn_a = Uuid::now_v7();
let turn_b = Uuid::now_v7();
let events = vec![
caller_event(&turn_a, "persona-a"),
caller_event(&turn_b, "persona-b"),
];
let map =
caller_by_turn_last_wins(attribution_events(&events, AttributionScope::CallerOnly));
assert_eq!(map.get(&turn_a), Some(&"persona-a".to_owned()));
assert_eq!(map.get(&turn_b), Some(&"persona-b".to_owned()));
}
}