1extern crate alloc;
4use alloc::string::String;
5use alloc::vec::Vec;
6use core::fmt;
7
8use crate::{Header, Id128, SubstrateKind};
9
10#[derive(Clone, Debug)]
12#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
13pub struct Event {
14 #[cfg_attr(feature = "serde", serde(flatten))]
15 pub header: Header,
16 pub verb: String,
18 pub substrate: SubstrateKind,
20 pub actor: Option<String>,
22 pub kind: EventKind,
24 pub payload: EventPayload,
26 pub payload_schema_version: u32,
28 pub profile_state_version: Option<u64>,
30 pub aggregate: Option<AggregateRef>,
32}
33
34#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
36#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
37#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
38pub enum EventOutcome {
39 #[default]
41 Success,
42 Denied,
44 Error,
46}
47
48impl EventOutcome {
49 pub const fn name(self) -> &'static str {
51 match self {
52 Self::Success => "success",
53 Self::Denied => "denied",
54 Self::Error => "error",
55 }
56 }
57}
58
59impl fmt::Display for EventOutcome {
60 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61 f.write_str(self.name())
62 }
63}
64
65#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
67#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
68#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
69pub enum EventKind {
70 Audit,
72 RecallExecuted,
74 RerankExecuted,
76 SearchExecuted,
78 LinkCreated,
80 EntityCreated,
82 EntityUpdated,
84 EntityDeleted,
86 EntityMerged,
88 NoteCreated,
90 NoteUpdated,
92 NoteDeleted,
94 EdgeUpdated,
96 EdgeDeleted,
98 TaskTransitioned,
100 FeedbackExplicit,
102 ProfileResolutionRecommended,
104 ProfileMerged,
106 EmbeddingModelChanged,
108 EmbeddingMigrationCompleted,
110 EmbeddingMigrationFailed,
112 EmbeddingDriftDetected,
114 ProposalCreated,
116 ProposalReviewed,
118 ProposalApplied,
120 ProposalWithdrawn,
122}
123
124impl EventKind {
125 pub const ALL: [Self; 26] = [
127 Self::Audit,
128 Self::RecallExecuted,
129 Self::RerankExecuted,
130 Self::SearchExecuted,
131 Self::LinkCreated,
132 Self::EntityCreated,
133 Self::EntityUpdated,
134 Self::EntityDeleted,
135 Self::EntityMerged,
136 Self::NoteCreated,
137 Self::NoteUpdated,
138 Self::NoteDeleted,
139 Self::EdgeUpdated,
140 Self::EdgeDeleted,
141 Self::TaskTransitioned,
142 Self::FeedbackExplicit,
143 Self::ProfileResolutionRecommended,
144 Self::ProfileMerged,
145 Self::EmbeddingModelChanged,
146 Self::EmbeddingMigrationCompleted,
147 Self::EmbeddingMigrationFailed,
148 Self::EmbeddingDriftDetected,
149 Self::ProposalCreated,
150 Self::ProposalReviewed,
151 Self::ProposalApplied,
152 Self::ProposalWithdrawn,
153 ];
154
155 pub const fn name(self) -> &'static str {
157 match self {
158 Self::Audit => "audit",
159 Self::RecallExecuted => "recall_executed",
160 Self::RerankExecuted => "rerank_executed",
161 Self::SearchExecuted => "search_executed",
162 Self::LinkCreated => "link_created",
163 Self::EntityCreated => "entity_created",
164 Self::EntityUpdated => "entity_updated",
165 Self::EntityDeleted => "entity_deleted",
166 Self::EntityMerged => "entity_merged",
167 Self::NoteCreated => "note_created",
168 Self::NoteUpdated => "note_updated",
169 Self::NoteDeleted => "note_deleted",
170 Self::EdgeUpdated => "edge_updated",
171 Self::EdgeDeleted => "edge_deleted",
172 Self::TaskTransitioned => "task_transitioned",
173 Self::FeedbackExplicit => "feedback_explicit",
174 Self::ProfileResolutionRecommended => "profile_resolution_recommended",
175 Self::ProfileMerged => "profile_merged",
176 Self::EmbeddingModelChanged => "embedding_model_changed",
177 Self::EmbeddingMigrationCompleted => "embedding_migration_completed",
178 Self::EmbeddingMigrationFailed => "embedding_migration_failed",
179 Self::EmbeddingDriftDetected => "embedding_drift_detected",
180 Self::ProposalCreated => "proposal_created",
181 Self::ProposalReviewed => "proposal_reviewed",
182 Self::ProposalApplied => "proposal_applied",
183 Self::ProposalWithdrawn => "proposal_withdrawn",
184 }
185 }
186}
187
188impl fmt::Display for EventKind {
189 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
190 f.write_str(self.name())
191 }
192}
193
194const EVENT_KIND_VALID: &[&str] = &[
195 "audit",
196 "recall_executed",
197 "rerank_executed",
198 "search_executed",
199 "link_created",
200 "entity_created",
201 "entity_updated",
202 "entity_deleted",
203 "entity_merged",
204 "note_created",
205 "note_updated",
206 "note_deleted",
207 "edge_updated",
208 "edge_deleted",
209 "task_transitioned",
210 "feedback_explicit",
211 "profile_resolution_recommended",
212 "profile_merged",
213 "embedding_model_changed",
214 "embedding_migration_completed",
215 "embedding_migration_failed",
216 "embedding_drift_detected",
217 "proposal_created",
218 "proposal_reviewed",
219 "proposal_applied",
220 "proposal_withdrawn",
221];
222
223impl core::str::FromStr for EventKind {
224 type Err = crate::error::UnknownVariant;
225
226 fn from_str(s: &str) -> Result<Self, Self::Err> {
227 match s.trim().to_ascii_lowercase().as_str() {
228 "audit" => Ok(Self::Audit),
229 "recall_executed" => Ok(Self::RecallExecuted),
230 "rerank_executed" => Ok(Self::RerankExecuted),
231 "search_executed" => Ok(Self::SearchExecuted),
232 "link_created" => Ok(Self::LinkCreated),
233 "entity_created" => Ok(Self::EntityCreated),
234 "entity_updated" => Ok(Self::EntityUpdated),
235 "entity_deleted" => Ok(Self::EntityDeleted),
236 "entity_merged" => Ok(Self::EntityMerged),
237 "note_created" => Ok(Self::NoteCreated),
238 "note_updated" => Ok(Self::NoteUpdated),
239 "note_deleted" => Ok(Self::NoteDeleted),
240 "edge_updated" => Ok(Self::EdgeUpdated),
241 "edge_deleted" => Ok(Self::EdgeDeleted),
242 "task_transitioned" => Ok(Self::TaskTransitioned),
243 "feedback_explicit" => Ok(Self::FeedbackExplicit),
244 "profile_resolution_recommended" => Ok(Self::ProfileResolutionRecommended),
245 "profile_merged" => Ok(Self::ProfileMerged),
246 "embedding_model_changed" => Ok(Self::EmbeddingModelChanged),
247 "embedding_migration_completed" => Ok(Self::EmbeddingMigrationCompleted),
248 "embedding_migration_failed" => Ok(Self::EmbeddingMigrationFailed),
249 "embedding_drift_detected" => Ok(Self::EmbeddingDriftDetected),
250 "proposal_created" => Ok(Self::ProposalCreated),
251 "proposal_reviewed" => Ok(Self::ProposalReviewed),
252 "proposal_applied" => Ok(Self::ProposalApplied),
253 "proposal_withdrawn" => Ok(Self::ProposalWithdrawn),
254 other => Err(crate::error::UnknownVariant::new(
255 "event_kind",
256 other,
257 EVENT_KIND_VALID,
258 )),
259 }
260 }
261}
262
263#[derive(Clone, Debug, PartialEq, Eq)]
268#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
269pub struct AggregateRef {
270 pub kind: String,
272 pub id: Id128,
274}
275
276#[derive(Clone, Debug, PartialEq)]
282#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
283#[cfg_attr(
284 feature = "serde",
285 serde(tag = "kind", content = "payload", rename_all = "snake_case")
286)]
287pub enum EventPayload {
288 Json(String),
290 RerankExecuted(RerankExecutedPayload),
292 #[cfg(feature = "serde")]
294 ProposalCreated(ProposalCreatedPayload),
295 ProposalReviewed(ProposalReviewedPayload),
297 ProposalApplied(ProposalAppliedPayload),
299 ProposalWithdrawn(ProposalWithdrawnPayload),
301}
302
303impl Default for EventPayload {
304 fn default() -> Self {
305 Self::Json("{}".into())
306 }
307}
308
309#[derive(Clone, Debug, PartialEq)]
314#[cfg_attr(feature = "serde", derive(serde::Serialize))]
315pub struct RerankExecutedPayload {
316 pub served_by_profile_id: Option<String>,
318 pub model_id: Id128,
320 pub candidates: Vec<Id128>,
322 pub reranked: Vec<(Id128, Vec<(String, f32)>)>,
324 pub final_scores: Vec<(Id128, f32)>,
326 pub latency_us: u64,
328 pub hook_applied: bool,
330 pub hook_target_match: bool,
332}
333
334impl RerankExecutedPayload {
335 pub fn is_valid(&self) -> bool {
337 let reranked_ok = self
338 .reranked
339 .iter()
340 .all(|(_, scores)| scores.iter().all(|(_, s)| s.is_finite()));
341 let final_ok = self.final_scores.iter().all(|(_, s)| s.is_finite());
342 reranked_ok && final_ok
343 }
344}
345
346#[cfg(feature = "serde")]
347impl<'de> serde::Deserialize<'de> for RerankExecutedPayload {
348 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
349 where
350 D: serde::Deserializer<'de>,
351 {
352 #[derive(serde::Deserialize)]
353 struct Raw {
354 served_by_profile_id: Option<String>,
355 model_id: Id128,
356 candidates: Vec<Id128>,
357 reranked: Vec<(Id128, Vec<(String, f32)>)>,
358 final_scores: Vec<(Id128, f32)>,
359 latency_us: u64,
360 hook_applied: bool,
361 hook_target_match: bool,
362 }
363
364 let raw = Raw::deserialize(deserializer)?;
365
366 for (_, score) in &raw.final_scores {
367 if !score.is_finite() {
368 return Err(serde::de::Error::custom(alloc::format!(
369 "RerankExecutedPayload final_scores must be finite, got {score}"
370 )));
371 }
372 }
373 for (_, sections) in &raw.reranked {
374 for (section_name, score) in sections {
375 if !score.is_finite() {
376 return Err(serde::de::Error::custom(alloc::format!(
377 "RerankExecutedPayload reranked section '{section_name}' score must be finite, got {score}"
378 )));
379 }
380 }
381 }
382
383 Ok(RerankExecutedPayload {
384 served_by_profile_id: raw.served_by_profile_id,
385 model_id: raw.model_id,
386 candidates: raw.candidates,
387 reranked: raw.reranked,
388 final_scores: raw.final_scores,
389 latency_us: raw.latency_us,
390 hook_applied: raw.hook_applied,
391 hook_target_match: raw.hook_target_match,
392 })
393 }
394}
395
396#[cfg(feature = "serde")]
397#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
398pub struct ProposalCreatedPayload {
399 pub proposal_id: Id128,
400 pub proposer: String,
401 pub title: String,
402 pub description: String,
403 pub changeset: ProposalChangeset,
404 pub reviewers: Vec<String>,
405 pub expiry: Option<crate::Timestamp>,
406 pub parent_id: Option<Id128>,
407}
408
409#[cfg(feature = "serde")]
414#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
415pub struct EntityDraft {
416 pub kind: String,
418 pub name: String,
420 #[serde(skip_serializing_if = "Option::is_none")]
422 pub description: Option<String>,
423 #[serde(skip_serializing_if = "Option::is_none")]
425 pub properties: Option<serde_json::Value>,
426 #[serde(default, skip_serializing_if = "Vec::is_empty")]
428 pub tags: Vec<String>,
429}
430
431#[cfg(feature = "serde")]
435#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
436pub struct ProposalEntityPatch {
437 #[serde(skip_serializing_if = "Option::is_none")]
438 pub name: Option<String>,
439 #[serde(
441 default,
442 skip_serializing_if = "Option::is_none",
443 with = "serde_opt_opt"
444 )]
445 pub description: Option<Option<String>>,
446 #[serde(skip_serializing_if = "Option::is_none")]
447 pub properties: Option<serde_json::Value>,
448 #[serde(skip_serializing_if = "Option::is_none")]
449 pub tags: Option<Vec<String>>,
450}
451
452#[cfg(feature = "serde")]
456#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
457pub struct NoteDraft {
458 pub kind: String,
460 pub content: String,
462 #[serde(skip_serializing_if = "Option::is_none")]
464 pub name: Option<String>,
465 #[serde(skip_serializing_if = "Option::is_none")]
467 pub properties: Option<serde_json::Value>,
468}
469
470#[cfg(feature = "serde")]
472mod serde_opt_opt {
473 use serde::{Deserialize, Deserializer, Serialize, Serializer};
474
475 pub fn serialize<T, S>(val: &Option<Option<T>>, s: S) -> Result<S::Ok, S::Error>
476 where
477 T: Serialize,
478 S: Serializer,
479 {
480 match val {
481 None => unreachable!("skip_serializing_if guards the None case"),
482 Some(inner) => inner.serialize(s),
483 }
484 }
485
486 pub fn deserialize<'de, T, D>(d: D) -> Result<Option<Option<T>>, D::Error>
487 where
488 T: Deserialize<'de>,
489 D: Deserializer<'de>,
490 {
491 let opt: Option<T> = Option::deserialize(d)?;
492 Ok(Some(opt))
493 }
494}
495
496#[cfg(feature = "serde")]
497#[derive(Clone, Debug, PartialEq, serde::Serialize)]
498#[serde(tag = "kind", rename_all = "snake_case")]
499pub enum ProposalChangeset {
500 AddEntity {
502 entity: EntityDraft,
503 },
504 UpdateEntity {
506 id: Id128,
507 patch: ProposalEntityPatch,
508 },
509 AddEdge {
511 source: Id128,
512 target: Id128,
513 relation: crate::EdgeRelation,
514 weight: Option<f32>,
515 },
516 AddNote {
518 note: NoteDraft,
519 },
520 MergeEntities {
521 into: Id128,
522 from: Id128,
523 },
524 SupersedeEntity {
525 old: Id128,
526 new: Id128,
527 },
528 Compound {
529 steps: Vec<ProposalChangeset>,
530 },
531}
532
533#[cfg(feature = "serde")]
534impl ProposalChangeset {
535 fn validate(&self) -> Result<(), alloc::string::String> {
536 match self {
537 Self::AddEdge { weight, .. } => {
538 if let Some(w) = weight {
539 if !w.is_finite() {
540 return Err(alloc::format!(
541 "ProposalChangeset AddEdge weight must be finite, got {w}"
542 ));
543 }
544 if !(*w >= 0.0 && *w <= 1.0) {
545 return Err(alloc::format!(
546 "ProposalChangeset AddEdge weight must be in [0.0, 1.0], got {w}"
547 ));
548 }
549 }
550 Ok(())
551 }
552 Self::Compound { steps } => {
553 for step in steps {
554 step.validate()?;
555 }
556 Ok(())
557 }
558 _ => Ok(()),
559 }
560 }
561}
562
563#[cfg(feature = "serde")]
564impl<'de> serde::Deserialize<'de> for ProposalChangeset {
565 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
566 where
567 D: serde::Deserializer<'de>,
568 {
569 #[derive(serde::Deserialize)]
570 #[serde(tag = "kind", rename_all = "snake_case")]
571 enum ProposalChangesetRaw {
572 AddEntity {
573 entity: EntityDraft,
574 },
575 UpdateEntity {
576 id: Id128,
577 patch: ProposalEntityPatch,
578 },
579 AddEdge {
580 source: Id128,
581 target: Id128,
582 relation: crate::EdgeRelation,
583 weight: Option<f32>,
584 },
585 AddNote {
586 note: NoteDraft,
587 },
588 MergeEntities {
589 into: Id128,
590 from: Id128,
591 },
592 SupersedeEntity {
593 old: Id128,
594 new: Id128,
595 },
596 Compound {
597 steps: Vec<ProposalChangeset>,
598 },
599 }
600
601 let raw = ProposalChangesetRaw::deserialize(deserializer)?;
602 let cs = match raw {
603 ProposalChangesetRaw::AddEntity { entity } => Self::AddEntity { entity },
604 ProposalChangesetRaw::UpdateEntity { id, patch } => Self::UpdateEntity { id, patch },
605 ProposalChangesetRaw::AddEdge {
606 source,
607 target,
608 relation,
609 weight,
610 } => Self::AddEdge {
611 source,
612 target,
613 relation,
614 weight,
615 },
616 ProposalChangesetRaw::AddNote { note } => Self::AddNote { note },
617 ProposalChangesetRaw::MergeEntities { into, from } => {
618 Self::MergeEntities { into, from }
619 }
620 ProposalChangesetRaw::SupersedeEntity { old, new } => {
621 Self::SupersedeEntity { old, new }
622 }
623 ProposalChangesetRaw::Compound { steps } => Self::Compound { steps },
624 };
625 cs.validate().map_err(serde::de::Error::custom)?;
626 Ok(cs)
627 }
628}
629
630#[cfg(not(feature = "serde"))]
631#[derive(Clone, Debug, PartialEq)]
632pub enum ProposalChangeset {
633 AddEdge {
634 source: Id128,
635 target: Id128,
636 relation: crate::EdgeRelation,
637 weight: Option<f32>,
638 },
639 MergeEntities {
640 into: Id128,
641 from: Id128,
642 },
643 SupersedeEntity {
644 old: Id128,
645 new: Id128,
646 },
647 Compound {
648 steps: Vec<ProposalChangeset>,
649 },
650}
651
652#[derive(Clone, Debug, PartialEq)]
653#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
654pub struct ProposalReviewedPayload {
655 pub proposal_id: Id128,
656 pub reviewer: String,
657 pub decision: ProposalDecision,
658 pub comment: Option<String>,
659}
660
661#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
663#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
664#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
665pub enum ProposalDecision {
666 Approve,
668 Reject,
670 Comment,
672 RequestChanges,
674}
675
676impl ProposalDecision {
677 pub fn as_str(self) -> &'static str {
682 match self {
683 Self::Approve => "approve",
684 Self::Reject => "reject",
685 Self::Comment => "comment",
686 Self::RequestChanges => "request_changes",
687 }
688 }
689}
690
691#[derive(Clone, Debug, PartialEq)]
692#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
693pub struct ProposalAppliedPayload {
694 pub proposal_id: Id128,
695 pub applied_at: crate::Timestamp,
696 pub applied_by: String,
697 pub result: ApplyResult,
698}
699
700#[derive(Clone, Debug, PartialEq)]
701#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
702#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
703pub enum ApplyResult {
704 Success {
705 created_records: Vec<Id128>,
706 },
707 Failed {
708 error: String,
709 applied_step_count: u32,
710 },
711}
712
713#[derive(Clone, Debug, PartialEq)]
714#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
715pub struct ProposalWithdrawnPayload {
716 pub proposal_id: Id128,
717 pub by: String,
718 pub reason: Option<String>,
719}
720
721pub struct EventBuilder {
723 verb: String,
724 substrate: SubstrateKind,
725 actor: Option<String>,
726 kind: EventKind,
727 payload: EventPayload,
728 payload_schema_version: u32,
729 profile_state_version: Option<u64>,
730 aggregate: Option<AggregateRef>,
731}
732
733impl EventBuilder {
734 pub fn new(
736 verb: impl Into<String>,
737 substrate: SubstrateKind,
738 actor: impl Into<String>,
739 ) -> Self {
740 Self {
741 verb: verb.into(),
742 substrate,
743 actor: Some(actor.into()),
744 kind: EventKind::Audit,
745 payload: EventPayload::default(),
746 payload_schema_version: 1,
747 profile_state_version: None,
748 aggregate: None,
749 }
750 }
751
752 pub fn kind(mut self, kind: EventKind) -> Self {
754 self.kind = kind;
755 self
756 }
757
758 pub fn payload(mut self, payload: EventPayload) -> Self {
760 self.payload = payload;
761 self
762 }
763
764 pub fn payload_schema_version(mut self, version: u32) -> Self {
766 self.payload_schema_version = version;
767 self
768 }
769
770 pub fn profile_state_version(mut self, version: u64) -> Self {
772 self.profile_state_version = Some(version);
773 self
774 }
775
776 pub fn aggregate(mut self, aggregate: AggregateRef) -> Self {
778 self.aggregate = Some(aggregate);
779 self
780 }
781
782 pub fn build(self, header: Header) -> Event {
784 Event {
785 header,
786 verb: self.verb,
787 substrate: self.substrate,
788 actor: self.actor,
789 kind: self.kind,
790 payload: self.payload,
791 payload_schema_version: self.payload_schema_version,
792 profile_state_version: self.profile_state_version,
793 aggregate: self.aggregate,
794 }
795 }
796}
797
798#[cfg(test)]
799mod tests {
800 extern crate alloc;
801
802 use super::*;
803 use crate::{Namespace, Timestamp};
804 #[cfg(feature = "serde")]
805 use alloc::string::ToString;
806
807 fn header() -> Header {
808 Header::new(
809 Id128::from_u128(1),
810 Namespace::local(),
811 Timestamp::from_secs(1700000000),
812 )
813 }
814
815 #[test]
816 fn event_kind_parse_roundtrip() {
817 for kind in EventKind::ALL {
818 let parsed: EventKind = kind
819 .name()
820 .parse()
821 .expect("EventKind::name must parse back");
822 assert_eq!(parsed, kind);
823 }
824 }
825
826 #[test]
827 fn rerank_payload_records_served_profile() {
828 let payload = EventPayload::RerankExecuted(RerankExecutedPayload {
829 served_by_profile_id: Some("profile-a".into()),
830 model_id: Id128::from_u128(1),
831 candidates: Vec::new(),
832 reranked: Vec::new(),
833 final_scores: Vec::new(),
834 latency_us: 100,
835 hook_applied: false,
836 hook_target_match: false,
837 });
838 let event = EventBuilder::new("rerank", SubstrateKind::Note, "agent:test")
839 .kind(EventKind::RerankExecuted)
840 .payload(payload)
841 .build(header());
842
843 if let EventPayload::RerankExecuted(ref p) = event.payload {
844 assert_eq!(p.served_by_profile_id.as_deref(), Some("profile-a"));
845 } else {
846 panic!("unexpected payload variant");
847 }
848 }
849
850 #[test]
851 fn proposal_payloads_are_typed() {
852 let payload = EventPayload::ProposalReviewed(ProposalReviewedPayload {
853 proposal_id: Id128::from_u128(42),
854 reviewer: "ocean".into(),
855 decision: ProposalDecision::Approve,
856 comment: None,
857 });
858 let event = EventBuilder::new("review", SubstrateKind::Entity, "ocean")
859 .kind(EventKind::ProposalReviewed)
860 .payload(payload)
861 .build(header());
862 assert_eq!(event.kind.name(), "proposal_reviewed");
863 }
864
865 #[cfg(feature = "serde")]
870 #[test]
871 fn proposal_changeset_id_variants_deserialize_from_value() {
872 let uuid = "7426afd6-0234-4701-9045-83dfd39166e6";
873 let uuid2 = "abcdef01-2345-6789-abcd-ef0123456789";
874
875 let v =
877 serde_json::json!({"kind": "update_entity", "id": uuid, "patch": {"name": "NewName"}});
878 let cs: ProposalChangeset =
879 serde_json::from_value(v).expect("UpdateEntity must deserialize from Value");
880 assert!(
881 matches!(cs, ProposalChangeset::UpdateEntity { .. }),
882 "expected UpdateEntity"
883 );
884
885 let v = serde_json::json!({
887 "kind": "add_edge",
888 "source": uuid, "target": uuid2,
889 "relation": "extends", "weight": 1.0
890 });
891 let cs: ProposalChangeset =
892 serde_json::from_value(v).expect("AddEdge must deserialize from Value");
893 assert!(
894 matches!(cs, ProposalChangeset::AddEdge { .. }),
895 "expected AddEdge"
896 );
897
898 let v = serde_json::json!({"kind": "merge_entities", "into": uuid, "from": uuid2});
900 let cs: ProposalChangeset =
901 serde_json::from_value(v).expect("MergeEntities must deserialize from Value");
902 assert!(
903 matches!(cs, ProposalChangeset::MergeEntities { .. }),
904 "expected MergeEntities"
905 );
906
907 let v = serde_json::json!({"kind": "supersede_entity", "old": uuid, "new": uuid2});
909 let cs: ProposalChangeset =
910 serde_json::from_value(v).expect("SupersedeEntity must deserialize from Value");
911 assert!(
912 matches!(cs, ProposalChangeset::SupersedeEntity { .. }),
913 "expected SupersedeEntity"
914 );
915 }
916
917 #[cfg(feature = "serde")]
918 #[test]
919 fn proposal_changeset_rejects_invalid_edge_weight() {
920 let uuid = "7426afd6-0234-4701-9045-83dfd39166e6";
921 let uuid2 = "abcdef01-2345-6789-abcd-ef0123456789";
922
923 let v = serde_json::json!({
924 "kind": "add_edge",
925 "source": uuid, "target": uuid2,
926 "relation": "extends", "weight": 2.0
927 });
928 let result: Result<ProposalChangeset, _> = serde_json::from_value(v);
929 assert!(result.is_err());
930 let err = result.unwrap_err().to_string();
931 assert!(
932 err.contains("[0.0, 1.0]"),
933 "error should mention range: {err}"
934 );
935 }
936
937 #[cfg(feature = "serde")]
938 #[test]
939 fn proposal_changeset_accepts_null_edge_weight() {
940 let uuid = "7426afd6-0234-4701-9045-83dfd39166e6";
941 let uuid2 = "abcdef01-2345-6789-abcd-ef0123456789";
942
943 let v = serde_json::json!({
944 "kind": "add_edge",
945 "source": uuid, "target": uuid2,
946 "relation": "extends", "weight": null
947 });
948 let cs: ProposalChangeset =
949 serde_json::from_value(v).expect("null weight should be accepted");
950 assert!(matches!(
951 cs,
952 ProposalChangeset::AddEdge { weight: None, .. }
953 ));
954 }
955
956 #[cfg(feature = "serde")]
957 #[test]
958 fn rerank_payload_serde_rejects_non_finite_score() {
959 let json = serde_json::json!({
960 "served_by_profile_id": null,
961 "model_id": "00000000-0000-0000-0000-000000000001",
962 "candidates": [],
963 "reranked": [],
964 "final_scores": [["00000000-0000-0000-0000-000000000001", "Infinity"]],
965 "latency_us": 100,
966 "hook_applied": false,
967 "hook_target_match": false
968 });
969 let result: Result<RerankExecutedPayload, _> = serde_json::from_value(json);
970 assert!(result.is_err());
971 }
972
973 #[test]
974 fn rerank_payload_is_valid_checks_finite() {
975 let p = RerankExecutedPayload {
976 served_by_profile_id: None,
977 model_id: Id128::from_u128(1),
978 candidates: Vec::new(),
979 reranked: Vec::new(),
980 final_scores: alloc::vec![(Id128::from_u128(1), 0.5)],
981 latency_us: 100,
982 hook_applied: false,
983 hook_target_match: false,
984 };
985 assert!(p.is_valid());
986
987 let p_inf = RerankExecutedPayload {
988 served_by_profile_id: None,
989 model_id: Id128::from_u128(1),
990 candidates: Vec::new(),
991 reranked: Vec::new(),
992 final_scores: alloc::vec![(Id128::from_u128(1), f32::INFINITY)],
993 latency_us: 100,
994 hook_applied: false,
995 hook_target_match: false,
996 };
997 assert!(!p_inf.is_valid());
998 }
999}