use std::collections::BTreeMap;
use chrono::Utc;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
pub const VALID_ENTITY_TYPES: &[&str] = &[
"gene",
"protein",
"compound",
"disease",
"cell_type",
"organism",
"pathway",
"assay",
"anatomical_structure",
"particle",
"instrument",
"dataset",
"quantity",
"other",
];
pub const VALID_ASSERTION_TYPES: &[&str] = &[
"mechanism",
"therapeutic",
"diagnostic",
"epidemiological",
"observational",
"review",
"methodological",
"computational",
"theoretical",
"negative",
"measurement",
"exclusion",
"tension",
"open_question",
"hypothesis",
"candidate_finding",
];
pub const VALID_ARTIFACT_KINDS: &[&str] = &[
"dataset",
"clinical_trial_record",
"protocol",
"supplement",
"notebook",
"code",
"model_output",
"table",
"figure",
"registry_record",
"lab_file",
"source_file",
"other",
];
pub fn valid_artifact_kind(kind: &str) -> bool {
VALID_ARTIFACT_KINDS.contains(&kind)
}
pub const VALID_EVIDENCE_TYPES: &[&str] = &[
"experimental",
"observational",
"computational",
"theoretical",
"meta_analysis",
"systematic_review",
"case_report",
"extracted_from_notes",
];
pub const VALID_PROVENANCE_SOURCE_TYPES: &[&str] = &[
"published_paper",
"preprint",
"clinical_trial",
"lab_notebook",
"model_output",
"expert_assertion",
"database_record",
"data_release",
"researcher_notes",
];
pub const VALID_LINK_TYPES: &[&str] = &[
"supports",
"contradicts",
"extends",
"depends",
"replicates",
"supersedes",
"synthesized_from",
];
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResolvedId {
pub source: String,
pub id: String,
pub confidence: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub matched_name: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ResolutionMethod {
ExactMatch,
FuzzyMatch,
LlmInference,
Manual,
}
impl std::fmt::Display for ResolutionMethod {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ResolutionMethod::ExactMatch => write!(f, "exact_match"),
ResolutionMethod::FuzzyMatch => write!(f, "fuzzy_match"),
ResolutionMethod::LlmInference => write!(f, "llm_inference"),
ResolutionMethod::Manual => write!(f, "manual"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Entity {
pub name: String,
#[serde(rename = "type")]
pub entity_type: String,
#[serde(default)]
pub identifiers: serde_json::Map<String, serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub canonical_id: Option<ResolvedId>,
#[serde(default)]
pub candidates: Vec<ResolvedId>,
#[serde(default)]
pub aliases: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resolution_provenance: Option<String>,
#[serde(default = "default_one")]
pub resolution_confidence: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resolution_method: Option<ResolutionMethod>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub species_context: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub needs_review: bool,
}
fn default_one() -> f64 {
1.0
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Evidence {
#[serde(rename = "type")]
pub evidence_type: String,
#[serde(default)]
pub model_system: String,
pub species: Option<String>,
#[serde(default)]
pub method: String,
pub sample_size: Option<String>,
pub effect_size: Option<String>,
pub p_value: Option<String>,
#[serde(default)]
pub replicated: bool,
pub replication_count: Option<u32>,
#[serde(default)]
pub evidence_spans: Vec<serde_json::Value>,
}
pub const VALID_REPLICATION_OUTCOMES: &[&str] =
&["replicated", "failed", "partial", "inconclusive"];
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Replication {
pub id: String,
pub target_finding: String,
pub attempted_by: String,
pub outcome: String,
pub evidence: Evidence,
pub conditions: Conditions,
pub provenance: Provenance,
#[serde(default)]
pub notes: String,
pub created: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub previous_attempt: Option<String>,
}
impl Replication {
pub fn content_address(
target_finding: &str,
attempted_by: &str,
conditions: &Conditions,
outcome: &str,
) -> String {
let norm_conditions = FindingBundle::normalize_text(&conditions.text);
let preimage = format!(
"{}|{}|{}|{}",
target_finding, attempted_by, norm_conditions, outcome
);
let hash = Sha256::digest(preimage.as_bytes());
format!("vrep_{}", &hex::encode(hash)[..16])
}
pub fn new(
target_finding: impl Into<String>,
attempted_by: impl Into<String>,
outcome: impl Into<String>,
evidence: Evidence,
conditions: Conditions,
provenance: Provenance,
notes: impl Into<String>,
) -> Self {
let target = target_finding.into();
let actor = attempted_by.into();
let oc = outcome.into();
let id = Self::content_address(&target, &actor, &conditions, &oc);
Self {
id,
target_finding: target,
attempted_by: actor,
outcome: oc,
evidence,
conditions,
provenance,
notes: notes.into(),
created: Utc::now().to_rfc3339(),
previous_attempt: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ExpectedOutcome {
Affirmed,
Falsified,
Quantitative {
value: f64,
tolerance: f64,
units: String,
},
Categorical {
value: String,
},
}
impl ExpectedOutcome {
pub fn canonical(&self) -> String {
match self {
ExpectedOutcome::Affirmed => "affirmed".to_string(),
ExpectedOutcome::Falsified => "falsified".to_string(),
ExpectedOutcome::Quantitative {
value,
tolerance,
units,
} => format!("quant:{value}±{tolerance}{units}"),
ExpectedOutcome::Categorical { value } => format!("cat:{value}"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Prediction {
pub id: String,
pub claim_text: String,
#[serde(default)]
pub target_findings: Vec<String>,
pub predicted_at: String,
pub resolves_by: Option<String>,
pub resolution_criterion: String,
pub expected_outcome: ExpectedOutcome,
pub made_by: String,
pub confidence: f64,
pub conditions: Conditions,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub expired_unresolved: bool,
}
impl Prediction {
pub fn content_address(
claim_text: &str,
made_by: &str,
predicted_at: &str,
resolution_criterion: &str,
expected_outcome: &ExpectedOutcome,
) -> String {
let preimage = format!(
"{}|{}|{}|{}|{}",
FindingBundle::normalize_text(claim_text),
made_by,
predicted_at,
FindingBundle::normalize_text(resolution_criterion),
expected_outcome.canonical(),
);
let hash = Sha256::digest(preimage.as_bytes());
format!("vpred_{}", &hex::encode(hash)[..16])
}
#[allow(clippy::too_many_arguments)]
pub fn new(
claim_text: impl Into<String>,
target_findings: Vec<String>,
predicted_at: Option<String>,
resolves_by: Option<String>,
resolution_criterion: impl Into<String>,
expected_outcome: ExpectedOutcome,
made_by: impl Into<String>,
confidence: f64,
conditions: Conditions,
) -> Self {
let now = predicted_at.unwrap_or_else(|| Utc::now().to_rfc3339());
let claim = claim_text.into();
let crit = resolution_criterion.into();
let actor = made_by.into();
let id = Self::content_address(&claim, &actor, &now, &crit, &expected_outcome);
Self {
id,
claim_text: claim,
target_findings,
predicted_at: now,
resolves_by,
resolution_criterion: crit,
expected_outcome,
made_by: actor,
confidence,
conditions,
expired_unresolved: false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Resolution {
pub id: String,
pub prediction_id: String,
pub actual_outcome: String,
pub matched_expected: bool,
pub resolved_at: String,
pub resolved_by: String,
pub evidence: Evidence,
pub confidence: f64,
}
impl Resolution {
pub fn content_address(
prediction_id: &str,
actual_outcome: &str,
resolved_by: &str,
resolved_at: &str,
matched_expected: bool,
) -> String {
let preimage = format!(
"{}|{}|{}|{}|{}",
prediction_id,
FindingBundle::normalize_text(actual_outcome),
resolved_by,
resolved_at,
matched_expected,
);
let hash = Sha256::digest(preimage.as_bytes());
format!("vres_{}", &hex::encode(hash)[..16])
}
pub fn new(
prediction_id: impl Into<String>,
actual_outcome: impl Into<String>,
matched_expected: bool,
resolved_by: impl Into<String>,
evidence: Evidence,
confidence: f64,
) -> Self {
let now = Utc::now().to_rfc3339();
let pid = prediction_id.into();
let outcome = actual_outcome.into();
let resolver = resolved_by.into();
let id = Self::content_address(&pid, &outcome, &resolver, &now, matched_expected);
Self {
id,
prediction_id: pid,
actual_outcome: outcome,
matched_expected,
resolved_at: now,
resolved_by: resolver,
evidence,
confidence,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum NegativeResultKind {
RegisteredTrial {
endpoint: String,
intervention: String,
comparator: String,
population: String,
n_enrolled: u32,
power: f64,
effect_size_ci: (f64, f64),
#[serde(default, skip_serializing_if = "Option::is_none")]
effect_size_threshold: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
registry_id: Option<String>,
},
Exploratory {
reagent: String,
observation: String,
attempts: u32,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NegativeResult {
pub id: String,
pub kind: NegativeResultKind,
#[serde(default)]
pub target_findings: Vec<String>,
pub deposited_by: String,
pub conditions: Conditions,
pub provenance: Provenance,
pub created: String,
#[serde(default)]
pub notes: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub review_state: Option<ReviewState>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub retracted: bool,
#[serde(default, skip_serializing_if = "is_public_tier")]
pub access_tier: crate::access_tier::AccessTier,
}
impl NegativeResultKind {
pub fn canonical(&self) -> String {
match self {
NegativeResultKind::RegisteredTrial {
endpoint,
intervention,
comparator,
population,
n_enrolled,
power,
effect_size_ci,
effect_size_threshold,
registry_id,
} => format!(
"trial|{}|{}|{}|{}|{}|{:.4}|{:.6},{:.6}|{}|{}",
FindingBundle::normalize_text(endpoint),
FindingBundle::normalize_text(intervention),
FindingBundle::normalize_text(comparator),
FindingBundle::normalize_text(population),
n_enrolled,
power,
effect_size_ci.0,
effect_size_ci.1,
effect_size_threshold
.map(|t| format!("{t:.6}"))
.unwrap_or_default(),
registry_id.clone().unwrap_or_default(),
),
NegativeResultKind::Exploratory {
reagent,
observation,
attempts,
} => format!(
"exploratory|{}|{}|{}",
FindingBundle::normalize_text(reagent),
FindingBundle::normalize_text(observation),
attempts,
),
}
}
}
impl NegativeResult {
pub fn content_address(
kind: &NegativeResultKind,
deposited_by: &str,
created: &str,
conditions: &Conditions,
) -> String {
let preimage = format!(
"{}|{}|{}|{}",
kind.canonical(),
deposited_by,
created,
FindingBundle::normalize_text(&conditions.text),
);
let hash = Sha256::digest(preimage.as_bytes());
format!("vnr_{}", &hex::encode(hash)[..16])
}
pub fn new(
kind: NegativeResultKind,
target_findings: Vec<String>,
deposited_by: impl Into<String>,
conditions: Conditions,
provenance: Provenance,
notes: impl Into<String>,
) -> Self {
let depositor = deposited_by.into();
let created = Utc::now().to_rfc3339();
let id = Self::content_address(&kind, &depositor, &created, &conditions);
Self {
id,
kind,
target_findings,
deposited_by: depositor,
conditions,
provenance,
created,
notes: notes.into(),
review_state: None,
retracted: false,
access_tier: crate::access_tier::AccessTier::Public,
}
}
pub fn is_informative_trial_null(&self) -> Option<bool> {
match &self.kind {
NegativeResultKind::RegisteredTrial {
power,
effect_size_ci,
effect_size_threshold,
..
} => {
let threshold = (*effect_size_threshold)?;
Some(*power >= 0.8 && effect_size_ci.0 > -threshold && effect_size_ci.1 < threshold)
}
NegativeResultKind::Exploratory { .. } => None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TrajectoryStepKind {
Hypothesis,
Tried,
RuledOut,
Observed,
Refined,
}
impl TrajectoryStepKind {
pub fn canonical(&self) -> &'static str {
match self {
TrajectoryStepKind::Hypothesis => "hypothesis",
TrajectoryStepKind::Tried => "tried",
TrajectoryStepKind::RuledOut => "ruled_out",
TrajectoryStepKind::Observed => "observed",
TrajectoryStepKind::Refined => "refined",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrajectoryStep {
pub id: String,
pub kind: TrajectoryStepKind,
pub description: String,
pub at: String,
pub actor: String,
#[serde(default)]
pub references: Vec<String>,
}
impl TrajectoryStep {
pub fn content_address(
trajectory_id: &str,
kind: &TrajectoryStepKind,
description: &str,
at: &str,
actor: &str,
) -> String {
let preimage = format!(
"{}|{}|{}|{}|{}",
trajectory_id,
kind.canonical(),
FindingBundle::normalize_text(description),
at,
actor,
);
let hash = Sha256::digest(preimage.as_bytes());
format!("vts_{}", &hex::encode(hash)[..16])
}
pub fn new(
trajectory_id: &str,
kind: TrajectoryStepKind,
description: impl Into<String>,
actor: impl Into<String>,
at: Option<String>,
references: Vec<String>,
) -> Self {
let at = at.unwrap_or_else(|| Utc::now().to_rfc3339());
let actor = actor.into();
let description = description.into();
let id = Self::content_address(trajectory_id, &kind, &description, &at, &actor);
Self {
id,
kind,
description,
at,
actor,
references,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Trajectory {
pub id: String,
#[serde(default)]
pub target_findings: Vec<String>,
pub deposited_by: String,
pub created: String,
#[serde(default)]
pub steps: Vec<TrajectoryStep>,
#[serde(default)]
pub notes: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub review_state: Option<ReviewState>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub retracted: bool,
#[serde(default, skip_serializing_if = "is_public_tier")]
pub access_tier: crate::access_tier::AccessTier,
}
impl Trajectory {
pub fn content_address(
target_findings: &[String],
deposited_by: &str,
created: &str,
) -> String {
let mut sorted: Vec<&str> = target_findings.iter().map(String::as_str).collect();
sorted.sort();
let preimage = format!("{}|{}|{}", sorted.join(","), deposited_by, created);
let hash = Sha256::digest(preimage.as_bytes());
format!("vtr_{}", &hex::encode(hash)[..16])
}
pub fn new(
target_findings: Vec<String>,
deposited_by: impl Into<String>,
notes: impl Into<String>,
) -> Self {
let depositor = deposited_by.into();
let created = Utc::now().to_rfc3339();
let id = Self::content_address(&target_findings, &depositor, &created);
Self {
id,
target_findings,
deposited_by: depositor,
created,
steps: Vec::new(),
notes: notes.into(),
review_state: None,
retracted: false,
access_tier: crate::access_tier::AccessTier::Public,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Dataset {
pub id: String,
pub name: String,
pub version: Option<String>,
#[serde(default)]
pub schema: Vec<(String, String)>,
pub row_count: Option<u64>,
pub content_hash: String,
pub url: Option<String>,
pub license: Option<String>,
pub provenance: Provenance,
pub created: String,
}
impl Dataset {
pub fn content_address(
name: &str,
version: Option<&str>,
content_hash: &str,
url: Option<&str>,
) -> String {
let preimage = format!(
"{}|{}|{}|{}",
name,
version.unwrap_or(""),
content_hash,
url.unwrap_or("")
);
let hash = Sha256::digest(preimage.as_bytes());
format!("vd_{}", &hex::encode(hash)[..16])
}
pub fn new(
name: impl Into<String>,
version: Option<String>,
content_hash: impl Into<String>,
url: Option<String>,
license: Option<String>,
provenance: Provenance,
) -> Self {
let n = name.into();
let h = content_hash.into();
let id = Self::content_address(&n, version.as_deref(), &h, url.as_deref());
Self {
id,
name: n,
version,
schema: Vec::new(),
row_count: None,
content_hash: h,
url,
license,
provenance,
created: Utc::now().to_rfc3339(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CodeArtifact {
pub id: String,
pub language: String,
pub repo_url: Option<String>,
pub git_commit: Option<String>,
pub path: String,
pub line_range: Option<(u32, u32)>,
pub content_hash: String,
pub entry_point: Option<String>,
pub created: String,
}
impl CodeArtifact {
pub fn content_address(
repo_url: Option<&str>,
git_commit: Option<&str>,
path: &str,
line_range: Option<(u32, u32)>,
content_hash: &str,
) -> String {
let lr = line_range
.map(|(a, b)| format!("{a}-{b}"))
.unwrap_or_default();
let preimage = format!(
"{}|{}|{}|{}|{}",
repo_url.unwrap_or(""),
git_commit.unwrap_or(""),
path,
lr,
content_hash
);
let hash = Sha256::digest(preimage.as_bytes());
format!("vc_{}", &hex::encode(hash)[..16])
}
pub fn new(
language: impl Into<String>,
repo_url: Option<String>,
git_commit: Option<String>,
path: impl Into<String>,
line_range: Option<(u32, u32)>,
content_hash: impl Into<String>,
entry_point: Option<String>,
) -> Self {
let p = path.into();
let h = content_hash.into();
let id = Self::content_address(
repo_url.as_deref(),
git_commit.as_deref(),
&p,
line_range,
&h,
);
Self {
id,
language: language.into(),
repo_url,
git_commit,
path: p,
line_range,
content_hash: h,
entry_point,
created: Utc::now().to_rfc3339(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Artifact {
pub id: String,
pub kind: String,
pub name: String,
pub content_hash: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub size_bytes: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub media_type: Option<String>,
pub storage_mode: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub locator: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub license: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub target_findings: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_id: Option<String>,
pub provenance: Provenance,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub metadata: BTreeMap<String, Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub review_state: Option<ReviewState>,
#[serde(default)]
pub retracted: bool,
#[serde(default)]
pub access_tier: crate::access_tier::AccessTier,
pub created: String,
}
impl Artifact {
pub fn content_address(
kind: &str,
name: &str,
content_hash: &str,
source_url: Option<&str>,
locator: Option<&str>,
) -> String {
let preimage = format!(
"{}|{}|{}|{}|{}",
kind,
name,
content_hash,
source_url.unwrap_or(""),
locator.unwrap_or("")
);
let hash = Sha256::digest(preimage.as_bytes());
format!("va_{}", &hex::encode(hash)[..16])
}
#[allow(clippy::too_many_arguments)]
pub fn new(
kind: impl Into<String>,
name: impl Into<String>,
content_hash: impl Into<String>,
size_bytes: Option<u64>,
media_type: Option<String>,
storage_mode: impl Into<String>,
locator: Option<String>,
source_url: Option<String>,
license: Option<String>,
target_findings: Vec<String>,
provenance: Provenance,
metadata: BTreeMap<String, Value>,
access_tier: crate::access_tier::AccessTier,
) -> Result<Self, String> {
let kind = kind.into();
if !valid_artifact_kind(&kind) {
return Err(format!(
"artifact kind '{kind}' is not supported; valid: {}",
VALID_ARTIFACT_KINDS.join(", ")
));
}
let name = name.into();
if name.trim().is_empty() {
return Err("artifact name must be non-empty".to_string());
}
let content_hash = normalize_sha256(content_hash.into())?;
let storage_mode = storage_mode.into();
if !matches!(
storage_mode.as_str(),
"local_blob" | "local_file" | "remote" | "pointer"
) {
return Err(format!(
"artifact storage_mode '{storage_mode}' is not supported; valid: local_blob, local_file, remote, pointer"
));
}
let id = Self::content_address(
&kind,
&name,
&content_hash,
source_url.as_deref(),
locator.as_deref(),
);
Ok(Self {
id,
kind,
name,
content_hash,
size_bytes,
media_type,
storage_mode,
locator,
source_url,
license,
target_findings,
source_id: None,
provenance,
metadata,
review_state: None,
retracted: false,
access_tier,
created: Utc::now().to_rfc3339(),
})
}
}
fn normalize_sha256(value: String) -> Result<String, String> {
let trimmed = value.trim();
let hex = trimmed.strip_prefix("sha256:").unwrap_or(trimmed);
if hex.len() != 64 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(format!(
"content_hash must be sha256:<64hex> or 64 hex chars, got {trimmed:?}"
));
}
Ok(format!("sha256:{}", hex.to_ascii_lowercase()))
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Conditions {
#[serde(default)]
pub text: String,
#[serde(default)]
pub species_verified: Vec<String>,
#[serde(default)]
pub species_unverified: Vec<String>,
#[serde(default)]
pub in_vitro: bool,
#[serde(default)]
pub in_vivo: bool,
#[serde(default)]
pub human_data: bool,
#[serde(default)]
pub clinical_trial: bool,
pub concentration_range: Option<String>,
pub duration: Option<String>,
pub age_group: Option<String>,
pub cell_type: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfidenceComponents {
#[serde(alias = "evidence_grade")]
pub evidence_strength: f64,
#[serde(alias = "replication_factor")]
pub replication_strength: f64,
pub sample_strength: f64,
#[serde(alias = "species_relevance")]
pub model_relevance: f64,
#[serde(alias = "contradiction_penalty")]
pub review_penalty: f64,
#[serde(default)]
pub calibration_adjustment: f64,
#[serde(default = "default_causal_consistency")]
pub causal_consistency: f64,
#[serde(default = "default_formula_version")]
pub formula_version: String,
}
fn default_causal_consistency() -> f64 {
1.0
}
fn default_formula_version() -> String {
"v0.8".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
pub enum ConfidenceMethod {
Computed,
ExpertJudgment,
#[default]
LlmInitial,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum ConfidenceKind {
#[default]
FrontierEpistemic,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Confidence {
#[serde(default)]
pub kind: ConfidenceKind,
pub score: f64,
pub basis: String,
#[serde(default)]
pub method: ConfidenceMethod,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub components: Option<ConfidenceComponents>,
#[serde(default = "default_extraction_conf")]
pub extraction_confidence: f64,
}
fn default_extraction_conf() -> f64 {
0.85
}
impl Confidence {
pub fn raw(score: f64, basis: impl Into<String>, extraction_confidence: f64) -> Self {
Self {
kind: ConfidenceKind::FrontierEpistemic,
score,
basis: basis.into(),
method: ConfidenceMethod::LlmInitial,
components: None,
extraction_confidence,
}
}
}
fn parse_sample_size(s: &str) -> Option<u64> {
let mut max_num: Option<u64> = None;
for word in s.split(|c: char| !c.is_ascii_digit()) {
if let Ok(n) = word.parse::<u64>() {
max_num = Some(max_num.map_or(n, |prev: u64| prev.max(n)));
}
}
max_num
}
pub fn compute_confidence(
evidence: &Evidence,
conditions: &Conditions,
contested: bool,
) -> Confidence {
let n_replicated = if evidence.replicated {
evidence.replication_count.unwrap_or(1)
} else {
0
};
compute_confidence_from_components(
evidence,
conditions,
contested,
n_replicated,
0,
0,
None,
None,
)
}
#[must_use]
pub fn causal_consistency_multiplier(
claim: Option<CausalClaim>,
grade: Option<CausalEvidenceGrade>,
) -> f64 {
use CausalClaim::*;
use CausalEvidenceGrade::*;
let (Some(c), Some(g)) = (claim, grade) else {
return 1.0;
};
match (c, g) {
(_, Rct) => 1.10,
(Correlation, _) => 1.0,
(Mediation, QuasiExperimental) => 1.05,
(Mediation, Observational) => 0.85,
(Mediation, Theoretical) => 0.90,
(Intervention, QuasiExperimental) => 0.90,
(Intervention, Observational) => 0.65,
(Intervention, Theoretical) => 0.75,
}
}
#[must_use]
pub fn compute_confidence_from_components(
evidence: &Evidence,
conditions: &Conditions,
contested: bool,
n_replicated: u32,
n_failed: u32,
n_partial: u32,
causal_claim: Option<CausalClaim>,
causal_evidence_grade: Option<CausalEvidenceGrade>,
) -> Confidence {
let evidence_strength = match evidence.evidence_type.as_str() {
"meta_analysis" => 0.95,
"systematic_review" => 0.90,
"experimental" => 0.80,
"observational" => 0.65,
"computational" => 0.55,
"case_report" => 0.40,
"theoretical" => 0.30,
_ => 0.50,
};
let replication_strength = (0.7 + 0.1 * f64::from(n_replicated) + 0.05 * f64::from(n_partial)
- 0.10 * f64::from(n_failed))
.clamp(0.4, 1.0);
let sample_strength = match evidence.sample_size.as_deref().and_then(parse_sample_size) {
Some(n) if n > 1000 => 1.0,
Some(n) if n > 100 => 0.9,
Some(n) if n > 30 => 0.8,
Some(n) if n > 10 => 0.7,
Some(_) => 0.6,
None => 0.6,
};
let model_relevance = if conditions.human_data {
1.0
} else if conditions.in_vivo {
0.8
} else if conditions.in_vitro {
0.6
} else {
0.5
};
let review_penalty = if contested { 0.15 } else { 0.0 };
let calibration_adjustment = 0.0;
let causal_consistency = causal_consistency_multiplier(causal_claim, causal_evidence_grade);
let raw = evidence_strength
* replication_strength
* model_relevance
* sample_strength
* causal_consistency
- review_penalty
+ calibration_adjustment;
let score = raw.clamp(0.0, 1.0);
let score = (score * 1000.0).round() / 1000.0;
let components = ConfidenceComponents {
evidence_strength,
replication_strength,
sample_strength,
model_relevance,
review_penalty,
calibration_adjustment,
causal_consistency,
formula_version: "v0.7".to_string(),
};
let basis = format!(
"frontier_epistemic: evidence={:.2} * replication={:.2} * model={:.2} * sample={:.2} * causal={:.2} - review_penalty={:.2} + calibration={:.2} = {:.3}",
evidence_strength,
replication_strength,
model_relevance,
sample_strength,
causal_consistency,
review_penalty,
calibration_adjustment,
score,
);
Confidence {
kind: ConfidenceKind::FrontierEpistemic,
score,
basis,
method: ConfidenceMethod::Computed,
components: Some(components),
extraction_confidence: default_extraction_conf(),
}
}
#[must_use]
pub fn count_replication_outcomes(
replications: &[Replication],
target_finding: &str,
) -> (u32, u32, u32) {
let mut n_replicated = 0u32;
let mut n_failed = 0u32;
let mut n_partial = 0u32;
for r in replications {
if r.target_finding != target_finding {
continue;
}
match r.outcome.as_str() {
"replicated" => n_replicated += 1,
"failed" => n_failed += 1,
"partial" => n_partial += 1,
_ => {}
}
}
(n_replicated, n_failed, n_partial)
}
pub fn recompute_all_confidence(
findings: &mut [FindingBundle],
replications: &[Replication],
) -> usize {
let mut changed = 0;
for bundle in findings.iter_mut() {
let old_score = bundle.confidence.score;
let extraction_conf = bundle.confidence.extraction_confidence;
let (n_repl, n_failed, n_partial) = count_replication_outcomes(replications, &bundle.id);
let (n_repl, n_failed, n_partial) = if n_repl + n_failed + n_partial == 0 {
let legacy = if bundle.evidence.replicated {
bundle.evidence.replication_count.unwrap_or(1)
} else {
0
};
(legacy, 0, 0)
} else {
(n_repl, n_failed, n_partial)
};
let mut new_conf = compute_confidence_from_components(
&bundle.evidence,
&bundle.conditions,
bundle.flags.contested,
n_repl,
n_failed,
n_partial,
bundle.assertion.causal_claim,
bundle.assertion.causal_evidence_grade,
);
new_conf.extraction_confidence = extraction_conf;
if (new_conf.score - old_score).abs() > 0.001 {
changed += 1;
}
bundle.confidence = new_conf;
}
changed
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Extraction {
#[serde(default = "default_extraction_method")]
pub method: String,
pub model: Option<String>,
pub model_version: Option<String>,
#[serde(default)]
pub extracted_at: String,
#[serde(default = "default_extractor_version")]
pub extractor_version: String,
}
fn default_extraction_method() -> String {
"llm_extraction".into()
}
fn default_extractor_version() -> String {
"vela/0.2.0".into()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Review {
#[serde(default)]
pub reviewed: bool,
pub reviewer: Option<String>,
pub reviewed_at: Option<String>,
#[serde(default)]
pub corrections: Vec<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Author {
pub name: String,
pub orcid: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Provenance {
#[serde(default = "default_source_type")]
pub source_type: String,
pub doi: Option<String>,
pub pmid: Option<String>,
pub pmc: Option<String>,
pub openalex_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(default)]
pub title: String,
#[serde(default)]
pub authors: Vec<Author>,
pub year: Option<i32>,
pub journal: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub license: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub publisher: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub funders: Vec<String>,
#[serde(default)]
pub extraction: Extraction,
pub review: Option<Review>,
#[serde(default)]
pub citation_count: Option<u64>,
}
fn default_source_type() -> String {
"published_paper".into()
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ReviewState {
Accepted,
Contested,
NeedsRevision,
Rejected,
}
impl ReviewState {
#[must_use]
pub fn implies_contested(&self) -> bool {
matches!(
self,
ReviewState::Contested | ReviewState::NeedsRevision | ReviewState::Rejected
)
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Flags {
#[serde(default)]
pub gap: bool,
#[serde(default)]
pub negative_space: bool,
#[serde(default)]
pub contested: bool,
#[serde(default)]
pub retracted: bool,
#[serde(default)]
pub declining: bool,
#[serde(default)]
pub gravity_well: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub review_state: Option<ReviewState>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub superseded: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signature_threshold: Option<u32>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub jointly_accepted: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CausalClaim {
Correlation,
Mediation,
Intervention,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CausalEvidenceGrade {
Rct,
QuasiExperimental,
Observational,
Theoretical,
}
pub const VALID_CAUSAL_CLAIMS: &[&str] = &["correlation", "mediation", "intervention"];
pub const VALID_CAUSAL_EVIDENCE_GRADES: &[&str] =
&["rct", "quasi_experimental", "observational", "theoretical"];
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Assertion {
pub text: String,
#[serde(rename = "type")]
pub assertion_type: String,
#[serde(default)]
pub entities: Vec<Entity>,
pub relation: Option<String>,
pub direction: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub causal_claim: Option<CausalClaim>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub causal_evidence_grade: Option<CausalEvidenceGrade>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Link {
pub target: String,
#[serde(rename = "type")]
pub link_type: String,
#[serde(default)]
pub note: String,
#[serde(default = "default_compiler")]
pub inferred_by: String,
#[serde(default)]
pub created_at: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mechanism: Option<Mechanism>,
}
fn default_compiler() -> String {
"compiler".into()
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Mechanism {
Linear {
sign: MechanismSign,
slope: f64,
},
Monotonic {
sign: MechanismSign,
},
Threshold {
sign: MechanismSign,
threshold: f64,
},
Saturating {
sign: MechanismSign,
half_max: f64,
},
Unknown,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum MechanismSign {
Positive,
Negative,
}
impl MechanismSign {
#[must_use]
pub fn as_f64(self) -> f64 {
match self {
Self::Positive => 1.0,
Self::Negative => -1.0,
}
}
}
impl Mechanism {
#[must_use]
pub fn apply(&self, delta_x: f64) -> Option<f64> {
match *self {
Self::Linear { sign, slope } => Some(sign.as_f64() * slope * delta_x),
Self::Monotonic { sign } => {
Some(sign.as_f64() * delta_x.signum() * delta_x.abs().min(1.0))
}
Self::Threshold { sign, threshold } => {
if delta_x.abs() >= threshold {
Some(sign.as_f64() * delta_x.signum())
} else {
Some(0.0)
}
}
Self::Saturating { sign, half_max } => {
let denom = delta_x.abs() + half_max.max(1e-9);
Some(sign.as_f64() * delta_x / denom)
}
Self::Unknown => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LinkRef {
Local { vf_id: String },
Cross { vf_id: String, vfr_id: String },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LinkParseError {
Empty,
BadVfPrefix,
BadVfrPrefix,
EmptyVfId,
EmptyVfrId,
TooManyAtSigns,
}
impl std::fmt::Display for LinkParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
LinkParseError::Empty => write!(f, "empty link target"),
LinkParseError::BadVfPrefix => write!(f, "link target must start with 'vf_'"),
LinkParseError::BadVfrPrefix => {
write!(f, "cross-frontier suffix must start with 'vfr_'")
}
LinkParseError::EmptyVfId => write!(f, "link target's vf_ id is empty"),
LinkParseError::EmptyVfrId => write!(f, "cross-frontier vfr_ id is empty"),
LinkParseError::TooManyAtSigns => {
write!(f, "link target has more than one '@' separator")
}
}
}
}
impl std::error::Error for LinkParseError {}
impl LinkRef {
pub fn parse(s: &str) -> Result<Self, LinkParseError> {
if s.is_empty() {
return Err(LinkParseError::Empty);
}
let mut parts = s.split('@');
let local = parts.next().ok_or(LinkParseError::Empty)?;
let remote = parts.next();
if parts.next().is_some() {
return Err(LinkParseError::TooManyAtSigns);
}
let vf_id = local
.strip_prefix("vf_")
.ok_or(LinkParseError::BadVfPrefix)?;
if vf_id.is_empty() {
return Err(LinkParseError::EmptyVfId);
}
match remote {
None => Ok(LinkRef::Local {
vf_id: local.to_string(),
}),
Some(r) => {
let vfr_id = r.strip_prefix("vfr_").ok_or(LinkParseError::BadVfrPrefix)?;
if vfr_id.is_empty() {
return Err(LinkParseError::EmptyVfrId);
}
Ok(LinkRef::Cross {
vf_id: local.to_string(),
vfr_id: r.to_string(),
})
}
}
}
pub fn format(&self) -> String {
match self {
LinkRef::Local { vf_id } => vf_id.clone(),
LinkRef::Cross { vf_id, vfr_id } => format!("{vf_id}@{vfr_id}"),
}
}
pub fn is_cross_frontier(&self) -> bool {
matches!(self, LinkRef::Cross { .. })
}
}
#[cfg(test)]
mod link_ref_tests {
use super::*;
#[test]
fn parses_local_vf_id() {
let r = LinkRef::parse("vf_abc123").unwrap();
assert_eq!(
r,
LinkRef::Local {
vf_id: "vf_abc123".into()
}
);
assert_eq!(r.format(), "vf_abc123");
assert!(!r.is_cross_frontier());
}
#[test]
fn parses_cross_frontier_target() {
let r = LinkRef::parse("vf_abc@vfr_def").unwrap();
assert_eq!(
r,
LinkRef::Cross {
vf_id: "vf_abc".into(),
vfr_id: "vfr_def".into(),
}
);
assert_eq!(r.format(), "vf_abc@vfr_def");
assert!(r.is_cross_frontier());
}
#[test]
fn rejects_empty() {
assert_eq!(LinkRef::parse(""), Err(LinkParseError::Empty));
}
#[test]
fn rejects_missing_vf_prefix() {
assert_eq!(LinkRef::parse("xx_abc"), Err(LinkParseError::BadVfPrefix));
}
#[test]
fn rejects_empty_vf_id() {
assert_eq!(LinkRef::parse("vf_"), Err(LinkParseError::EmptyVfId));
}
#[test]
fn rejects_missing_vfr_prefix_after_at() {
assert_eq!(
LinkRef::parse("vf_abc@xxx_def"),
Err(LinkParseError::BadVfrPrefix)
);
}
#[test]
fn rejects_empty_vfr_id() {
assert_eq!(
LinkRef::parse("vf_abc@vfr_"),
Err(LinkParseError::EmptyVfrId)
);
}
#[test]
fn rejects_double_at() {
assert_eq!(
LinkRef::parse("vf_abc@vfr_def@x"),
Err(LinkParseError::TooManyAtSigns)
);
}
#[test]
fn round_trips_real_ids() {
for s in [
"vf_d0a962d3251133dd",
"vf_d0a962d3251133dd@vfr_7344e96c0f2669d5",
] {
assert_eq!(LinkRef::parse(s).unwrap().format(), s);
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Annotation {
pub id: String,
pub text: String,
pub author: String,
pub timestamp: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provenance: Option<ProvenanceRef>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProvenanceRef {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub doi: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pmid: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub span: Option<String>,
}
impl ProvenanceRef {
#[must_use]
pub fn has_identifier(&self) -> bool {
self.doi.is_some() || self.pmid.is_some() || self.title.is_some()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Attachment {
pub filename: String,
pub label: Option<String>,
pub path: String,
pub size_bytes: u64,
pub mime_type: Option<String>,
pub attached_at: String,
pub attached_by: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReviewEvent {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace: Option<String>,
pub finding_id: String,
pub reviewer: String,
pub reviewed_at: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scope: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
pub action: ReviewAction,
#[serde(default)]
pub reason: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub evidence_considered: Vec<ReviewEvidence>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub state_change: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ReviewEvidence {
pub finding_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub role: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ReviewAction {
Approved,
Qualified { target: String },
Corrected {
field: String,
original: String,
corrected: String,
},
Flagged { flag_type: String },
Disputed {
counter_evidence: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
counter_doi: Option<String>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfidenceUpdate {
pub finding_id: String,
pub previous_score: f64,
pub new_score: f64,
pub basis: String,
pub updated_by: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FindingBundle {
pub id: String,
#[serde(default = "default_version")]
pub version: u32,
pub previous_version: Option<String>,
pub assertion: Assertion,
pub evidence: Evidence,
pub conditions: Conditions,
pub confidence: Confidence,
pub provenance: Provenance,
pub flags: Flags,
#[serde(default)]
pub links: Vec<Link>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub annotations: Vec<Annotation>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub attachments: Vec<Attachment>,
pub created: String,
pub updated: Option<String>,
#[serde(default, skip_serializing_if = "is_public_tier")]
pub access_tier: crate::access_tier::AccessTier,
}
fn is_public_tier(tier: &crate::access_tier::AccessTier) -> bool {
matches!(tier, crate::access_tier::AccessTier::Public)
}
fn default_version() -> u32 {
1
}
impl FindingBundle {
pub fn normalize_text(s: &str) -> String {
let lower = s.to_lowercase();
let collapsed: String = lower.split_whitespace().collect::<Vec<_>>().join(" ");
collapsed
.trim_end_matches(['.', ';', ':', '!', '?'])
.to_string()
}
pub fn content_address(assertion: &Assertion, provenance: &Provenance) -> String {
let norm_text = Self::normalize_text(&assertion.text);
let prov_id = provenance
.doi
.as_deref()
.or(provenance.pmid.as_deref())
.unwrap_or(&provenance.title);
let preimage = format!("{}|{}|{}", norm_text, assertion.assertion_type, prov_id);
let hash = Sha256::digest(preimage.as_bytes());
format!("vf_{}", &hex::encode(hash)[..16])
}
pub fn new(
assertion: Assertion,
evidence: Evidence,
conditions: Conditions,
confidence: Confidence,
provenance: Provenance,
flags: Flags,
) -> Self {
let now = Utc::now().to_rfc3339();
let id = Self::content_address(&assertion, &provenance);
Self {
id,
version: 1,
previous_version: None,
assertion,
evidence,
conditions,
confidence,
provenance,
flags,
links: Vec::new(),
annotations: Vec::new(),
attachments: Vec::new(),
created: now,
updated: None,
access_tier: crate::access_tier::AccessTier::Public,
}
}
pub fn add_link(&mut self, target_id: &str, link_type: &str, note: &str) {
self.links.push(Link {
target: target_id.to_string(),
link_type: link_type.to_string(),
note: note.to_string(),
inferred_by: "compiler".to_string(),
created_at: Utc::now().to_rfc3339(),
mechanism: None,
});
}
pub fn add_link_with_source(
&mut self,
target_id: &str,
link_type: &str,
note: &str,
inferred_by: &str,
) {
self.links.push(Link {
target: target_id.to_string(),
link_type: link_type.to_string(),
note: note.to_string(),
inferred_by: inferred_by.to_string(),
created_at: Utc::now().to_rfc3339(),
mechanism: None,
});
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_assertion() -> Assertion {
Assertion {
text: "NLRP3 activates IL-1B".into(),
assertion_type: "mechanism".into(),
entities: vec![Entity {
name: "NLRP3".into(),
entity_type: "protein".into(),
identifiers: serde_json::Map::new(),
canonical_id: None,
candidates: vec![],
aliases: vec![],
resolution_provenance: None,
resolution_confidence: 1.0,
resolution_method: None,
species_context: None,
needs_review: false,
}],
relation: Some("activates".into()),
direction: Some("positive".into()),
causal_claim: None,
causal_evidence_grade: None,
}
}
fn sample_evidence() -> Evidence {
Evidence {
evidence_type: "experimental".into(),
model_system: "mouse".into(),
species: Some("Mus musculus".into()),
method: "Western blot".into(),
sample_size: Some("n=30".into()),
effect_size: None,
p_value: Some("p<0.05".into()),
replicated: true,
replication_count: Some(3),
evidence_spans: vec![],
}
}
fn sample_conditions() -> Conditions {
Conditions {
text: "In vitro, mouse microglia".into(),
species_verified: vec!["Mus musculus".into()],
species_unverified: vec![],
in_vitro: true,
in_vivo: false,
human_data: false,
clinical_trial: false,
concentration_range: None,
duration: None,
age_group: None,
cell_type: Some("microglia".into()),
}
}
fn sample_confidence() -> Confidence {
Confidence {
kind: ConfidenceKind::FrontierEpistemic,
score: 0.85,
basis: "Experimental with replication".into(),
method: ConfidenceMethod::LlmInitial,
components: None,
extraction_confidence: 0.9,
}
}
fn sample_provenance() -> Provenance {
Provenance {
source_type: "published_paper".into(),
doi: Some("10.1234/test".into()),
pmid: None,
pmc: None,
openalex_id: None,
url: None,
title: "Test Paper".into(),
authors: vec![Author {
name: "Smith J".into(),
orcid: None,
}],
year: Some(2024),
journal: Some("Nature".into()),
license: None,
publisher: None,
funders: vec![],
extraction: Extraction::default(),
review: None,
citation_count: Some(100),
}
}
fn sample_flags() -> Flags {
Flags {
gap: false,
negative_space: false,
contested: false,
retracted: false,
declining: false,
gravity_well: false,
review_state: None,
superseded: false,
signature_threshold: None,
jointly_accepted: false,
}
}
#[test]
fn same_content_same_id() {
let b1 = FindingBundle::new(
sample_assertion(),
sample_evidence(),
sample_conditions(),
sample_confidence(),
sample_provenance(),
sample_flags(),
);
let b2 = FindingBundle::new(
sample_assertion(),
sample_evidence(),
sample_conditions(),
sample_confidence(),
sample_provenance(),
sample_flags(),
);
assert_eq!(b1.id, b2.id);
}
#[test]
fn different_content_different_id() {
let b1 = FindingBundle::new(
sample_assertion(),
sample_evidence(),
sample_conditions(),
sample_confidence(),
sample_provenance(),
sample_flags(),
);
let mut different_assertion = sample_assertion();
different_assertion.text = "Completely different claim".into();
let b2 = FindingBundle::new(
different_assertion,
sample_evidence(),
sample_conditions(),
sample_confidence(),
sample_provenance(),
sample_flags(),
);
assert_ne!(b1.id, b2.id);
}
#[test]
fn id_starts_with_vf_prefix() {
let b = FindingBundle::new(
sample_assertion(),
sample_evidence(),
sample_conditions(),
sample_confidence(),
sample_provenance(),
sample_flags(),
);
assert!(b.id.starts_with("vf_"));
assert_eq!(b.id.len(), 3 + 16); }
#[test]
fn new_bundle_version_is_one() {
let b = FindingBundle::new(
sample_assertion(),
sample_evidence(),
sample_conditions(),
sample_confidence(),
sample_provenance(),
sample_flags(),
);
assert_eq!(b.version, 1);
assert!(b.previous_version.is_none());
}
#[test]
fn new_bundle_has_no_links() {
let b = FindingBundle::new(
sample_assertion(),
sample_evidence(),
sample_conditions(),
sample_confidence(),
sample_provenance(),
sample_flags(),
);
assert!(b.links.is_empty());
}
#[test]
fn new_bundle_has_created_timestamp() {
let b = FindingBundle::new(
sample_assertion(),
sample_evidence(),
sample_conditions(),
sample_confidence(),
sample_provenance(),
sample_flags(),
);
assert!(!b.created.is_empty());
assert!(b.updated.is_none());
}
#[test]
fn add_link_works() {
let mut b = FindingBundle::new(
sample_assertion(),
sample_evidence(),
sample_conditions(),
sample_confidence(),
sample_provenance(),
sample_flags(),
);
b.add_link("target_id", "extends", "shared entity");
assert_eq!(b.links.len(), 1);
assert_eq!(b.links[0].target, "target_id");
assert_eq!(b.links[0].link_type, "extends");
assert_eq!(b.links[0].note, "shared entity");
assert_eq!(b.links[0].inferred_by, "compiler");
}
#[test]
fn add_link_with_source_works() {
let mut b = FindingBundle::new(
sample_assertion(),
sample_evidence(),
sample_conditions(),
sample_confidence(),
sample_provenance(),
sample_flags(),
);
b.add_link_with_source(
"target_id",
"contradicts",
"opposite direction",
"entity_overlap",
);
assert_eq!(b.links.len(), 1);
assert_eq!(b.links[0].inferred_by, "entity_overlap");
}
#[test]
fn multiple_links_accumulate() {
let mut b = FindingBundle::new(
sample_assertion(),
sample_evidence(),
sample_conditions(),
sample_confidence(),
sample_provenance(),
sample_flags(),
);
b.add_link("t1", "extends", "note1");
b.add_link("t2", "contradicts", "note2");
b.add_link("t3", "supports", "note3");
assert_eq!(b.links.len(), 3);
}
#[test]
fn review_event_creation() {
let event = ReviewEvent {
id: "rev_abc123".into(),
workspace: None,
finding_id: "vf_abc".into(),
reviewer: "0000-0001-2345-6789".into(),
reviewed_at: "2024-01-01T00:00:00Z".into(),
scope: None,
status: None,
action: ReviewAction::Approved,
reason: "Looks correct".into(),
evidence_considered: vec![],
state_change: None,
};
assert_eq!(event.finding_id, "vf_abc");
assert_eq!(event.reviewer, "0000-0001-2345-6789");
}
#[test]
fn review_action_corrected() {
let action = ReviewAction::Corrected {
field: "direction".into(),
original: "positive".into(),
corrected: "negative".into(),
};
if let ReviewAction::Corrected {
field,
original,
corrected,
} = action
{
assert_eq!(field, "direction");
assert_eq!(original, "positive");
assert_eq!(corrected, "negative");
} else {
panic!("Expected Corrected variant");
}
}
#[test]
fn review_action_disputed() {
let action = ReviewAction::Disputed {
counter_evidence: "Later study contradicts".into(),
counter_doi: Some("10.1234/counter".into()),
};
if let ReviewAction::Disputed {
counter_evidence,
counter_doi,
} = action
{
assert_eq!(counter_evidence, "Later study contradicts");
assert_eq!(counter_doi, Some("10.1234/counter".into()));
} else {
panic!("Expected Disputed variant");
}
}
#[test]
fn confidence_update_creation() {
let update = ConfidenceUpdate {
finding_id: "vf_abc".into(),
previous_score: 0.7,
new_score: 0.85,
basis: "grounded".into(),
updated_by: "grounding_pass".into(),
updated_at: "2024-01-01T00:00:00Z".into(),
};
assert_eq!(update.previous_score, 0.7);
assert_eq!(update.new_score, 0.85);
assert_eq!(update.updated_by, "grounding_pass");
}
#[test]
fn finding_serializes_and_deserializes() {
let b = FindingBundle::new(
sample_assertion(),
sample_evidence(),
sample_conditions(),
sample_confidence(),
sample_provenance(),
sample_flags(),
);
let json = serde_json::to_string(&b).unwrap();
let b2: FindingBundle = serde_json::from_str(&json).unwrap();
assert_eq!(b.id, b2.id);
assert_eq!(b.assertion.text, b2.assertion.text);
assert_eq!(b.confidence.score, b2.confidence.score);
}
#[test]
fn valid_entity_types_list() {
for t in ["gene", "protein", "compound", "other"] {
assert!(VALID_ENTITY_TYPES.contains(&t), "missing {t}");
}
for t in ["particle", "instrument", "dataset", "quantity"] {
assert!(VALID_ENTITY_TYPES.contains(&t), "missing {t}");
}
assert_eq!(VALID_ENTITY_TYPES.len(), 14);
}
#[test]
fn v0_10_assertion_and_source_extensions() {
assert!(VALID_ASSERTION_TYPES.contains(&"measurement"));
assert!(VALID_ASSERTION_TYPES.contains(&"exclusion"));
assert!(VALID_PROVENANCE_SOURCE_TYPES.contains(&"data_release"));
}
#[test]
fn confidence_does_not_affect_id() {
let b1 = FindingBundle::new(
sample_assertion(),
sample_evidence(),
sample_conditions(),
sample_confidence(),
sample_provenance(),
sample_flags(),
);
let mut conf2 = sample_confidence();
conf2.score = 0.5;
let b2 = FindingBundle::new(
sample_assertion(),
sample_evidence(),
sample_conditions(),
conf2,
sample_provenance(),
sample_flags(),
);
assert_eq!(b1.id, b2.id);
}
#[test]
fn flags_do_not_affect_id() {
let b1 = FindingBundle::new(
sample_assertion(),
sample_evidence(),
sample_conditions(),
sample_confidence(),
sample_provenance(),
sample_flags(),
);
let mut flags2 = sample_flags();
flags2.gap = true;
flags2.contested = true;
let b2 = FindingBundle::new(
sample_assertion(),
sample_evidence(),
sample_conditions(),
sample_confidence(),
sample_provenance(),
flags2,
);
assert_eq!(b1.id, b2.id);
}
#[test]
fn different_assertion_text_different_id() {
let b1 = FindingBundle::new(
sample_assertion(),
sample_evidence(),
sample_conditions(),
sample_confidence(),
sample_provenance(),
sample_flags(),
);
let mut assertion2 = sample_assertion();
assertion2.assertion_type = "therapeutic".into();
let b2 = FindingBundle::new(
assertion2,
sample_evidence(),
sample_conditions(),
sample_confidence(),
sample_provenance(),
sample_flags(),
);
assert_ne!(b1.id, b2.id);
}
#[test]
fn different_doi_different_id() {
let b1 = FindingBundle::new(
sample_assertion(),
sample_evidence(),
sample_conditions(),
sample_confidence(),
sample_provenance(),
sample_flags(),
);
let mut prov2 = sample_provenance();
prov2.doi = Some("10.5678/other".into());
let b2 = FindingBundle::new(
sample_assertion(),
sample_evidence(),
sample_conditions(),
sample_confidence(),
prov2,
sample_flags(),
);
assert_ne!(b1.id, b2.id);
}
#[test]
fn content_address_is_deterministic_across_runs() {
let assertion1 = Assertion {
text: "Mitochondrial dysfunction precedes amyloid plaque formation.".into(),
assertion_type: "mechanism".into(),
entities: vec![],
relation: None,
direction: None,
causal_claim: None,
causal_evidence_grade: None,
};
let prov1 = Provenance {
source_type: "published_paper".into(),
doi: Some("10.1038/s41586-023-06789-1".into()),
pmid: None,
pmc: None,
openalex_id: None,
url: None,
title: "Mitochondria in AD".into(),
authors: vec![],
year: Some(2023),
journal: None,
license: None,
publisher: None,
funders: vec![],
extraction: Extraction::default(),
review: None,
citation_count: None,
};
let assertion2 = Assertion {
text: "Mitochondrial dysfunction precedes amyloid plaque formation.".into(),
assertion_type: "mechanism".into(),
entities: vec![Entity {
name: "mitochondria".into(),
entity_type: "anatomical_structure".into(),
identifiers: serde_json::Map::new(),
canonical_id: None,
candidates: vec![],
aliases: vec![],
resolution_provenance: None,
resolution_confidence: 1.0,
resolution_method: None,
species_context: None,
needs_review: false,
}],
relation: Some("precedes".into()),
direction: Some("positive".into()),
causal_claim: None,
causal_evidence_grade: None,
};
let prov2 = Provenance {
source_type: "published_paper".into(),
doi: Some("10.1038/s41586-023-06789-1".into()),
pmid: Some("37654321".into()),
pmc: None,
openalex_id: None,
url: None,
title: "Different title".into(),
authors: vec![Author {
name: "Jones A".into(),
orcid: None,
}],
year: Some(2023),
journal: Some("Nature".into()),
license: None,
publisher: None,
funders: vec![],
extraction: Extraction::default(),
review: None,
citation_count: Some(50),
};
let id1 = FindingBundle::content_address(&assertion1, &prov1);
let id2 = FindingBundle::content_address(&assertion2, &prov2);
assert_eq!(
id1, id2,
"Same assertion text + type + DOI must produce same ID"
);
}
#[test]
fn content_address_normalizes_whitespace_and_punctuation() {
let assertion1 = Assertion {
text: " NLRP3 activates IL-1B. ".into(),
assertion_type: "mechanism".into(),
entities: vec![],
relation: None,
direction: None,
causal_claim: None,
causal_evidence_grade: None,
};
let assertion2 = Assertion {
text: "NLRP3 activates IL-1B".into(),
assertion_type: "mechanism".into(),
entities: vec![],
relation: None,
direction: None,
causal_claim: None,
causal_evidence_grade: None,
};
let prov = sample_provenance();
let id1 = FindingBundle::content_address(&assertion1, &prov);
let id2 = FindingBundle::content_address(&assertion2, &prov);
assert_eq!(
id1, id2,
"Whitespace and trailing punctuation should be normalized away"
);
}
#[test]
fn content_address_falls_back_to_title_when_no_doi_or_pmid() {
let assertion = sample_assertion();
let mut prov = sample_provenance();
prov.doi = None;
prov.pmid = None;
prov.title = "Fallback Title".into();
let id = FindingBundle::content_address(&assertion, &prov);
assert!(id.starts_with("vf_"));
assert_eq!(id.len(), 19);
let mut prov2 = sample_provenance();
prov2.doi = None;
prov2.pmid = None;
prov2.title = "Fallback Title".into();
let id2 = FindingBundle::content_address(&assertion, &prov2);
assert_eq!(id, id2);
}
#[test]
fn content_address_prefers_doi_over_pmid_over_title() {
let assertion = sample_assertion();
let mut prov_doi = sample_provenance();
prov_doi.doi = Some("10.1234/test".into());
prov_doi.pmid = Some("12345".into());
prov_doi.title = "Title".into();
let mut prov_pmid = sample_provenance();
prov_pmid.doi = None;
prov_pmid.pmid = Some("12345".into());
prov_pmid.title = "Title".into();
let mut prov_title = sample_provenance();
prov_title.doi = None;
prov_title.pmid = None;
prov_title.title = "Title".into();
let id_doi = FindingBundle::content_address(&assertion, &prov_doi);
let id_pmid = FindingBundle::content_address(&assertion, &prov_pmid);
let id_title = FindingBundle::content_address(&assertion, &prov_title);
assert_ne!(id_doi, id_pmid, "DOI vs PMID should differ");
assert_ne!(id_pmid, id_title, "PMID vs title should differ");
assert_ne!(id_doi, id_title, "DOI vs title should differ");
}
#[test]
fn compute_confidence_meta_analysis_human() {
let evidence = Evidence {
evidence_type: "meta_analysis".into(),
model_system: "human cohorts".into(),
species: Some("Homo sapiens".into()),
method: "meta-analysis".into(),
sample_size: Some("n=5000".into()),
effect_size: None,
p_value: None,
replicated: true,
replication_count: Some(5),
evidence_spans: vec![],
};
let conditions = Conditions {
text: String::new(),
species_verified: vec![],
species_unverified: vec![],
in_vitro: false,
in_vivo: false,
human_data: true,
clinical_trial: false,
concentration_range: None,
duration: None,
age_group: None,
cell_type: None,
};
let conf = compute_confidence(&evidence, &conditions, false);
assert_eq!(conf.method, ConfidenceMethod::Computed);
assert_eq!(conf.kind, ConfidenceKind::FrontierEpistemic);
assert!(conf.components.is_some());
let c = conf.components.unwrap();
assert!((c.evidence_strength - 0.95).abs() < 0.001);
assert!((c.replication_strength - 1.0).abs() < 0.001); assert!((c.sample_strength - 1.0).abs() < 0.001); assert!((c.model_relevance - 1.0).abs() < 0.001); assert!((c.review_penalty - 0.0).abs() < 0.001);
assert!((c.calibration_adjustment - 0.0).abs() < 0.001);
assert!((conf.score - 0.95).abs() < 0.001);
}
#[test]
fn compute_confidence_theoretical_no_replication() {
let evidence = Evidence {
evidence_type: "theoretical".into(),
model_system: "computational".into(),
species: None,
method: "simulation".into(),
sample_size: None,
effect_size: None,
p_value: None,
replicated: false,
replication_count: None,
evidence_spans: vec![],
};
let conditions = Conditions {
text: String::new(),
species_verified: vec![],
species_unverified: vec![],
in_vitro: false,
in_vivo: false,
human_data: false,
clinical_trial: false,
concentration_range: None,
duration: None,
age_group: None,
cell_type: None,
};
let conf = compute_confidence(&evidence, &conditions, false);
let c = conf.components.unwrap();
assert!((c.evidence_strength - 0.30).abs() < 0.001);
assert!((c.replication_strength - 0.70).abs() < 0.001);
assert!((c.sample_strength - 0.60).abs() < 0.001);
assert!((c.model_relevance - 0.50).abs() < 0.001);
assert!((conf.score - 0.063).abs() < 0.001);
}
#[test]
fn compute_confidence_contested_penalty() {
let evidence = Evidence {
evidence_type: "experimental".into(),
model_system: "mouse".into(),
species: Some("Mus musculus".into()),
method: "Western blot".into(),
sample_size: Some("n=30".into()),
effect_size: None,
p_value: None,
replicated: false,
replication_count: None,
evidence_spans: vec![],
};
let conditions = Conditions {
text: String::new(),
species_verified: vec![],
species_unverified: vec![],
in_vitro: false,
in_vivo: true,
human_data: false,
clinical_trial: false,
concentration_range: None,
duration: None,
age_group: None,
cell_type: None,
};
let uncontested = compute_confidence(&evidence, &conditions, false);
let contested = compute_confidence(&evidence, &conditions, true);
assert!((contested.score - (uncontested.score - 0.15)).abs() < 0.001);
}
#[test]
fn compute_confidence_sample_size_parsing() {
assert_eq!(parse_sample_size("n=30"), Some(30));
assert_eq!(parse_sample_size("n = 120"), Some(120));
assert_eq!(parse_sample_size("3 cohorts of 20"), Some(20));
assert_eq!(parse_sample_size("500"), Some(500));
assert_eq!(parse_sample_size(""), None);
}
#[test]
fn compute_confidence_v010_deserialize_compat() {
let json = r#"{"score": 0.75, "basis": "legacy seeded confidence", "extraction_confidence": 0.85}"#;
let conf: Confidence = serde_json::from_str(json).unwrap();
assert!((conf.score - 0.75).abs() < 0.001);
assert_eq!(conf.kind, ConfidenceKind::FrontierEpistemic);
assert_eq!(conf.method, ConfidenceMethod::LlmInitial); assert!(conf.components.is_none());
}
#[test]
fn compute_confidence_components_deserialize_legacy_names() {
let json = r#"{
"score": 0.75,
"basis": "legacy components",
"method": "computed",
"components": {
"evidence_grade": 0.8,
"replication_factor": 0.7,
"sample_strength": 0.6,
"species_relevance": 0.8,
"contradiction_penalty": 0.15
},
"extraction_confidence": 0.85
}"#;
let conf: Confidence = serde_json::from_str(json).unwrap();
let components = conf.components.unwrap();
assert!((components.evidence_strength - 0.8).abs() < 0.001);
assert!((components.replication_strength - 0.7).abs() < 0.001);
assert!((components.sample_strength - 0.6).abs() < 0.001);
assert!((components.model_relevance - 0.8).abs() < 0.001);
assert!((components.review_penalty - 0.15).abs() < 0.001);
assert!((components.calibration_adjustment - 0.0).abs() < 0.001);
}
#[test]
fn compute_confidence_serializes_new_component_names_and_kind() {
let conf = compute_confidence(&sample_evidence(), &sample_conditions(), false);
let value = serde_json::to_value(&conf).unwrap();
assert_eq!(value["kind"], "frontier_epistemic");
let components = &value["components"];
assert!(components.get("evidence_strength").is_some());
assert!(components.get("replication_strength").is_some());
assert!(components.get("model_relevance").is_some());
assert!(components.get("review_penalty").is_some());
assert!(components.get("calibration_adjustment").is_some());
assert!(components.get("evidence_grade").is_none());
assert!(components.get("replication_factor").is_none());
assert!(components.get("species_relevance").is_none());
assert!(components.get("contradiction_penalty").is_none());
}
#[test]
fn recompute_all_updates_findings() {
let mut b = FindingBundle::new(
sample_assertion(),
sample_evidence(),
sample_conditions(),
sample_confidence(),
sample_provenance(),
sample_flags(),
);
let old_score = b.confidence.score;
assert!((old_score - 0.85).abs() < 0.001);
let changed = recompute_all_confidence(std::slice::from_mut(&mut b), &[]);
assert_eq!(b.confidence.method, ConfidenceMethod::Computed);
assert!(b.confidence.components.is_some());
assert!((b.confidence.score - 0.336).abs() < 0.001);
assert_eq!(changed, 1);
}
#[test]
fn causal_multiplier_neutral_when_either_field_none() {
assert!((causal_consistency_multiplier(None, None) - 1.0).abs() < 1e-12);
assert!(
(causal_consistency_multiplier(Some(CausalClaim::Intervention), None) - 1.0).abs()
< 1e-12
);
assert!(
(causal_consistency_multiplier(None, Some(CausalEvidenceGrade::Rct)) - 1.0).abs()
< 1e-12
);
}
#[test]
fn rct_grade_bumps_any_claim() {
for c in [
CausalClaim::Correlation,
CausalClaim::Mediation,
CausalClaim::Intervention,
] {
assert!(
(causal_consistency_multiplier(Some(c), Some(CausalEvidenceGrade::Rct)) - 1.10)
.abs()
< 1e-12,
"RCT should bump claim {c:?}"
);
}
}
#[test]
fn observational_intervention_gets_strong_penalty() {
let m = causal_consistency_multiplier(
Some(CausalClaim::Intervention),
Some(CausalEvidenceGrade::Observational),
);
assert!(
(m - 0.65).abs() < 1e-12,
"intervention from observational should be 0.65, got {m}"
);
}
#[test]
fn correlation_neutral_under_any_grade() {
for g in [
CausalEvidenceGrade::QuasiExperimental,
CausalEvidenceGrade::Observational,
CausalEvidenceGrade::Theoretical,
] {
let m = causal_consistency_multiplier(Some(CausalClaim::Correlation), Some(g));
assert!(
(m - 1.0).abs() < 1e-12,
"correlation should be neutral for grade {g:?}, got {m}"
);
}
}
#[test]
fn confidence_score_unchanged_for_pre_v0_38_findings() {
let mut e = sample_evidence();
e.replicated = false;
e.replication_count = None;
let c = sample_conditions();
let score_legacy_path = compute_confidence(&e, &c, false).score;
let score_kernel_path =
compute_confidence_from_components(&e, &c, false, 0, 0, 0, None, None).score;
assert!((score_legacy_path - score_kernel_path).abs() < 1e-12);
let conf = compute_confidence_from_components(&e, &c, false, 0, 0, 0, None, None);
let cc = conf.components.unwrap().causal_consistency;
assert!((cc - 1.0).abs() < 1e-12);
}
#[test]
fn intervention_from_observational_drops_score_meaningfully() {
let e = sample_evidence();
let c = sample_conditions();
let neutral = compute_confidence_from_components(&e, &c, false, 0, 0, 0, None, None);
let observational_intervention = compute_confidence_from_components(
&e,
&c,
false,
0,
0,
0,
Some(CausalClaim::Intervention),
Some(CausalEvidenceGrade::Observational),
);
let drop = neutral.score - observational_intervention.score;
assert!(
drop > 0.05,
"observational-intervention should drop score noticeably; got {drop}"
);
}
#[test]
fn parses_bbb_review_event_with_richer_schema() {
let raw = include_str!("../embedded/tests/fixtures/legacy/rev_001_bbb_correction.json");
let review: ReviewEvent = serde_json::from_str(raw).unwrap();
assert_eq!(review.id, "rev_001_bbb_correction");
assert_eq!(review.workspace.as_deref(), Some("projects/bbb-flagship"));
assert_eq!(review.scope.as_deref(), Some("bbb_opening_trusted_subset"));
assert_eq!(review.status.as_deref(), Some("accepted"));
assert!(matches!(
review.action,
ReviewAction::Qualified { ref target } if target == "trusted_interpretation"
));
assert_eq!(review.evidence_considered.len(), 3);
assert_eq!(
review.evidence_considered[0].role.as_deref(),
Some("qualifier")
);
assert_eq!(
review
.state_change
.as_ref()
.and_then(|value| value.get("assumption_retired"))
.and_then(|value| value.as_str()),
Some("safe opening implies therapeutic efficacy")
);
}
#[test]
fn artifact_requires_sha256_and_stable_kind() {
let artifact = Artifact::new(
"clinical_trial_record",
"AHEAD 3-45",
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
Some(42),
Some("application/json".into()),
"local_blob",
Some(".vela/artifact-blobs/sha256/aaaaaaaa".into()),
Some("https://clinicaltrials.gov/study/NCT04468659".into()),
Some("ClinicalTrials.gov public record".into()),
vec!["vf_demo".into()],
sample_provenance(),
BTreeMap::new(),
crate::access_tier::AccessTier::Public,
)
.unwrap();
assert!(artifact.id.starts_with("va_"));
assert_eq!(
artifact.content_hash,
"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
);
assert_eq!(artifact.kind, "clinical_trial_record");
}
}