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")]
398#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
399pub struct ProposalCreatedPayload {
400 pub proposal_id: Id128,
401 pub proposer: String,
402 pub title: String,
403 pub description: String,
404 pub changeset: ProposalChangeset,
405 pub reviewers: Vec<String>,
406 pub expiry: Option<crate::Timestamp>,
407 pub parent_id: Option<Id128>,
408}
409
410#[cfg(feature = "serde")]
415#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
416pub struct EntityDraft {
417 pub kind: String,
419 pub name: String,
421 #[serde(skip_serializing_if = "Option::is_none")]
423 pub description: Option<String>,
424 #[serde(skip_serializing_if = "Option::is_none")]
426 pub properties: Option<serde_json::Value>,
427 #[serde(default, skip_serializing_if = "Vec::is_empty")]
429 pub tags: Vec<String>,
430}
431
432#[cfg(feature = "serde")]
436#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
437pub struct ProposalEntityPatch {
438 #[serde(skip_serializing_if = "Option::is_none")]
439 pub name: Option<String>,
440 #[serde(
442 default,
443 skip_serializing_if = "Option::is_none",
444 with = "serde_opt_opt"
445 )]
446 pub description: Option<Option<String>>,
447 #[serde(skip_serializing_if = "Option::is_none")]
448 pub properties: Option<serde_json::Value>,
449 #[serde(skip_serializing_if = "Option::is_none")]
450 pub tags: Option<Vec<String>>,
451}
452
453#[cfg(feature = "serde")]
457#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
458pub struct NoteDraft {
459 pub kind: String,
461 pub content: String,
463 #[serde(skip_serializing_if = "Option::is_none")]
465 pub name: Option<String>,
466 #[serde(skip_serializing_if = "Option::is_none")]
468 pub properties: Option<serde_json::Value>,
469}
470
471#[cfg(feature = "serde")]
473mod serde_opt_opt {
474 use serde::{Deserialize, Deserializer, Serialize, Serializer};
475
476 pub fn serialize<T, S>(val: &Option<Option<T>>, s: S) -> Result<S::Ok, S::Error>
477 where
478 T: Serialize,
479 S: Serializer,
480 {
481 match val {
482 None => unreachable!("skip_serializing_if guards the None case"),
483 Some(inner) => inner.serialize(s),
484 }
485 }
486
487 pub fn deserialize<'de, T, D>(d: D) -> Result<Option<Option<T>>, D::Error>
488 where
489 T: Deserialize<'de>,
490 D: Deserializer<'de>,
491 {
492 let opt: Option<T> = Option::deserialize(d)?;
493 Ok(Some(opt))
494 }
495}
496
497#[cfg(feature = "serde")]
499#[derive(Clone, Debug, PartialEq, serde::Serialize)]
500#[serde(tag = "kind", rename_all = "snake_case")]
501pub enum ProposalChangeset {
502 AddEntity {
504 entity: EntityDraft,
505 },
506 UpdateEntity {
508 id: Id128,
509 patch: ProposalEntityPatch,
510 },
511 AddEdge {
513 source: Id128,
514 target: Id128,
515 relation: crate::EdgeRelation,
516 weight: Option<f32>,
517 },
518 AddNote {
520 note: NoteDraft,
521 },
522 MergeEntities {
523 into: Id128,
524 from: Id128,
525 },
526 SupersedeEntity {
527 old: Id128,
528 new: Id128,
529 },
530 Compound {
531 steps: Vec<ProposalChangeset>,
532 },
533}
534
535#[cfg(feature = "serde")]
536impl ProposalChangeset {
537 fn validate(&self) -> Result<(), alloc::string::String> {
538 match self {
539 Self::AddEdge { weight, .. } => {
540 if let Some(w) = weight {
541 if !w.is_finite() {
542 return Err(alloc::format!(
543 "ProposalChangeset AddEdge weight must be finite, got {w}"
544 ));
545 }
546 if !(*w >= 0.0 && *w <= 1.0) {
547 return Err(alloc::format!(
548 "ProposalChangeset AddEdge weight must be in [0.0, 1.0], got {w}"
549 ));
550 }
551 }
552 Ok(())
553 }
554 Self::Compound { steps } => {
555 for step in steps {
556 step.validate()?;
557 }
558 Ok(())
559 }
560 _ => Ok(()),
561 }
562 }
563}
564
565#[cfg(feature = "serde")]
566impl<'de> serde::Deserialize<'de> for ProposalChangeset {
567 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
568 where
569 D: serde::Deserializer<'de>,
570 {
571 #[derive(serde::Deserialize)]
572 #[serde(tag = "kind", rename_all = "snake_case")]
573 enum ProposalChangesetRaw {
574 AddEntity {
575 entity: EntityDraft,
576 },
577 UpdateEntity {
578 id: Id128,
579 patch: ProposalEntityPatch,
580 },
581 AddEdge {
582 source: Id128,
583 target: Id128,
584 relation: crate::EdgeRelation,
585 weight: Option<f32>,
586 },
587 AddNote {
588 note: NoteDraft,
589 },
590 MergeEntities {
591 into: Id128,
592 from: Id128,
593 },
594 SupersedeEntity {
595 old: Id128,
596 new: Id128,
597 },
598 Compound {
599 steps: Vec<ProposalChangeset>,
600 },
601 }
602
603 let raw = ProposalChangesetRaw::deserialize(deserializer)?;
604 let cs = match raw {
605 ProposalChangesetRaw::AddEntity { entity } => Self::AddEntity { entity },
606 ProposalChangesetRaw::UpdateEntity { id, patch } => Self::UpdateEntity { id, patch },
607 ProposalChangesetRaw::AddEdge {
608 source,
609 target,
610 relation,
611 weight,
612 } => Self::AddEdge {
613 source,
614 target,
615 relation,
616 weight,
617 },
618 ProposalChangesetRaw::AddNote { note } => Self::AddNote { note },
619 ProposalChangesetRaw::MergeEntities { into, from } => {
620 Self::MergeEntities { into, from }
621 }
622 ProposalChangesetRaw::SupersedeEntity { old, new } => {
623 Self::SupersedeEntity { old, new }
624 }
625 ProposalChangesetRaw::Compound { steps } => Self::Compound { steps },
626 };
627 cs.validate().map_err(serde::de::Error::custom)?;
628 Ok(cs)
629 }
630}
631
632#[cfg(not(feature = "serde"))]
633#[derive(Clone, Debug, PartialEq)]
634pub enum ProposalChangeset {
635 AddEdge {
636 source: Id128,
637 target: Id128,
638 relation: crate::EdgeRelation,
639 weight: Option<f32>,
640 },
641 MergeEntities {
642 into: Id128,
643 from: Id128,
644 },
645 SupersedeEntity {
646 old: Id128,
647 new: Id128,
648 },
649 Compound {
650 steps: Vec<ProposalChangeset>,
651 },
652}
653
654#[derive(Clone, Debug, PartialEq)]
656#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
657pub struct ProposalReviewedPayload {
658 pub proposal_id: Id128,
659 pub reviewer: String,
660 pub decision: ProposalDecision,
661 pub comment: Option<String>,
662}
663
664#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
666#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
667#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
668pub enum ProposalDecision {
669 Approve,
671 Reject,
673 Comment,
675 RequestChanges,
677}
678
679impl ProposalDecision {
680 pub fn as_str(self) -> &'static str {
685 match self {
686 Self::Approve => "approve",
687 Self::Reject => "reject",
688 Self::Comment => "comment",
689 Self::RequestChanges => "request_changes",
690 }
691 }
692}
693
694#[derive(Clone, Debug, PartialEq)]
696#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
697pub struct ProposalAppliedPayload {
698 pub proposal_id: Id128,
699 pub applied_at: crate::Timestamp,
700 pub applied_by: String,
701 pub result: ApplyResult,
702}
703
704#[derive(Clone, Debug, PartialEq)]
706#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
707#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
708pub enum ApplyResult {
709 Success {
710 created_records: Vec<Id128>,
711 },
712 Failed {
713 error: String,
714 applied_step_count: u32,
715 },
716}
717
718#[derive(Clone, Debug, PartialEq)]
720#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
721pub struct ProposalWithdrawnPayload {
722 pub proposal_id: Id128,
723 pub by: String,
724 pub reason: Option<String>,
725}
726
727pub struct EventBuilder {
729 verb: String,
730 substrate: SubstrateKind,
731 actor: Option<String>,
732 kind: EventKind,
733 payload: EventPayload,
734 payload_schema_version: u32,
735 profile_state_version: Option<u64>,
736 aggregate: Option<AggregateRef>,
737}
738
739impl EventBuilder {
740 pub fn new(
742 verb: impl Into<String>,
743 substrate: SubstrateKind,
744 actor: impl Into<String>,
745 ) -> Self {
746 Self {
747 verb: verb.into(),
748 substrate,
749 actor: Some(actor.into()),
750 kind: EventKind::Audit,
751 payload: EventPayload::default(),
752 payload_schema_version: 1,
753 profile_state_version: None,
754 aggregate: None,
755 }
756 }
757
758 pub fn kind(mut self, kind: EventKind) -> Self {
760 self.kind = kind;
761 self
762 }
763
764 pub fn payload(mut self, payload: EventPayload) -> Self {
766 self.payload = payload;
767 self
768 }
769
770 pub fn payload_schema_version(mut self, version: u32) -> Self {
772 self.payload_schema_version = version;
773 self
774 }
775
776 pub fn profile_state_version(mut self, version: u64) -> Self {
778 self.profile_state_version = Some(version);
779 self
780 }
781
782 pub fn aggregate(mut self, aggregate: AggregateRef) -> Self {
784 self.aggregate = Some(aggregate);
785 self
786 }
787
788 pub fn build(self, header: Header) -> Event {
790 Event {
791 header,
792 verb: self.verb,
793 substrate: self.substrate,
794 actor: self.actor,
795 kind: self.kind,
796 payload: self.payload,
797 payload_schema_version: self.payload_schema_version,
798 profile_state_version: self.profile_state_version,
799 aggregate: self.aggregate,
800 }
801 }
802}
803
804#[cfg(test)]
805mod tests {
806 extern crate alloc;
807
808 use super::*;
809 use crate::{Namespace, Timestamp};
810 #[cfg(feature = "serde")]
811 use alloc::string::ToString;
812
813 fn header() -> Header {
814 Header::new(
815 Id128::from_u128(1),
816 Namespace::local(),
817 Timestamp::from_secs(1700000000),
818 )
819 }
820
821 #[test]
822 fn event_kind_parse_roundtrip() {
823 for kind in EventKind::ALL {
824 let parsed: EventKind = kind
825 .name()
826 .parse()
827 .expect("EventKind::name must parse back");
828 assert_eq!(parsed, kind);
829 }
830 }
831
832 #[test]
833 fn rerank_payload_records_served_profile() {
834 let payload = EventPayload::RerankExecuted(RerankExecutedPayload {
835 served_by_profile_id: Some("profile-a".into()),
836 model_id: Id128::from_u128(1),
837 candidates: Vec::new(),
838 reranked: Vec::new(),
839 final_scores: Vec::new(),
840 latency_us: 100,
841 hook_applied: false,
842 hook_target_match: false,
843 });
844 let event = EventBuilder::new("rerank", SubstrateKind::Note, "agent:test")
845 .kind(EventKind::RerankExecuted)
846 .payload(payload)
847 .build(header());
848
849 if let EventPayload::RerankExecuted(ref p) = event.payload {
850 assert_eq!(p.served_by_profile_id.as_deref(), Some("profile-a"));
851 } else {
852 panic!("unexpected payload variant");
853 }
854 }
855
856 #[test]
857 fn proposal_payloads_are_typed() {
858 let payload = EventPayload::ProposalReviewed(ProposalReviewedPayload {
859 proposal_id: Id128::from_u128(42),
860 reviewer: "ocean".into(),
861 decision: ProposalDecision::Approve,
862 comment: None,
863 });
864 let event = EventBuilder::new("review", SubstrateKind::Entity, "ocean")
865 .kind(EventKind::ProposalReviewed)
866 .payload(payload)
867 .build(header());
868 assert_eq!(event.kind.name(), "proposal_reviewed");
869 }
870
871 #[cfg(feature = "serde")]
876 #[test]
877 fn proposal_changeset_id_variants_deserialize_from_value() {
878 let uuid = "7426afd6-0234-4701-9045-83dfd39166e6";
879 let uuid2 = "abcdef01-2345-6789-abcd-ef0123456789";
880
881 let v =
883 serde_json::json!({"kind": "update_entity", "id": uuid, "patch": {"name": "NewName"}});
884 let cs: ProposalChangeset =
885 serde_json::from_value(v).expect("UpdateEntity must deserialize from Value");
886 assert!(
887 matches!(cs, ProposalChangeset::UpdateEntity { .. }),
888 "expected UpdateEntity"
889 );
890
891 let v = serde_json::json!({
893 "kind": "add_edge",
894 "source": uuid, "target": uuid2,
895 "relation": "extends", "weight": 1.0
896 });
897 let cs: ProposalChangeset =
898 serde_json::from_value(v).expect("AddEdge must deserialize from Value");
899 assert!(
900 matches!(cs, ProposalChangeset::AddEdge { .. }),
901 "expected AddEdge"
902 );
903
904 let v = serde_json::json!({"kind": "merge_entities", "into": uuid, "from": uuid2});
906 let cs: ProposalChangeset =
907 serde_json::from_value(v).expect("MergeEntities must deserialize from Value");
908 assert!(
909 matches!(cs, ProposalChangeset::MergeEntities { .. }),
910 "expected MergeEntities"
911 );
912
913 let v = serde_json::json!({"kind": "supersede_entity", "old": uuid, "new": uuid2});
915 let cs: ProposalChangeset =
916 serde_json::from_value(v).expect("SupersedeEntity must deserialize from Value");
917 assert!(
918 matches!(cs, ProposalChangeset::SupersedeEntity { .. }),
919 "expected SupersedeEntity"
920 );
921 }
922
923 #[cfg(feature = "serde")]
924 #[test]
925 fn proposal_changeset_rejects_invalid_edge_weight() {
926 let uuid = "7426afd6-0234-4701-9045-83dfd39166e6";
927 let uuid2 = "abcdef01-2345-6789-abcd-ef0123456789";
928
929 let v = serde_json::json!({
930 "kind": "add_edge",
931 "source": uuid, "target": uuid2,
932 "relation": "extends", "weight": 2.0
933 });
934 let result: Result<ProposalChangeset, _> = serde_json::from_value(v);
935 assert!(result.is_err());
936 let err = result.unwrap_err().to_string();
937 assert!(
938 err.contains("[0.0, 1.0]"),
939 "error should mention range: {err}"
940 );
941 }
942
943 #[cfg(feature = "serde")]
944 #[test]
945 fn proposal_changeset_accepts_null_edge_weight() {
946 let uuid = "7426afd6-0234-4701-9045-83dfd39166e6";
947 let uuid2 = "abcdef01-2345-6789-abcd-ef0123456789";
948
949 let v = serde_json::json!({
950 "kind": "add_edge",
951 "source": uuid, "target": uuid2,
952 "relation": "extends", "weight": null
953 });
954 let cs: ProposalChangeset =
955 serde_json::from_value(v).expect("null weight should be accepted");
956 assert!(matches!(
957 cs,
958 ProposalChangeset::AddEdge { weight: None, .. }
959 ));
960 }
961
962 #[cfg(feature = "serde")]
963 #[test]
964 fn rerank_payload_serde_rejects_non_finite_score() {
965 let json = serde_json::json!({
966 "served_by_profile_id": null,
967 "model_id": "00000000-0000-0000-0000-000000000001",
968 "candidates": [],
969 "reranked": [],
970 "final_scores": [["00000000-0000-0000-0000-000000000001", "Infinity"]],
971 "latency_us": 100,
972 "hook_applied": false,
973 "hook_target_match": false
974 });
975 let result: Result<RerankExecutedPayload, _> = serde_json::from_value(json);
976 assert!(result.is_err());
977 }
978
979 #[test]
980 fn rerank_payload_is_valid_checks_finite() {
981 let p = RerankExecutedPayload {
982 served_by_profile_id: None,
983 model_id: Id128::from_u128(1),
984 candidates: Vec::new(),
985 reranked: Vec::new(),
986 final_scores: alloc::vec![(Id128::from_u128(1), 0.5)],
987 latency_us: 100,
988 hook_applied: false,
989 hook_target_match: false,
990 };
991 assert!(p.is_valid());
992
993 let p_inf = RerankExecutedPayload {
994 served_by_profile_id: None,
995 model_id: Id128::from_u128(1),
996 candidates: Vec::new(),
997 reranked: Vec::new(),
998 final_scores: alloc::vec![(Id128::from_u128(1), f32::INFINITY)],
999 latency_us: 100,
1000 hook_applied: false,
1001 hook_target_match: false,
1002 };
1003 assert!(!p_inf.is_valid());
1004 }
1005}