diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fba90a..d243900 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,19 @@ before bumping. See README § "Versioning & stability". ## [Unreleased] +## [0.15.0] - 2026-09-19 + ### Added +- `mbse::requirements`: `RequirementGraph`/`Requirement`/`RequirementRelation` + and the five induced viewpoints (decomposition, traceability, impact, + behaviour, verification coverage), vendored from kr0ki-core's + `requirements` module (kr0ki M1 — the backend-neutral ReqIF/Flexo + requirements semantic model, previously a local kr0ki-core integration + seam). Preserves asserted/inferred/proposed relation authority and + explicit `promote_relation()`; emits no renderer source. kr0ki-core now + re-exports this module rather than defining its own copy. + (`PromptExecution/kr0ki` `HANDOFF-2026-09-19-reqif-flexo.md`, + lead-developer action #1) - `sysgraph`: `SysGraph`/`OntologicalNode` — the box-2 typed envelope holding a full snapshot of UFO-stereotyped nodes plus `ontology:: OntologicalEdge`s, per kr0ki `docs/TODO.md` box 2 and `DESIGN-NOTE- diff --git a/Cargo.toml b/Cargo.toml index 516d086..6363d01 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ufo-types" -version = "0.14.1" +version = "0.15.0" edition = "2024" rust-version = "1.85" authors = ["Brian Horakh "] diff --git a/README.md b/README.md index 0a1c1a9..96168e8 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ Projects cleared to depend on `ufo-types` directly (retire any vendored copy): |---|---|---|---| | **b00t** ([elasticdotventures/_b00t_](https://github.com/elasticdotventures/_b00t_)) | `b00t-c0re-lib`, `b00t-lib-chat`, `b00t-cli` | `stereotype`, `satisfies`, `capability`, `dare` | `=0.14.0` | | **ledgrrr** ([PromptExecution/ledgrrr](https://github.com/PromptExecution/ledgrrr)) | `ledger-core`, `ledgerr-mcp` | `stereotype`, `satisfies`, `iso` | `=0.14.0` (vendored copy retired) | -| **kr0ki** ([PromptExecution/kr0ki](https://github.com/PromptExecution/kr0ki)) | `kr0ki-core` | `iso_ir`, `ontology`, `sysml_model`, `view` — the model-ingestion path | `tag = "v0.14.0"` | +| **kr0ki** ([PromptExecution/kr0ki](https://github.com/PromptExecution/kr0ki)) | `kr0ki-core` | `iso_ir`, `ontology`, `sysml_model`, `view` — the model-ingestion path; `mbse::requirements` — the ReqIF/Flexo requirements semantic model, re-exported as `kr0ki_core::requirements` | `tag = "v0.15.0"` | | **m0ltis** ([elasticdotventures/moltis-b00t](https://github.com/elasticdotventures/moltis-b00t)) | *(planned)* provider→ufo-types lowering | `iso_ir`, `stereotype`, `sysml_model` | `tag = "v0.14.0"` | | **critter-keeper** ([app4dog](https://github.com/app4dog)) | — | `stereotype` | `=0.14.0` | | **cim-gridy** `mission-engine` | — | `stereotype`, `satisfies` | `=0.14.0` | diff --git a/src/mbse.rs b/src/mbse.rs index c4b91db..8f21788 100644 --- a/src/mbse.rs +++ b/src/mbse.rs @@ -37,6 +37,12 @@ //! that pattern: it nests each child's own `to_sysml_v2()` output rather //! than re-deriving it. +/// Backend-neutral requirements semantics (ReqIF/Flexo baselines, five +/// induced viewpoints, promotion of inferred/proposed relations). Vendored +/// from kr0ki-core 'requirements' module (kr0ki M1, 2026-09-19) as the +/// canonical shared MBSE-requirements contract; kr0ki-core re-exports it. +pub mod requirements; + use serde::Serialize; use serde_json::Value; diff --git a/src/mbse/requirements.rs b/src/mbse/requirements.rs new file mode 100644 index 0000000..94febac --- /dev/null +++ b/src/mbse/requirements.rs @@ -0,0 +1,875 @@ +//! Backend-neutral requirements semantics and induced views. +//! +//! This module is deliberately independent from ReqIF XML, OPA, Flexo, HTTP, +//! and diagram grammars. Adapters lower their data into [`RequirementGraph`]; +//! consumers ask it for a [`ViewRequest`] and receive an induced typed graph. +//! Rendering is a consumer's concern (e.g. `kr0ki-core::requirements_render` +//! lowers a [`ViewResult`] to D2) — this module emits no renderer source, +//! keeping the semantic contract reusable by a Flexo extension or an MCP +//! server. + +use std::collections::{BTreeMap, BTreeSet, VecDeque}; + +use serde::{Deserialize, Serialize}; + +/// An immutable ReqIF/Flexo baseline identity. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BaselineIdentity { + pub id: String, + /// Flexo commit or other immutable source revision. + pub revision: String, + /// Digest of the import artifact, when one exists. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub import_artifact_sha256: Option, + /// Digest of the deterministic exported ReqIF baseline. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub exported_baseline_sha256: Option, +} + +/// Where a fact came from. The URI is deliberately opaque: it can name a +/// ReqIF object, a Flexo RDF resource, or an OPA input artifact. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Provenance { + pub source_uri: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub artifact_sha256: Option, + #[serde(default)] + pub locator: Option, +} + +/// A test, review record, analysis report, or other verification evidence. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EvidenceRef { + pub id: String, + pub label: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub uri: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provenance: Option, +} + +/// One normalized requirement. Attributes retain ReqIF's extensible fields +/// without making the common attributes stringly typed at every call site. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Requirement { + pub id: String, + pub title: String, + pub text: String, + pub baseline: BaselineIdentity, + pub provenance: Provenance, + #[serde(default)] + pub attributes: BTreeMap, + #[serde(default)] + pub evidence: Vec, +} + +/// The asserted relation vocabulary shared by ReqIF, Flexo, and OPA adapters. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RequirementRelationKind { + Contains, + Derives, + Refines, + Requires, + Satisfies, + Verifies, + Implements, + Traces, + AllocatedTo, + Precedes, +} + +impl RequirementRelationKind { + pub const DECOMPOSITION: &'static [Self] = &[Self::Contains, Self::Derives, Self::Refines]; + pub const TRACEABILITY: &'static [Self] = &[ + Self::Satisfies, + Self::Verifies, + Self::Implements, + Self::Traces, + Self::AllocatedTo, + ]; +} + +/// The model/version that produced an inferred or proposed relation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ModelIdentity { + pub name: String, + pub version: String, +} + +/// Information attached to an edge which is not yet authoritative. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct NonAuthoritativeRelation { + /// A finite score in `[0, 1]`; validation rejects anything else. + pub confidence: f64, + pub rationale: String, + #[serde(default)] + pub evidence: Vec, + pub model: ModelIdentity, +} + +/// Whether an edge is authoritative. Inferred and proposed edges are visible +/// in review views only and can affect authoritative views only after explicit +/// promotion. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum RelationAuthority { + Asserted, + Inferred(NonAuthoritativeRelation), + Proposed(NonAuthoritativeRelation), +} + +impl RelationAuthority { + pub const fn is_asserted(&self) -> bool { + matches!(self, Self::Asserted) + } +} + +/// Audit record made when a human promotes an inferred/proposed relation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Promotion { + pub actor: String, + pub rationale: String, + pub previous_status: String, +} + +/// A directed relation. Endpoints may refer to a requirement or evidence; this +/// permits verification and implementation traces to leave the ReqIF tree. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RequirementRelation { + pub id: String, + pub source: String, + pub target: String, + pub kind: RequirementRelationKind, + pub authority: RelationAuthority, + pub provenance: Provenance, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub promotion: Option, +} + +impl RequirementRelation { + /// Promote a review-only relation after an explicit human decision. + pub fn promote( + &mut self, + actor: impl Into, + rationale: impl Into, + ) -> Result<(), RequirementError> { + let previous_status = match &self.authority { + RelationAuthority::Asserted => { + return Err(RequirementError::AlreadyAsserted(self.id.clone())); + } + RelationAuthority::Inferred(_) => "inferred", + RelationAuthority::Proposed(_) => "proposed", + }; + self.authority = RelationAuthority::Asserted; + self.promotion = Some(Promotion { + actor: actor.into(), + rationale: rationale.into(), + previous_status: previous_status.to_string(), + }); + Ok(()) + } +} + +/// The normalized requirements graph consumed by all views. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RequirementGraph { + pub baseline: BaselineIdentity, + #[serde(default)] + pub requirements: Vec, + #[serde(default)] + pub evidence: Vec, + #[serde(default)] + pub relations: Vec, +} + +impl RequirementGraph { + /// Promote by id. This is intentionally an explicit mutation, never an + /// incidental side effect of a view request. + pub fn promote_relation( + &mut self, + relation_id: &str, + actor: impl Into, + rationale: impl Into, + ) -> Result<(), RequirementError> { + let edge = self + .relations + .iter_mut() + .find(|edge| edge.id == relation_id) + .ok_or_else(|| RequirementError::UnknownRelation(relation_id.to_string()))?; + edge.promote(actor, rationale) + } + + /// Validates graph references and guardrails before any adapter persists it. + pub fn validate(&self) -> Result<(), RequirementError> { + let mut ids = BTreeSet::new(); + for requirement in &self.requirements { + if !ids.insert(requirement.id.as_str()) { + return Err(RequirementError::DuplicateNode(requirement.id.clone())); + } + if requirement.baseline != self.baseline { + return Err(RequirementError::BaselineMismatch(requirement.id.clone())); + } + } + for evidence in &self.evidence { + if !ids.insert(evidence.id.as_str()) { + return Err(RequirementError::DuplicateNode(evidence.id.clone())); + } + } + let mut relation_ids = BTreeSet::new(); + for edge in &self.relations { + if !relation_ids.insert(edge.id.as_str()) { + return Err(RequirementError::DuplicateRelation(edge.id.clone())); + } + if !ids.contains(edge.source.as_str()) || !ids.contains(edge.target.as_str()) { + return Err(RequirementError::UnknownEndpoint(edge.id.clone())); + } + if let RelationAuthority::Inferred(details) | RelationAuthority::Proposed(details) = + &edge.authority + { + if !(0.0..=1.0).contains(&details.confidence) || !details.confidence.is_finite() { + return Err(RequirementError::InvalidConfidence(edge.id.clone())); + } + } + } + Ok(()) + } + + /// Compute an induced typed view. This never produces renderer source. + pub fn view(&self, request: &ViewRequest) -> Result { + self.validate()?; + let visible: Vec<&RequirementRelation> = self + .relations + .iter() + .filter(|edge| request.scope == ViewScope::Review || edge.authority.is_asserted()) + .collect(); + + match request.kind { + ViewKind::Decomposition => Ok(self.filtered_view( + &visible, + RequirementRelationKind::DECOMPOSITION, + ViewDiagnostics::default(), + None, + None, + )), + ViewKind::Traceability => Ok(self.filtered_view( + &visible, + RequirementRelationKind::TRACEABILITY, + ViewDiagnostics::default(), + None, + None, + )), + ViewKind::Impact => self.impact_view(&visible, request), + ViewKind::Behaviour => self.behaviour_view(&visible, request), + ViewKind::VerificationCoverage => self.coverage_view(&visible), + } + } + + fn filtered_view( + &self, + visible: &[&RequirementRelation], + kinds: &[RequirementRelationKind], + diagnostics: ViewDiagnostics, + behaviour: Option, + coverage: Option, + ) -> ViewResult { + self.induce( + visible + .iter() + .copied() + .filter(|edge| kinds.contains(&edge.kind)), + diagnostics, + behaviour, + coverage, + ) + } + + fn induce<'a>( + &self, + edges: impl IntoIterator, + diagnostics: ViewDiagnostics, + behaviour: Option, + coverage: Option, + ) -> ViewResult { + let mut relations: Vec = edges.into_iter().cloned().collect(); + relations.sort_by(|left, right| left.id.cmp(&right.id)); + let ids: BTreeSet<&str> = relations + .iter() + .flat_map(|e| [e.source.as_str(), e.target.as_str()]) + .collect(); + ViewResult { + graph: RequirementGraph { + baseline: self.baseline.clone(), + requirements: sorted_nodes( + self.requirements + .iter() + .filter(|n| ids.contains(n.id.as_str())) + .cloned(), + |n| &n.id, + ), + evidence: sorted_nodes( + self.evidence + .iter() + .filter(|n| ids.contains(n.id.as_str())) + .cloned(), + |n| &n.id, + ), + relations, + }, + diagnostics, + behaviour, + coverage, + } + } + + fn impact_view( + &self, + visible: &[&RequirementRelation], + request: &ViewRequest, + ) -> Result { + let root = request + .root_id + .as_deref() + .ok_or(RequirementError::ImpactNeedsRoot)?; + if !self.has_node(root) { + return Err(RequirementError::UnknownNode(root.to_string())); + } + let max_depth = request.max_depth.unwrap_or(3); + let mut seen = BTreeSet::from([root.to_string()]); + let mut queue = VecDeque::from([(root.to_string(), 0usize)]); + let mut selected = BTreeSet::new(); + let mut cycles = BTreeSet::new(); + while let Some((node, depth)) = queue.pop_front() { + if depth == max_depth { + continue; + } + for edge in visible { + let neighbor = match request.direction { + TraversalDirection::Downstream if edge.source == node => Some(&edge.target), + TraversalDirection::Upstream if edge.target == node => Some(&edge.source), + TraversalDirection::Both if edge.source == node => Some(&edge.target), + TraversalDirection::Both if edge.target == node => Some(&edge.source), + _ => None, + }; + if let Some(neighbor) = neighbor { + selected.insert(edge.id.clone()); + if !seen.insert(neighbor.clone()) { + if has_path(visible, request.direction, neighbor, &node) { + cycles.insert(edge.id.clone()); + } + } else { + queue.push_back((neighbor.clone(), depth + 1)); + } + } + } + } + let diagnostics = ViewDiagnostics { + cycles: cycles.into_iter().collect(), + bounded_at_depth: Some(max_depth), + }; + Ok(self.induce( + visible + .iter() + .copied() + .filter(|edge| selected.contains(&edge.id)), + diagnostics, + None, + None, + )) + } + + fn behaviour_view( + &self, + visible: &[&RequirementRelation], + request: &ViewRequest, + ) -> Result { + let recommendation = self.recommend_behaviour(visible); + let selected = request + .confirmed_behaviour + .unwrap_or(recommendation.recommended); + let kinds: &[RequirementRelationKind] = match selected { + BehaviourViewKind::Activity | BehaviourViewKind::Sequence => { + &[RequirementRelationKind::Precedes] + } + BehaviourViewKind::State => &[ + RequirementRelationKind::Refines, + RequirementRelationKind::Precedes, + ], + }; + Ok(self.filtered_view( + visible, + kinds, + ViewDiagnostics::default(), + Some(BehaviourRecommendation { + selected: request.confirmed_behaviour, + ..recommendation + }), + None, + )) + } + + fn recommend_behaviour(&self, visible: &[&RequirementRelation]) -> BehaviourRecommendation { + // Explicit semantic annotation wins. Otherwise a precedence relation + // is a sequence; an unannotated model defaults to activity. + let hint = self + .requirements + .iter() + .find_map(|r| r.attributes.get("behaviour").map(String::as_str)); + let recommended = match hint { + Some("state") => BehaviourViewKind::State, + Some("sequence") => BehaviourViewKind::Sequence, + Some("activity") => BehaviourViewKind::Activity, + _ if visible + .iter() + .any(|edge| edge.kind == RequirementRelationKind::Precedes) => + { + BehaviourViewKind::Sequence + } + _ => BehaviourViewKind::Activity, + }; + BehaviourRecommendation { + recommended, + selected: None, + rationale: match hint { + Some("state") => "explicit behaviour=state attribute".into(), + Some("sequence") => "explicit behaviour=sequence attribute".into(), + Some("activity") => "explicit behaviour=activity attribute".into(), + _ if recommended == BehaviourViewKind::Sequence => { + "precedence relations imply an ordered interaction".into() + } + _ => { + "no state or precedence semantics; activity is the deterministic default".into() + } + }, + } + } + + fn coverage_view( + &self, + visible: &[&RequirementRelation], + ) -> Result { + let mut covered = BTreeMap::>::new(); + let mut linked_evidence = BTreeSet::new(); + for edge in visible + .iter() + .filter(|e| e.kind == RequirementRelationKind::Verifies) + { + let requirement = if self.requirements.iter().any(|r| r.id == edge.target) { + Some(&edge.target) + } else if self.requirements.iter().any(|r| r.id == edge.source) { + Some(&edge.source) + } else { + None + }; + let evidence = if self.evidence.iter().any(|e| e.id == edge.source) { + Some(&edge.source) + } else if self.evidence.iter().any(|e| e.id == edge.target) { + Some(&edge.target) + } else { + None + }; + if let (Some(requirement), Some(evidence)) = (requirement, evidence) { + covered + .entry(requirement.clone()) + .or_default() + .push(evidence.clone()); + linked_evidence.insert(evidence.clone()); + } + } + for values in covered.values_mut() { + values.sort(); + values.dedup(); + } + let unverified = self + .requirements + .iter() + .filter(|r| !covered.contains_key(&r.id)) + .map(|r| r.id.clone()) + .collect(); + let orphan_evidence = self + .evidence + .iter() + .filter(|e| !linked_evidence.contains(&e.id)) + .map(|e| e.id.clone()) + .collect(); + let coverage = VerificationCoverage { + covered, + unverified, + orphan_evidence, + }; + Ok(self.induce( + visible + .iter() + .copied() + .filter(|edge| edge.kind == RequirementRelationKind::Verifies), + ViewDiagnostics::default(), + None, + Some(coverage), + )) + } + + fn has_node(&self, id: &str) -> bool { + self.requirements.iter().any(|n| n.id == id) || self.evidence.iter().any(|n| n.id == id) + } +} + +/// Five supported requirement viewpoints. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ViewKind { + Decomposition, + Traceability, + Impact, + Behaviour, + VerificationCoverage, +} + +/// Authoritative views deliberately exclude inferred/proposed relations. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum ViewScope { + #[default] + Authoritative, + Review, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum TraversalDirection { + Upstream, + #[default] + Downstream, + Both, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BehaviourViewKind { + Activity, + Sequence, + State, +} + +/// Request contract reusable by HTTP, MCP, Playb00k, and a Flexo extension. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ViewRequest { + pub kind: ViewKind, + #[serde(default)] + pub scope: ViewScope, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub root_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_depth: Option, + #[serde(default)] + pub direction: TraversalDirection, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub confirmed_behaviour: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct ViewDiagnostics { + #[serde(default)] + pub cycles: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bounded_at_depth: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BehaviourRecommendation { + pub recommended: BehaviourViewKind, + /// A user-selected kind can intentionally differ from the deterministic + /// recommendation. `None` means it must not be rendered yet. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub selected: Option, + pub rationale: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct VerificationCoverage { + pub covered: BTreeMap>, + pub unverified: Vec, + pub orphan_evidence: Vec, +} + +/// A semantic view result. The `graph` is the only diagram input a renderer +/// receives; it has no knowledge of ReqIF XML or Flexo's storage layout. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ViewResult { + pub graph: RequirementGraph, + pub diagnostics: ViewDiagnostics, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub behaviour: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub coverage: Option, +} + +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum RequirementError { + #[error("duplicate graph node: {0}")] + DuplicateNode(String), + #[error("duplicate relation: {0}")] + DuplicateRelation(String), + #[error("relation has an unknown endpoint: {0}")] + UnknownEndpoint(String), + #[error("relation confidence must be finite and between zero and one: {0}")] + InvalidConfidence(String), + #[error("unknown relation: {0}")] + UnknownRelation(String), + #[error("relation is already asserted: {0}")] + AlreadyAsserted(String), + #[error("unknown graph node: {0}")] + UnknownNode(String), + #[error("impact viewpoint needs root_id")] + ImpactNeedsRoot, + #[error("requirement belongs to a different baseline: {0}")] + BaselineMismatch(String), +} + +fn sorted_nodes( + nodes: impl Iterator, + id: impl for<'a> Fn(&'a T) -> &'a String, +) -> Vec { + let mut nodes: Vec = nodes.collect(); + nodes.sort_by(|left, right| id(left).cmp(id(right))); + nodes +} + +fn has_path( + edges: &[&RequirementRelation], + direction: TraversalDirection, + start: &str, + target: &str, +) -> bool { + let mut seen = BTreeSet::from([start.to_string()]); + let mut queue = VecDeque::from([start.to_string()]); + while let Some(node) = queue.pop_front() { + for edge in edges { + let neighbor = match direction { + TraversalDirection::Downstream if edge.source == node => Some(&edge.target), + TraversalDirection::Upstream if edge.target == node => Some(&edge.source), + TraversalDirection::Both if edge.source == node => Some(&edge.target), + TraversalDirection::Both if edge.target == node => Some(&edge.source), + _ => None, + }; + if let Some(neighbor) = neighbor { + if neighbor == target { + return true; + } + if seen.insert(neighbor.clone()) { + queue.push_back(neighbor.clone()); + } + } + } + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + + fn baseline() -> BaselineIdentity { + BaselineIdentity { + id: "BL-1".into(), + revision: "commit-1".into(), + import_artifact_sha256: None, + exported_baseline_sha256: None, + } + } + fn provenance() -> Provenance { + Provenance { + source_uri: "reqif://fixture".into(), + artifact_sha256: None, + locator: None, + } + } + fn requirement(id: &str) -> Requirement { + Requirement { + id: id.into(), + title: id.into(), + text: id.into(), + baseline: baseline(), + provenance: provenance(), + attributes: BTreeMap::new(), + evidence: vec![], + } + } + fn edge( + id: &str, + source: &str, + target: &str, + kind: RequirementRelationKind, + authority: RelationAuthority, + ) -> RequirementRelation { + RequirementRelation { + id: id.into(), + source: source.into(), + target: target.into(), + kind, + authority, + provenance: provenance(), + promotion: None, + } + } + fn graph() -> RequirementGraph { + RequirementGraph { + baseline: baseline(), + requirements: vec![requirement("r1"), requirement("r2"), requirement("r3")], + evidence: vec![ + EvidenceRef { + id: "t1".into(), + label: "test 1".into(), + uri: None, + provenance: None, + }, + EvidenceRef { + id: "t2".into(), + label: "orphan test".into(), + uri: None, + provenance: None, + }, + ], + relations: vec![ + edge( + "contains", + "r1", + "r2", + RequirementRelationKind::Contains, + RelationAuthority::Asserted, + ), + edge( + "precedes", + "r2", + "r3", + RequirementRelationKind::Precedes, + RelationAuthority::Asserted, + ), + edge( + "verify", + "t1", + "r2", + RequirementRelationKind::Verifies, + RelationAuthority::Asserted, + ), + edge( + "candidate", + "r1", + "r3", + RequirementRelationKind::Derives, + RelationAuthority::Proposed(NonAuthoritativeRelation { + confidence: 0.8, + rationale: "candidate".into(), + evidence: vec![], + model: ModelIdentity { + name: "opa".into(), + version: "1".into(), + }, + }), + ), + ], + } + } + + #[test] + fn authoritative_views_exclude_proposed_edges_until_explicitly_promoted() { + let mut g = graph(); + let request = ViewRequest { + kind: ViewKind::Decomposition, + scope: ViewScope::Authoritative, + root_id: None, + max_depth: None, + direction: TraversalDirection::Downstream, + confirmed_behaviour: None, + }; + assert_eq!(g.view(&request).unwrap().graph.relations.len(), 1); + g.promote_relation("candidate", "alice", "reviewed") + .unwrap(); + let result = g.view(&request).unwrap(); + assert_eq!(result.graph.relations.len(), 2); + assert_eq!( + result + .graph + .relations + .iter() + .find(|relation| relation.id == "candidate") + .and_then(|relation| relation.promotion.as_ref()) + .map(|promotion| promotion.previous_status.as_str()), + Some("proposed") + ); + } + + #[test] + fn impact_is_bounded_and_reports_revisited_edges() { + let mut g = graph(); + g.relations.push(edge( + "cycle", + "r3", + "r2", + RequirementRelationKind::Requires, + RelationAuthority::Asserted, + )); + let result = g + .view(&ViewRequest { + kind: ViewKind::Impact, + scope: ViewScope::Authoritative, + root_id: Some("r2".into()), + max_depth: Some(3), + direction: TraversalDirection::Downstream, + confirmed_behaviour: None, + }) + .unwrap(); + assert_eq!(result.diagnostics.bounded_at_depth, Some(3)); + assert!(result.diagnostics.cycles.contains(&"cycle".to_string())); + } + + #[test] + fn behaviour_recommendation_is_deterministic_but_a_human_can_select_another_view() { + let g = graph(); + let ok = g + .view(&ViewRequest { + kind: ViewKind::Behaviour, + scope: ViewScope::Authoritative, + root_id: None, + max_depth: None, + direction: TraversalDirection::Downstream, + confirmed_behaviour: Some(BehaviourViewKind::Sequence), + }) + .unwrap(); + assert_eq!( + ok.behaviour.unwrap().recommended, + BehaviourViewKind::Sequence + ); + let selected = g + .view(&ViewRequest { + kind: ViewKind::Behaviour, + scope: ViewScope::Authoritative, + root_id: None, + max_depth: None, + direction: TraversalDirection::Downstream, + confirmed_behaviour: Some(BehaviourViewKind::State), + }) + .unwrap(); + assert_eq!( + selected.behaviour.unwrap().selected, + Some(BehaviourViewKind::State) + ); + } + + #[test] + fn coverage_returns_unverified_and_orphan_findings() { + let result = graph() + .view(&ViewRequest { + kind: ViewKind::VerificationCoverage, + scope: ViewScope::Authoritative, + root_id: None, + max_depth: None, + direction: TraversalDirection::Downstream, + confirmed_behaviour: None, + }) + .unwrap(); + let coverage = result.coverage.unwrap(); + assert_eq!(coverage.covered["r2"], vec!["t1"]); + assert!(coverage.unverified.contains(&"r1".to_string())); + assert_eq!(coverage.orphan_evidence, vec!["t2"]); + } +}