Skip to main content

relay_knowledge/domain/operations/software/
statement.rs

1use serde::{Deserialize, Serialize};
2
3use super::ontology::{SoftwareEvidenceRef, SoftwareSourceKind};
4use super::validation::stable_software_id;
5use super::vocabulary::SOFTWARE_PROPERTIES;
6
7/// Controlled relationship vocabulary for provenance-bearing software statements.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10#[repr(usize)]
11pub enum SoftwarePredicate {
12    Contains,
13    ProvidesApi,
14    ConsumesApi,
15    DependsOn,
16    Configures,
17    Builds,
18    Produces,
19    Packages,
20    Deploys,
21    RunsAs,
22    Tests,
23    Documents,
24    DerivedFrom,
25    ObservedAs,
26    Supersedes,
27}
28
29impl SoftwarePredicate {
30    pub const fn as_str(self) -> &'static str {
31        SOFTWARE_PROPERTIES[self as usize].id
32    }
33
34    /// RDF local name declared by the shared OWL object-property vocabulary.
35    pub const fn rdf_local_name(self) -> &'static str {
36        SOFTWARE_PROPERTIES[self as usize].rdf_local_name
37    }
38
39    pub fn parse(value: &str) -> Option<Self> {
40        match value {
41            "contains" => Some(Self::Contains),
42            "provides_api" => Some(Self::ProvidesApi),
43            "consumes_api" => Some(Self::ConsumesApi),
44            "depends_on" => Some(Self::DependsOn),
45            "configures" => Some(Self::Configures),
46            "builds" => Some(Self::Builds),
47            "produces" => Some(Self::Produces),
48            "packages" => Some(Self::Packages),
49            "deploys" => Some(Self::Deploys),
50            "runs_as" => Some(Self::RunsAs),
51            "tests" => Some(Self::Tests),
52            "documents" => Some(Self::Documents),
53            "derived_from" => Some(Self::DerivedFrom),
54            "observed_as" => Some(Self::ObservedAs),
55            "supersedes" => Some(Self::Supersedes),
56            _ => None,
57        }
58    }
59}
60
61/// How a statement entered the graph.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
63#[serde(rename_all = "snake_case")]
64pub enum SoftwareAssertionMode {
65    Declared,
66    Extracted,
67    Observed,
68    Verified,
69    Inferred,
70}
71
72impl SoftwareAssertionMode {
73    pub const fn as_str(self) -> &'static str {
74        match self {
75            Self::Declared => "declared",
76            Self::Extracted => "extracted",
77            Self::Observed => "observed",
78            Self::Verified => "verified",
79            Self::Inferred => "inferred",
80        }
81    }
82
83    pub fn parse(value: &str) -> Option<Self> {
84        match value {
85            "declared" => Some(Self::Declared),
86            "extracted" => Some(Self::Extracted),
87            "observed" => Some(Self::Observed),
88            "verified" => Some(Self::Verified),
89            "inferred" => Some(Self::Inferred),
90            _ => None,
91        }
92    }
93}
94
95/// Resolution state is independent from whether the assertion is accepted.
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
97#[serde(rename_all = "snake_case")]
98pub enum SoftwareStatementResolution {
99    Resolved,
100    Unresolved,
101    Ambiguous,
102    External,
103    Conflicting,
104}
105
106impl SoftwareStatementResolution {
107    pub const fn as_str(self) -> &'static str {
108        match self {
109            Self::Resolved => "resolved",
110            Self::Unresolved => "unresolved",
111            Self::Ambiguous => "ambiguous",
112            Self::External => "external",
113            Self::Conflicting => "conflicting",
114        }
115    }
116
117    pub fn parse(value: &str) -> Option<Self> {
118        match value {
119            "resolved" => Some(Self::Resolved),
120            "unresolved" => Some(Self::Unresolved),
121            "ambiguous" => Some(Self::Ambiguous),
122            "external" => Some(Self::External),
123            "conflicting" => Some(Self::Conflicting),
124            _ => None,
125        }
126    }
127}
128
129/// Lifecycle state for a statement; conflicting facts remain queryable.
130#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
131#[serde(rename_all = "snake_case")]
132pub enum SoftwareFactState {
133    Active,
134    Conflicting,
135    Superseded,
136    Rejected,
137}
138
139impl SoftwareFactState {
140    pub const fn as_str(self) -> &'static str {
141        match self {
142            Self::Active => "active",
143            Self::Conflicting => "conflicting",
144            Self::Superseded => "superseded",
145            Self::Rejected => "rejected",
146        }
147    }
148
149    pub fn parse(value: &str) -> Option<Self> {
150        match value {
151            "active" => Some(Self::Active),
152            "conflicting" => Some(Self::Conflicting),
153            "superseded" => Some(Self::Superseded),
154            "rejected" => Some(Self::Rejected),
155            _ => None,
156        }
157    }
158}
159
160/// A first-class assertion retaining provenance, time, extraction, and conflict state.
161#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
162pub struct SoftwareStatement {
163    pub statement_id: String,
164    pub subject_id: String,
165    pub predicate: SoftwarePredicate,
166    #[serde(skip_serializing_if = "Option::is_none")]
167    pub object_id: Option<String>,
168    #[serde(skip_serializing_if = "Option::is_none")]
169    pub object_value: Option<String>,
170    pub source_scope: String,
171    pub source_kind: SoftwareSourceKind,
172    pub evidence_refs: Vec<SoftwareEvidenceRef>,
173    pub assertion_mode: SoftwareAssertionMode,
174    pub resolution_state: SoftwareStatementResolution,
175    #[serde(skip_serializing_if = "Option::is_none")]
176    pub valid_from: Option<u64>,
177    #[serde(skip_serializing_if = "Option::is_none")]
178    pub valid_to: Option<u64>,
179    #[serde(skip_serializing_if = "Option::is_none")]
180    pub observed_at: Option<u64>,
181    pub extractor_id: String,
182    pub extractor_version: String,
183    pub confidence_basis_points: u16,
184    pub fact_state: SoftwareFactState,
185}
186
187impl SoftwareStatement {
188    /// Builds a candidate identity while leaving shape acceptance to the validator.
189    pub fn candidate(input: SoftwareStatementInput) -> Self {
190        let subject_id = input.subject_id.trim().to_owned();
191        let object_id = normalized_optional(input.object_id);
192        let object_value = normalized_optional(input.object_value);
193        let source_scope = input.source_scope.trim().to_owned();
194        let extractor_id = input.extractor_id.trim().to_owned();
195        let extractor_version = input.extractor_version.trim().to_owned();
196        let object_identity = object_id
197            .as_deref()
198            .or(object_value.as_deref())
199            .unwrap_or("missing-object");
200        let evidence_identity = input
201            .evidence_refs
202            .iter()
203            .map(|reference| reference.evidence_id.as_str())
204            .collect::<Vec<_>>()
205            .join("|");
206        let statement_id = stable_software_id(
207            "software_statement",
208            [
209                subject_id.as_str(),
210                input.predicate.as_str(),
211                object_identity,
212                source_scope.as_str(),
213                evidence_identity.as_str(),
214                extractor_id.as_str(),
215                extractor_version.as_str(),
216            ],
217        );
218
219        Self {
220            statement_id,
221            subject_id,
222            predicate: input.predicate,
223            object_id,
224            object_value,
225            source_scope,
226            source_kind: input.source_kind,
227            evidence_refs: input.evidence_refs,
228            assertion_mode: input.assertion_mode,
229            resolution_state: input.resolution_state,
230            valid_from: input.valid_from,
231            valid_to: input.valid_to,
232            observed_at: input.observed_at,
233            extractor_id,
234            extractor_version,
235            confidence_basis_points: input.confidence_basis_points,
236            fact_state: input.fact_state,
237        }
238    }
239
240    /// Stable comparison key used to retain competing objects without source precedence.
241    pub fn object_identity(&self) -> Option<&str> {
242        self.object_id.as_deref().or(self.object_value.as_deref())
243    }
244}
245
246/// Candidate input for `SoftwareStatement`.
247#[derive(Debug, Clone, PartialEq, Eq)]
248pub struct SoftwareStatementInput {
249    pub subject_id: String,
250    pub predicate: SoftwarePredicate,
251    pub object_id: Option<String>,
252    pub object_value: Option<String>,
253    pub source_scope: String,
254    pub source_kind: SoftwareSourceKind,
255    pub evidence_refs: Vec<SoftwareEvidenceRef>,
256    pub assertion_mode: SoftwareAssertionMode,
257    pub resolution_state: SoftwareStatementResolution,
258    pub valid_from: Option<u64>,
259    pub valid_to: Option<u64>,
260    pub observed_at: Option<u64>,
261    pub extractor_id: String,
262    pub extractor_version: String,
263    pub confidence_basis_points: u16,
264    pub fact_state: SoftwareFactState,
265}
266
267fn normalized_optional(value: Option<String>) -> Option<String> {
268    value
269        .map(|value| value.trim().to_owned())
270        .filter(|value| !value.is_empty())
271}