Skip to main content

converge_pack/
fact.rs

1// Copyright 2024-2026 Reflective Labs
2// SPDX-License-Identifier: MIT
3
4//! Facts and proposed facts — the type boundary.
5//!
6//! This is the most important design decision in Converge: LLMs suggest,
7//! the engine validates. `ProposedFact` is not `Fact`. There is no implicit
8//! conversion between them.
9
10use serde::{Deserialize, Serialize};
11
12use crate::context::ContextKey;
13use crate::types::{
14    ActorId, ApprovalId, ArtifactId, ContentHash, FactId, GateId, ObservationId, ProposalId,
15    SpanId, Timestamp, TraceId, TraceReference, TraceSystemId, UnitInterval, ValidationCheckId,
16};
17
18/// Actor kind recorded on a promoted fact.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20pub enum FactActorKind {
21    /// Human approver.
22    Human,
23    /// Suggestor or automated domain actor.
24    Suggestor,
25    /// Kernel or system component.
26    System,
27}
28
29/// Read-only actor record attached to authoritative facts.
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31pub struct FactActor {
32    id: ActorId,
33    kind: FactActorKind,
34}
35
36impl FactActor {
37    /// Returns the actor identifier.
38    #[must_use]
39    pub fn id(&self) -> &ActorId {
40        &self.id
41    }
42
43    /// Returns the actor kind.
44    #[must_use]
45    pub fn kind(&self) -> FactActorKind {
46        self.kind
47    }
48
49    #[doc(hidden)]
50    pub fn new_projection(id: impl Into<ActorId>, kind: FactActorKind) -> Self {
51        Self {
52            id: id.into(),
53            kind,
54        }
55    }
56}
57
58/// Summary of validation checks attached to an authoritative fact.
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
60pub struct FactValidationSummary {
61    checks_passed: Vec<ValidationCheckId>,
62    checks_skipped: Vec<ValidationCheckId>,
63    warnings: Vec<String>,
64}
65
66impl FactValidationSummary {
67    /// Returns validation checks that passed.
68    #[must_use]
69    pub fn checks_passed(&self) -> &[ValidationCheckId] {
70        &self.checks_passed
71    }
72
73    /// Returns validation checks that were skipped.
74    #[must_use]
75    pub fn checks_skipped(&self) -> &[ValidationCheckId] {
76        &self.checks_skipped
77    }
78
79    /// Returns validation warnings.
80    #[must_use]
81    pub fn warnings(&self) -> &[String] {
82        &self.warnings
83    }
84
85    #[doc(hidden)]
86    pub fn new_projection(
87        checks_passed: Vec<ValidationCheckId>,
88        checks_skipped: Vec<ValidationCheckId>,
89        warnings: Vec<String>,
90    ) -> Self {
91        Self {
92            checks_passed,
93            checks_skipped,
94            warnings,
95        }
96    }
97}
98
99/// Typed evidence references attached to an authoritative fact.
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101#[serde(tag = "type", content = "id")]
102pub enum FactEvidenceRef {
103    /// Observation used as evidence.
104    Observation(ObservationId),
105    /// Human approval used as evidence.
106    HumanApproval(ApprovalId),
107    /// Derived artifact used as evidence.
108    Derived(ArtifactId),
109}
110
111/// Local replayable trace reference.
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113pub struct FactLocalTrace {
114    trace_id: TraceId,
115    span_id: SpanId,
116    parent_span_id: Option<SpanId>,
117    sampled: bool,
118}
119
120impl FactLocalTrace {
121    /// Returns the trace identifier.
122    #[must_use]
123    pub fn trace_id(&self) -> &TraceId {
124        &self.trace_id
125    }
126
127    /// Returns the span identifier.
128    #[must_use]
129    pub fn span_id(&self) -> &SpanId {
130        &self.span_id
131    }
132
133    /// Returns the parent span identifier.
134    #[must_use]
135    pub fn parent_span_id(&self) -> Option<&SpanId> {
136        self.parent_span_id.as_ref()
137    }
138
139    /// Returns whether the trace was sampled.
140    #[must_use]
141    pub fn sampled(&self) -> bool {
142        self.sampled
143    }
144
145    #[doc(hidden)]
146    pub fn new_projection(
147        trace_id: impl Into<TraceId>,
148        span_id: impl Into<SpanId>,
149        parent_span_id: Option<SpanId>,
150        sampled: bool,
151    ) -> Self {
152        Self {
153            trace_id: trace_id.into(),
154            span_id: span_id.into(),
155            parent_span_id,
156            sampled,
157        }
158    }
159}
160
161/// Remote audit-only trace reference.
162#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
163pub struct FactRemoteTrace {
164    system: TraceSystemId,
165    reference: TraceReference,
166    retrieval_auth: Option<String>,
167    retention_hint: Option<String>,
168}
169
170impl FactRemoteTrace {
171    /// Returns the remote system identifier.
172    #[must_use]
173    pub fn system(&self) -> &TraceSystemId {
174        &self.system
175    }
176
177    /// Returns the remote trace reference.
178    #[must_use]
179    pub fn reference(&self) -> &TraceReference {
180        &self.reference
181    }
182
183    /// Returns the retrieval auth hint.
184    #[must_use]
185    pub fn retrieval_auth(&self) -> Option<&str> {
186        self.retrieval_auth.as_deref()
187    }
188
189    /// Returns the retention hint.
190    #[must_use]
191    pub fn retention_hint(&self) -> Option<&str> {
192        self.retention_hint.as_deref()
193    }
194
195    #[doc(hidden)]
196    pub fn new_projection(
197        system: impl Into<TraceSystemId>,
198        reference: impl Into<TraceReference>,
199        retrieval_auth: Option<String>,
200        retention_hint: Option<String>,
201    ) -> Self {
202        Self {
203            system: system.into(),
204            reference: reference.into(),
205            retrieval_auth,
206            retention_hint,
207        }
208    }
209}
210
211/// Trace record attached to an authoritative fact.
212#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
213#[serde(tag = "type")]
214pub enum FactTraceLink {
215    /// Local replayable trace.
216    Local(FactLocalTrace),
217    /// Remote audit-only trace.
218    Remote(FactRemoteTrace),
219}
220
221impl FactTraceLink {
222    /// Returns whether the trace is replay-eligible.
223    #[must_use]
224    pub fn is_replay_eligible(&self) -> bool {
225        matches!(self, Self::Local(_))
226    }
227}
228
229/// Read-only promotion record attached to an authoritative fact.
230#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
231pub struct FactPromotionRecord {
232    gate_id: GateId,
233    policy_version_hash: ContentHash,
234    approver: FactActor,
235    validation_summary: FactValidationSummary,
236    evidence_refs: Vec<FactEvidenceRef>,
237    trace_link: FactTraceLink,
238    promoted_at: Timestamp,
239}
240
241impl FactPromotionRecord {
242    /// Returns the gate identifier that promoted the fact.
243    #[must_use]
244    pub fn gate_id(&self) -> &GateId {
245        &self.gate_id
246    }
247
248    /// Returns the policy hash used during promotion.
249    #[must_use]
250    pub fn policy_version_hash(&self) -> &ContentHash {
251        &self.policy_version_hash
252    }
253
254    /// Returns the approving actor.
255    #[must_use]
256    pub fn approver(&self) -> &FactActor {
257        &self.approver
258    }
259
260    /// Returns the validation summary.
261    #[must_use]
262    pub fn validation_summary(&self) -> &FactValidationSummary {
263        &self.validation_summary
264    }
265
266    /// Returns the evidence references used during promotion.
267    #[must_use]
268    pub fn evidence_refs(&self) -> &[FactEvidenceRef] {
269        &self.evidence_refs
270    }
271
272    /// Returns the trace link for audit or replay.
273    #[must_use]
274    pub fn trace_link(&self) -> &FactTraceLink {
275        &self.trace_link
276    }
277
278    /// Returns the promotion timestamp.
279    #[must_use]
280    pub fn promoted_at(&self) -> &Timestamp {
281        &self.promoted_at
282    }
283
284    /// Returns whether the promotion is replay-eligible.
285    #[must_use]
286    pub fn is_replay_eligible(&self) -> bool {
287        self.trace_link.is_replay_eligible()
288    }
289
290    #[doc(hidden)]
291    pub fn new_projection(
292        gate_id: impl Into<GateId>,
293        policy_version_hash: ContentHash,
294        approver: FactActor,
295        validation_summary: FactValidationSummary,
296        evidence_refs: Vec<FactEvidenceRef>,
297        trace_link: FactTraceLink,
298        promoted_at: impl Into<Timestamp>,
299    ) -> Self {
300        Self {
301            gate_id: gate_id.into(),
302            policy_version_hash,
303            approver,
304            validation_summary,
305            evidence_refs,
306            trace_link,
307            promoted_at: promoted_at.into(),
308        }
309    }
310}
311
312/// Read-only projection of a validated assertion in the context.
313///
314/// This type is not promotion authority. It is the value suggestors and
315/// pack authors can read from context after the engine has promoted a
316/// proposal. Constructing one locally does not admit it into Converge; there is
317/// no public API that accepts a `ContextFact` as promoted truth.
318#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
319pub struct ContextFact {
320    /// Which context key this fact belongs to.
321    key: ContextKey,
322    /// Unique identifier within the context key namespace.
323    id: FactId,
324    /// The fact's content as a string. Interpretation is key-dependent.
325    content: String,
326    /// The immutable promotion record that made this fact authoritative.
327    promotion_record: FactPromotionRecord,
328    /// When the authoritative fact entered context.
329    created_at: Timestamp,
330}
331
332impl ContextFact {
333    /// Creates a read-only context projection.
334    ///
335    /// This constructor does not promote anything and is intentionally named as
336    /// a projection constructor. The engine is still the only component that can
337    /// add context facts to a live `ContextState`.
338    #[must_use]
339    pub fn new_projection(
340        key: ContextKey,
341        id: impl Into<FactId>,
342        content: impl Into<String>,
343        promotion_record: FactPromotionRecord,
344        created_at: impl Into<Timestamp>,
345    ) -> Self {
346        Self {
347            key,
348            id: id.into(),
349            content: content.into(),
350            promotion_record,
351            created_at: created_at.into(),
352        }
353    }
354
355    /// Returns the context key this fact belongs to.
356    #[must_use]
357    pub fn key(&self) -> ContextKey {
358        self.key
359    }
360
361    /// Returns the fact identifier.
362    #[must_use]
363    pub fn id(&self) -> &FactId {
364        &self.id
365    }
366
367    /// Returns the fact content.
368    #[must_use]
369    pub fn content(&self) -> &str {
370        &self.content
371    }
372
373    /// Returns the immutable promotion record for this fact.
374    #[must_use]
375    pub fn promotion_record(&self) -> &FactPromotionRecord {
376        &self.promotion_record
377    }
378
379    /// Returns the fact creation timestamp.
380    #[must_use]
381    pub fn created_at(&self) -> &Timestamp {
382        &self.created_at
383    }
384
385    /// Returns whether the fact is replay-eligible.
386    #[must_use]
387    pub fn is_replay_eligible(&self) -> bool {
388        self.promotion_record.is_replay_eligible()
389    }
390
391    /// Parse the fact's content as JSON into a typed value.
392    ///
393    /// This helper is deliberately named for JSON and preserves parse errors.
394    /// Callers that use another representation should parse `content` with
395    /// that representation's decoder.
396    pub fn parse_json_content<T: serde::de::DeserializeOwned>(&self) -> serde_json::Result<T> {
397        serde_json::from_str(&self.content)
398    }
399}
400
401/// An unvalidated suggestion from a non-authoritative source.
402///
403/// Proposed facts live in `ContextKey::Proposals` until a `ValidationAgent`
404/// promotes them to `Fact`. The proposal tracks its origin for audit trail.
405#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
406pub struct ProposedFact {
407    /// The context key this proposal targets.
408    pub key: ContextKey,
409    /// Unique identifier encoding origin and target.
410    pub id: ProposalId,
411    /// The proposed content.
412    pub content: String,
413    /// Confidence hint from the source. Always in [0.0, 1.0].
414    confidence: UnitInterval,
415    /// Provenance information (e.g., model ID, prompt hash).
416    pub provenance: String,
417}
418
419impl ProposedFact {
420    /// Create a new draft proposal with explicit provenance.
421    ///
422    /// Confidence defaults to 1.0. Override with [`with_confidence`][Self::with_confidence].
423    #[must_use]
424    pub fn new(
425        key: ContextKey,
426        id: impl Into<ProposalId>,
427        content: impl Into<String>,
428        provenance: impl Into<String>,
429    ) -> Self {
430        Self {
431            key,
432            id: id.into(),
433            content: content.into(),
434            confidence: UnitInterval::ONE,
435            provenance: provenance.into(),
436        }
437    }
438
439    /// Returns the context key this proposal targets.
440    #[must_use]
441    pub fn key(&self) -> ContextKey {
442        self.key
443    }
444
445    /// Returns the proposal identifier.
446    #[must_use]
447    pub fn id(&self) -> &ProposalId {
448        &self.id
449    }
450
451    /// Returns the proposed content.
452    #[must_use]
453    pub fn content(&self) -> &str {
454        &self.content
455    }
456
457    /// Returns the proposal provenance string.
458    #[must_use]
459    pub fn provenance(&self) -> &str {
460        &self.provenance
461    }
462
463    /// Returns the confidence value, always in [0.0, 1.0].
464    #[must_use]
465    pub fn confidence(&self) -> f64 {
466        self.confidence.as_f64()
467    }
468
469    /// Set an explicit confidence baseline for this proposal.
470    ///
471    /// Use this to establish a starting point, then accumulate criteria with
472    /// [`adjust_confidence`][Self::adjust_confidence]. The value is clamped to
473    /// [0.0, 1.0]; non-finite values (NaN, infinity) are treated as 0.0.
474    ///
475    /// For computed confidence (e.g. from a solver), pass the result directly.
476    #[must_use]
477    pub fn with_confidence(mut self, confidence: f64) -> Self {
478        self.confidence = UnitInterval::clamped(confidence);
479        self
480    }
481
482    /// Parse the proposal's content as JSON into a typed value.
483    ///
484    /// This helper is deliberately named for JSON and preserves parse errors.
485    /// Callers that use another representation should parse `content` with
486    /// that representation's decoder.
487    pub fn parse_json_content<T: serde::de::DeserializeOwned>(&self) -> serde_json::Result<T> {
488        serde_json::from_str(&self.content)
489    }
490
491    /// Construct a proposal whose content is `payload` serialized to JSON.
492    ///
493    /// Symmetric with [`parse_json_content`][Self::parse_json_content] and named
494    /// for the same reason: callers using another representation should serialize
495    /// `content` themselves and pass it to [`new`][Self::new].
496    ///
497    /// Returns a `serde_json::Error` only if `T` is non-representable as JSON
498    /// (e.g. floats with NaN, maps with non-string keys). For payload types that
499    /// are always representable, callers can `.expect("payload always serializable")`.
500    pub fn from_json_payload<T: serde::Serialize>(
501        key: ContextKey,
502        id: impl Into<ProposalId>,
503        payload: &T,
504        provenance: impl Into<String>,
505    ) -> serde_json::Result<Self> {
506        Ok(Self::new(
507            key,
508            id,
509            serde_json::to_string(payload)?,
510            provenance,
511        ))
512    }
513
514    /// Adjust confidence by a named step, clamped to [0.0, 1.0].
515    ///
516    /// This is the recommended way to express confidence in suggestors and pack
517    /// solvers. Use the `CONFIDENCE_STEP_*` constants as the vocabulary:
518    ///
519    /// ```rust,ignore
520    /// use converge_pack::{CONFIDENCE_STEP_MAJOR, CONFIDENCE_STEP_MINOR, CONFIDENCE_STEP_TINY};
521    ///
522    /// let proposal = ProposedFact::new(key, id, content, prov)
523    ///     .with_confidence(0.5)                        // baseline
524    ///     .adjust_confidence(CONFIDENCE_STEP_MAJOR)    // primary criterion met
525    ///     .adjust_confidence(CONFIDENCE_STEP_MINOR)    // supporting criterion met
526    ///     .adjust_confidence(CONFIDENCE_STEP_TINY);    // tiebreaker bonus
527    /// ```
528    ///
529    /// Prefer this over accumulating a local `f64` and calling `with_confidence`
530    /// at the end — the clamping is automatic and the intent is explicit at each step.
531    #[must_use]
532    pub fn adjust_confidence(mut self, delta: f64) -> Self {
533        self.confidence = self.confidence.saturating_add(delta);
534        self
535    }
536}
537
538/// Tiny confidence step — use for marginal or tiebreaker criteria (0.05).
539pub const CONFIDENCE_STEP_TINY: f64 = 0.05;
540
541/// Minor confidence step — use for supporting criteria (0.1).
542pub const CONFIDENCE_STEP_MINOR: f64 = 0.1;
543
544/// Medium confidence step — use for moderately significant criteria (0.15).
545pub const CONFIDENCE_STEP_MEDIUM: f64 = 0.15;
546
547/// Major confidence step — use for significant criteria (0.2).
548pub const CONFIDENCE_STEP_MAJOR: f64 = 0.2;
549
550/// Primary confidence step — use for decisive or high-weight criteria (0.25).
551pub const CONFIDENCE_STEP_PRIMARY: f64 = 0.25;
552
553/// Error when a `ProposedFact` fails validation.
554#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
555pub struct ValidationError {
556    /// Reason the proposal was rejected.
557    pub reason: String,
558}
559
560impl std::fmt::Display for ValidationError {
561    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
562        write!(f, "validation failed: {}", self.reason)
563    }
564}
565
566impl std::error::Error for ValidationError {}
567
568#[cfg(test)]
569mod tests {
570    use super::*;
571
572    fn projection_record() -> FactPromotionRecord {
573        FactPromotionRecord::new_projection(
574            "projection-test",
575            ContentHash::from_hex(
576                "1111111111111111111111111111111111111111111111111111111111111111",
577            ),
578            FactActor::new_projection("actor-1", FactActorKind::System),
579            FactValidationSummary::default(),
580            Vec::new(),
581            FactTraceLink::Local(FactLocalTrace::new_projection(
582                "trace-1", "span-1", None, true,
583            )),
584            Timestamp::epoch(),
585        )
586    }
587
588    fn projection_fact(
589        key: ContextKey,
590        id: impl Into<FactId>,
591        content: impl Into<String>,
592    ) -> ContextFact {
593        ContextFact::new_projection(key, id, content, projection_record(), Timestamp::epoch())
594    }
595
596    #[test]
597    fn trace_link_local_is_replay_eligible() {
598        let local = FactTraceLink::Local(FactLocalTrace {
599            trace_id: "t1".into(),
600            span_id: "s1".into(),
601            parent_span_id: None,
602            sampled: true,
603        });
604        assert!(local.is_replay_eligible());
605    }
606
607    #[test]
608    fn trace_link_remote_is_not_replay_eligible() {
609        let remote = FactTraceLink::Remote(FactRemoteTrace {
610            system: "datadog".into(),
611            reference: "ref-1".into(),
612            retrieval_auth: None,
613            retention_hint: None,
614        });
615        assert!(!remote.is_replay_eligible());
616    }
617
618    #[test]
619    fn promotion_record_delegates_replay_eligibility() {
620        let local_record = FactPromotionRecord::new_projection(
621            "gate-1",
622            ContentHash::from_hex(
623                "1111111111111111111111111111111111111111111111111111111111111111",
624            ),
625            FactActor::new_projection("actor-1", FactActorKind::Human),
626            FactValidationSummary::default(),
627            Vec::new(),
628            FactTraceLink::Local(FactLocalTrace::new_projection("t1", "s1", None, true)),
629            "2026-01-01T00:00:00Z",
630        );
631        assert!(local_record.is_replay_eligible());
632
633        let remote_record = FactPromotionRecord::new_projection(
634            "gate-2",
635            ContentHash::from_hex(
636                "2222222222222222222222222222222222222222222222222222222222222222",
637            ),
638            FactActor::new_projection("actor-2", FactActorKind::System),
639            FactValidationSummary::default(),
640            Vec::new(),
641            FactTraceLink::Remote(FactRemoteTrace::new_projection("dd", "ref-1", None, None)),
642            "2026-01-01T00:00:00Z",
643        );
644        assert!(!remote_record.is_replay_eligible());
645    }
646
647    #[test]
648    fn fact_delegates_replay_eligibility() {
649        let fact = projection_fact(ContextKey::Seeds, "f1", "content");
650        assert!(fact.is_replay_eligible());
651    }
652
653    #[test]
654    fn proposed_fact_new_sets_fields() {
655        let pf = ProposedFact::new(ContextKey::Hypotheses, "p1", "my content", "gpt-4");
656        assert_eq!(pf.key, ContextKey::Hypotheses);
657        assert_eq!(pf.id, "p1");
658        assert_eq!(pf.content, "my content");
659        assert_eq!(pf.confidence(), 1.0);
660        assert_eq!(pf.provenance, "gpt-4");
661    }
662
663    #[test]
664    fn proposed_fact_with_confidence() {
665        let pf = ProposedFact::new(ContextKey::Signals, "p2", "c", "prov").with_confidence(0.42);
666        assert!((pf.confidence() - 0.42).abs() < f64::EPSILON);
667    }
668
669    #[test]
670    fn adjust_confidence_accumulates() {
671        let pf = ProposedFact::new(ContextKey::Seeds, "p", "c", "x")
672            .with_confidence(0.5)
673            .adjust_confidence(CONFIDENCE_STEP_MINOR)
674            .adjust_confidence(CONFIDENCE_STEP_MAJOR);
675        assert!((pf.confidence() - 0.8).abs() < f64::EPSILON);
676    }
677
678    #[test]
679    fn adjust_confidence_clamps_at_one() {
680        let pf = ProposedFact::new(ContextKey::Seeds, "p", "c", "x")
681            .with_confidence(0.9)
682            .adjust_confidence(CONFIDENCE_STEP_MAJOR);
683        assert_eq!(pf.confidence(), 1.0);
684    }
685
686    #[test]
687    fn adjust_confidence_clamps_at_zero() {
688        let pf = ProposedFact::new(ContextKey::Seeds, "p", "c", "x")
689            .with_confidence(0.1)
690            .adjust_confidence(-0.5);
691        assert_eq!(pf.confidence(), 0.0);
692    }
693
694    #[test]
695    fn with_confidence_clamps_high() {
696        let pf = ProposedFact::new(ContextKey::Seeds, "p", "c", "x").with_confidence(1.5);
697        assert_eq!(pf.confidence(), 1.0);
698    }
699
700    #[test]
701    fn with_confidence_clamps_negative() {
702        let pf = ProposedFact::new(ContextKey::Seeds, "p", "c", "x").with_confidence(-0.1);
703        assert_eq!(pf.confidence(), 0.0);
704    }
705
706    #[test]
707    fn with_confidence_normalizes_nan() {
708        let pf = ProposedFact::new(ContextKey::Seeds, "p", "c", "x").with_confidence(f64::NAN);
709        assert_eq!(pf.confidence(), 0.0);
710    }
711
712    #[test]
713    fn with_confidence_normalizes_infinity() {
714        let pf = ProposedFact::new(ContextKey::Seeds, "p", "c", "x").with_confidence(f64::INFINITY);
715        assert_eq!(pf.confidence(), 0.0);
716    }
717
718    #[test]
719    fn proposed_fact_deserialization_rejects_out_of_range_confidence() {
720        let json = r#"{
721            "key":"Seeds",
722            "id":"p",
723            "content":"c",
724            "confidence":1.5,
725            "provenance":"test"
726        }"#;
727        let result = serde_json::from_str::<ProposedFact>(json);
728        assert!(result.is_err());
729    }
730
731    #[test]
732    fn proposed_fact_parse_json_content_succeeds_for_valid_json() {
733        #[derive(serde::Deserialize, PartialEq, Debug)]
734        struct Payload {
735            kind: String,
736            score: f64,
737        }
738        let pf = ProposedFact::new(
739            ContextKey::Hypotheses,
740            "p",
741            r#"{"kind":"vote","score":0.7}"#,
742            "test",
743        );
744        let parsed: Payload = pf.parse_json_content().unwrap();
745        assert_eq!(
746            parsed,
747            Payload {
748                kind: "vote".into(),
749                score: 0.7,
750            }
751        );
752    }
753
754    #[test]
755    fn proposed_fact_parse_json_content_returns_error_for_invalid_json() {
756        let pf = ProposedFact::new(ContextKey::Hypotheses, "p", "not json", "test");
757        let parsed = pf.parse_json_content::<serde_json::Value>();
758        assert!(parsed.is_err());
759    }
760
761    #[test]
762    fn fact_parse_json_content_succeeds_for_valid_json() {
763        #[derive(serde::Deserialize, PartialEq, Debug)]
764        struct Payload {
765            label: String,
766        }
767        let fact = projection_fact(ContextKey::Seeds, "f", r#"{"label":"x"}"#);
768        let parsed: Payload = fact.parse_json_content().unwrap();
769        assert_eq!(parsed, Payload { label: "x".into() });
770    }
771
772    #[test]
773    fn proposed_fact_from_json_payload_round_trips() {
774        #[derive(serde::Serialize, serde::Deserialize, PartialEq, Debug)]
775        struct Payload {
776            kind: String,
777            score: f64,
778        }
779        let payload = Payload {
780            kind: "vote".into(),
781            score: 0.7,
782        };
783        let pf =
784            ProposedFact::from_json_payload(ContextKey::Hypotheses, "p", &payload, "test").unwrap();
785        assert_eq!(pf.key, ContextKey::Hypotheses);
786        assert_eq!(pf.id, "p");
787        assert_eq!(pf.provenance, "test");
788        let parsed: Payload = pf.parse_json_content().unwrap();
789        assert_eq!(parsed, payload);
790    }
791
792    #[test]
793    fn proposed_fact_from_json_payload_propagates_serialization_error() {
794        use std::collections::HashMap;
795        let mut map: HashMap<Vec<u8>, &str> = HashMap::new();
796        map.insert(vec![1, 2, 3], "value");
797        let result = ProposedFact::from_json_payload(ContextKey::Hypotheses, "p", &map, "test");
798        assert!(result.is_err());
799    }
800
801    #[test]
802    fn fact_projection_json_payload_round_trips() {
803        #[derive(serde::Serialize, serde::Deserialize, PartialEq, Debug)]
804        struct Payload {
805            label: String,
806        }
807        let payload = Payload { label: "x".into() };
808        let fact = projection_fact(
809            ContextKey::Seeds,
810            "f",
811            serde_json::to_string(&payload).unwrap(),
812        );
813        let parsed: Payload = fact.parse_json_content().unwrap();
814        assert_eq!(parsed, payload);
815    }
816
817    #[test]
818    fn validation_error_display() {
819        let err = ValidationError {
820            reason: "bad input".into(),
821        };
822        assert_eq!(err.to_string(), "validation failed: bad input");
823    }
824
825    #[test]
826    fn validation_error_is_std_error() {
827        let err = ValidationError {
828            reason: "test".into(),
829        };
830        let _: &dyn std::error::Error = &err;
831    }
832
833    #[test]
834    fn fact_accessors() {
835        let fact = projection_fact(ContextKey::Constraints, "f2", "body");
836        assert_eq!(fact.key(), ContextKey::Constraints);
837        assert_eq!(fact.id(), "f2");
838        assert_eq!(fact.content(), "body");
839        assert_eq!(fact.created_at(), "1970-01-01T00:00:00Z");
840        assert_eq!(fact.promotion_record().gate_id(), "projection-test");
841    }
842
843    #[test]
844    fn fact_actor_accessors() {
845        let actor = FactActor::new_projection("agent-x", FactActorKind::Suggestor);
846        assert_eq!(actor.id(), "agent-x");
847        assert_eq!(actor.kind(), FactActorKind::Suggestor);
848    }
849
850    #[test]
851    fn validation_summary_accessors() {
852        let vs = FactValidationSummary::new_projection(
853            vec!["check-a".into()],
854            vec!["check-b".into()],
855            vec!["warn-c".into()],
856        );
857        assert_eq!(vs.checks_passed(), &["check-a"]);
858        assert_eq!(vs.checks_skipped(), &["check-b"]);
859        assert_eq!(vs.warnings(), &["warn-c"]);
860    }
861
862    #[test]
863    fn local_trace_accessors() {
864        let lt =
865            FactLocalTrace::new_projection("trace-1", "span-1", Some("parent-1".into()), false);
866        assert_eq!(lt.trace_id(), "trace-1");
867        assert_eq!(lt.span_id(), "span-1");
868        assert_eq!(lt.parent_span_id().map(SpanId::as_str), Some("parent-1"));
869        assert!(!lt.sampled());
870    }
871
872    #[test]
873    fn remote_trace_accessors() {
874        let rt =
875            FactRemoteTrace::new_projection("sys", "ref", Some("auth".into()), Some("30d".into()));
876        assert_eq!(rt.system(), "sys");
877        assert_eq!(rt.reference(), "ref");
878        assert_eq!(rt.retrieval_auth(), Some("auth"));
879        assert_eq!(rt.retention_hint(), Some("30d"));
880    }
881
882    mod prop {
883        use super::*;
884        use proptest::prelude::*;
885
886        fn arb_context_key() -> impl Strategy<Value = ContextKey> {
887            prop_oneof![
888                Just(ContextKey::Seeds),
889                Just(ContextKey::Hypotheses),
890                Just(ContextKey::Strategies),
891                Just(ContextKey::Constraints),
892                Just(ContextKey::Signals),
893                Just(ContextKey::Competitors),
894                Just(ContextKey::Evaluations),
895                Just(ContextKey::Proposals),
896                Just(ContextKey::Diagnostic),
897                Just(ContextKey::Votes),
898                Just(ContextKey::Disagreements),
899                Just(ContextKey::ConsensusOutcomes),
900            ]
901        }
902
903        proptest! {
904            #[test]
905            fn proposed_fact_always_constructible(
906                key in arb_context_key(),
907                id in "[a-z]{1,20}",
908                content in ".*",
909                prov in "[a-z0-9-]{1,30}",
910            ) {
911                let pf = ProposedFact::new(key, id.clone(), content.clone(), prov.clone());
912                prop_assert_eq!(pf.key, key);
913                prop_assert_eq!(&pf.id, &id);
914                prop_assert_eq!(&pf.content, &content);
915                prop_assert_eq!(&pf.provenance, &prov);
916                prop_assert!((pf.confidence() - 1.0).abs() < f64::EPSILON);
917            }
918        }
919    }
920}