-
Notifications
You must be signed in to change notification settings - Fork 0
feat(relation): refuse inferred status as observed evidence #133
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
3ae2d7f
421ccd8
f9aa224
5f91062
dbe1c1f
760ad30
33a0c83
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| [package] | ||
| name = "inferred_status" | ||
| description = "Inferred relations cannot be promoted to observed evidence or transitions." | ||
| version.workspace = true | ||
| edition.workspace = true | ||
| rust-version.workspace = true | ||
| license.workspace = true | ||
| authors.workspace = true | ||
| repository.workspace = true | ||
| homepage.workspace = true | ||
| readme.workspace = true | ||
| keywords.workspace = true | ||
| categories.workspace = true | ||
| publish = false | ||
|
|
||
| [lints] | ||
| workspace = true |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| //! Fail-closed inferred-status errors. | ||
|
|
||
| use std::fmt; | ||
|
|
||
| /// A fail-closed inferred-status error. | ||
| #[derive(Clone, Copy, Debug, Eq, PartialEq)] | ||
| #[non_exhaustive] | ||
| pub enum InferredStatusError { | ||
| /// An inferred relation was treated as observed evidence. | ||
| InferredIsNotObserved, | ||
| /// An inferred relation was treated as a state transition. | ||
| InferredIsNotTransition, | ||
| /// A recovery slice was empty or length-mismatched. | ||
| InvalidStatusPayload, | ||
| } | ||
|
|
||
| impl fmt::Display for InferredStatusError { | ||
| fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { | ||
| let message = match self { | ||
| Self::InferredIsNotObserved => "inferred relation is not observed evidence", | ||
| Self::InferredIsNotTransition => "inferred relation is not a state transition", | ||
| Self::InvalidStatusPayload => "invalid inferred-status payload", | ||
| }; | ||
| formatter.write_str(message) | ||
| } | ||
| } | ||
|
|
||
| impl std::error::Error for InferredStatusError {} | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::InferredStatusError; | ||
|
|
||
| #[test] | ||
| fn error_messages_are_stable() { | ||
| for (error, message) in [ | ||
| ( | ||
| InferredStatusError::InferredIsNotObserved, | ||
| "inferred relation is not observed evidence", | ||
| ), | ||
| ( | ||
| InferredStatusError::InferredIsNotTransition, | ||
| "inferred relation is not a state transition", | ||
| ), | ||
| ( | ||
| InferredStatusError::InvalidStatusPayload, | ||
| "invalid inferred-status payload", | ||
| ), | ||
| ] { | ||
| assert_eq!(error.to_string(), message); | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| #![forbid(unsafe_code)] | ||
| #![deny(missing_docs)] | ||
| #![allow(clippy::cast_precision_loss)] | ||
| //! Inferred relations cannot be promoted to observed evidence or transitions. | ||
| //! | ||
| //! LLM, reasoner, and heuristic proposals stay inferred until deterministic | ||
| //! schema, evidence, and scientific validation promote them (ADR 0003). | ||
|
|
||
| mod error; | ||
| mod status; | ||
|
|
||
| /// Fail-closed inferred-status errors. | ||
| pub use error::InferredStatusError; | ||
| /// Closed vocabulary of presence evidence that is not yet a transition. | ||
| pub use status::EvidenceStatus; | ||
| /// Fraction of recovered evidence statuses that match known truth. | ||
| pub use status::identity_recovery_rate; | ||
| /// Refuse to treat an inferred relation as observed evidence. | ||
| pub use status::refuse_inferred_as_observed; | ||
| /// Refuse to treat an inferred relation as a state transition. | ||
| pub use status::refuse_inferred_as_transition; | ||
| /// Return whether a status is observed evidence. | ||
| pub use status::status_is_observed; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,143 @@ | ||
| //! Observed versus inferred relation evidence status. | ||
|
|
||
| use crate::InferredStatusError; | ||
|
|
||
| /// Closed vocabulary of presence evidence that is not yet a transition. | ||
| #[derive(Clone, Copy, Debug, Eq, PartialEq)] | ||
| pub enum EvidenceStatus { | ||
| /// Directly observed in source documents or authoritative systems. | ||
| Observed, | ||
| /// Derived by a model, reasoner, or heuristic and not yet promoted. | ||
| Inferred, | ||
| } | ||
|
|
||
| impl EvidenceStatus { | ||
| /// Return the stable wire status name. | ||
| #[must_use] | ||
| pub const fn wire_name(self) -> &'static str { | ||
| match self { | ||
| Self::Observed => "observed", | ||
| Self::Inferred => "inferred", | ||
| } | ||
| } | ||
|
|
||
| /// Parse a stable wire status name. | ||
| /// | ||
| /// # Errors | ||
| /// | ||
| /// Returns [`InferredStatusError::InvalidStatusPayload`] for unrecognized | ||
| /// names. | ||
| pub fn from_wire_name(name: &str) -> Result<Self, InferredStatusError> { | ||
| match name { | ||
| "observed" => Ok(Self::Observed), | ||
| "inferred" => Ok(Self::Inferred), | ||
| _ => Err(InferredStatusError::InvalidStatusPayload), | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /// Return whether a status is observed evidence. | ||
| /// | ||
| /// # Errors | ||
| /// | ||
| /// This function is infallible for the closed vocabulary and exists to keep | ||
| /// the public comparison surface explicit. | ||
| #[allow(clippy::unnecessary_wraps)] | ||
| pub fn status_is_observed(status: EvidenceStatus) -> Result<bool, InferredStatusError> { | ||
| Ok(matches!(status, EvidenceStatus::Observed)) | ||
| } | ||
|
|
||
| /// Refuse to treat an inferred relation as observed evidence. | ||
| /// | ||
| /// # Errors | ||
| /// | ||
| /// Returns [`InferredStatusError::InferredIsNotObserved`] when `status` is | ||
| /// [`EvidenceStatus::Inferred`]. | ||
| pub fn refuse_inferred_as_observed(status: EvidenceStatus) -> Result<(), InferredStatusError> { | ||
| match status { | ||
| EvidenceStatus::Inferred => Err(InferredStatusError::InferredIsNotObserved), | ||
| EvidenceStatus::Observed => Ok(()), | ||
| } | ||
| } | ||
|
|
||
| /// Refuse to treat an inferred relation as a state transition. | ||
| /// | ||
| /// # Errors | ||
| /// | ||
| /// Returns [`InferredStatusError::InferredIsNotTransition`] when `status` is | ||
| /// [`EvidenceStatus::Inferred`]. | ||
| pub fn refuse_inferred_as_transition(status: EvidenceStatus) -> Result<(), InferredStatusError> { | ||
| match status { | ||
| EvidenceStatus::Inferred => Err(InferredStatusError::InferredIsNotTransition), | ||
| EvidenceStatus::Observed => Ok(()), | ||
| } | ||
| } | ||
|
|
||
| /// Fraction of recovered evidence statuses that match known truth. | ||
| /// | ||
| /// # Errors | ||
| /// | ||
| /// Returns [`InferredStatusError::InvalidStatusPayload`] when either slice is | ||
| /// empty or the lengths differ. | ||
| pub fn identity_recovery_rate( | ||
| truth: &[EvidenceStatus], | ||
| decided: &[EvidenceStatus], | ||
| ) -> Result<f64, InferredStatusError> { | ||
| if truth.is_empty() || truth.len() != decided.len() { | ||
| return Err(InferredStatusError::InvalidStatusPayload); | ||
| } | ||
| let mut matches = 0_u32; | ||
| for (truth_status, decided_status) in truth.iter().zip(decided) { | ||
| if truth_status == decided_status { | ||
| matches += 1; | ||
| } | ||
| } | ||
| Ok(f64::from(matches) / truth.len() as f64) | ||
| } | ||
|
Comment on lines
+82
to
+96
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📝 Info: New crate logic is self-contained and internally consistent The Was this helpful? React with 👍 or 👎 to provide feedback. |
||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::{ | ||
| EvidenceStatus, identity_recovery_rate, refuse_inferred_as_observed, | ||
| refuse_inferred_as_transition, status_is_observed, | ||
| }; | ||
| use crate::InferredStatusError; | ||
|
|
||
| #[test] | ||
| fn local_branches_cover_statuses_payloads_and_wire_names() { | ||
| assert_eq!( | ||
| refuse_inferred_as_observed(EvidenceStatus::Inferred), | ||
| Err(InferredStatusError::InferredIsNotObserved) | ||
| ); | ||
| assert_eq!( | ||
| refuse_inferred_as_transition(EvidenceStatus::Inferred), | ||
| Err(InferredStatusError::InferredIsNotTransition) | ||
| ); | ||
| refuse_inferred_as_observed(EvidenceStatus::Observed).expect("observed"); | ||
| refuse_inferred_as_transition(EvidenceStatus::Observed).expect("observed"); | ||
| assert!(status_is_observed(EvidenceStatus::Observed).expect("observed")); | ||
| assert!(!status_is_observed(EvidenceStatus::Inferred).expect("inferred")); | ||
| for status in [EvidenceStatus::Observed, EvidenceStatus::Inferred] { | ||
| assert_eq!( | ||
| EvidenceStatus::from_wire_name(status.wire_name()).expect("round-trip"), | ||
| status | ||
| ); | ||
| } | ||
| assert_eq!( | ||
| EvidenceStatus::from_wire_name("promoted"), | ||
| Err(InferredStatusError::InvalidStatusPayload) | ||
| ); | ||
| let matched = | ||
| identity_recovery_rate(&[EvidenceStatus::Inferred], &[EvidenceStatus::Inferred]) | ||
| .expect("rate"); | ||
| assert!((matched - 1.0).abs() < f64::EPSILON); | ||
| assert_eq!( | ||
| identity_recovery_rate(&[], &[]), | ||
| Err(InferredStatusError::InvalidStatusPayload) | ||
| ); | ||
| assert_eq!( | ||
| identity_recovery_rate(&[EvidenceStatus::Inferred], &[]), | ||
| Err(InferredStatusError::InvalidStatusPayload) | ||
| ); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| //! Integration contract for the `inferred_status` package identity. | ||
|
|
||
| #[test] | ||
| fn package_identity_is_stable() { | ||
| let observed = std::hint::black_box(env!("CARGO_PKG_NAME")); | ||
| assert_eq!(observed, "inferred_status"); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,70 @@ | ||
| //! Inferred relations cannot be promoted to observed evidence or transitions. | ||
|
|
||
| use inferred_status::{ | ||
| EvidenceStatus, InferredStatusError, identity_recovery_rate, refuse_inferred_as_observed, | ||
| refuse_inferred_as_transition, status_is_observed, | ||
| }; | ||
|
|
||
| #[test] | ||
| fn inferred_status_cannot_become_observed_or_a_transition() { | ||
| assert_eq!( | ||
| refuse_inferred_as_observed(EvidenceStatus::Inferred), | ||
| Err(InferredStatusError::InferredIsNotObserved) | ||
| ); | ||
| assert_eq!( | ||
| refuse_inferred_as_transition(EvidenceStatus::Inferred), | ||
| Err(InferredStatusError::InferredIsNotTransition) | ||
| ); | ||
| refuse_inferred_as_observed(EvidenceStatus::Observed).expect("observed stays observed"); | ||
| refuse_inferred_as_transition(EvidenceStatus::Observed) | ||
| .expect("observed may be considered for promotion elsewhere"); | ||
| assert!(status_is_observed(EvidenceStatus::Observed).expect("observed")); | ||
| assert!(!status_is_observed(EvidenceStatus::Inferred).expect("inferred")); | ||
| } | ||
|
|
||
| #[test] | ||
| fn recovered_statuses_match_known_truth_better_than_an_observed_collapse() { | ||
| let truth = [ | ||
| EvidenceStatus::Observed, | ||
| EvidenceStatus::Inferred, | ||
| EvidenceStatus::Inferred, | ||
| ]; | ||
| let recovered = truth; | ||
| let collapsed = [ | ||
| EvidenceStatus::Observed, | ||
| EvidenceStatus::Observed, | ||
| EvidenceStatus::Observed, | ||
| ]; | ||
| let recovered_rate = identity_recovery_rate(&truth, &recovered).expect("recovered"); | ||
| let collapsed_rate = identity_recovery_rate(&truth, &collapsed).expect("collapsed"); | ||
| let expected = { | ||
| let mut matches = 0_u32; | ||
| for (truth_status, decided_status) in truth.iter().zip(recovered.iter()) { | ||
| if truth_status == decided_status { | ||
| matches += 1; | ||
| } | ||
| } | ||
| f64::from(matches) / f64::from(u32::try_from(truth.len()).expect("len")) | ||
| }; | ||
| assert!((recovered_rate - expected).abs() < f64::EPSILON); | ||
| assert!(recovered_rate > collapsed_rate); | ||
| } | ||
|
|
||
| #[test] | ||
| fn empty_or_mismatched_status_payloads_fail_closed() { | ||
| assert_eq!( | ||
| identity_recovery_rate(&[], &[]), | ||
| Err(InferredStatusError::InvalidStatusPayload) | ||
| ); | ||
| assert_eq!( | ||
| identity_recovery_rate(&[EvidenceStatus::Inferred], &[]), | ||
| Err(InferredStatusError::InvalidStatusPayload) | ||
| ); | ||
| assert_eq!( | ||
| identity_recovery_rate( | ||
| &[EvidenceStatus::Observed, EvidenceStatus::Inferred], | ||
| &[EvidenceStatus::Observed] | ||
| ), | ||
| Err(InferredStatusError::InvalidStatusPayload) | ||
| ); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📝 Info: False branch of match counter covered only by integration test
The unit test
local_branches_cover_statuses_payloads_and_wire_namesin status.rs only callsidentity_recovery_ratewith matching pairs, so thetruth_status == decided_statusfalse branch at status.rs is never exercised by the unit test alone. It is covered by inferred_status_contract.rs (the collapsed-status case). Since coverage is computed withcargo llvm-cov --workspace, the aggregate reaches the required 100% branch coverage, so this is not a bug — just noting the coverage dependency across test targets.Was this helpful? React with 👍 or 👎 to provide feedback.