diff --git a/CHANGELOG.md b/CHANGELOG.md index 74d053c8..5c625695 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to ConceptWeave are documented here. ### Fixed +- Research evaluation rejects incomplete source inventories and invalidates prior approvals when retained source metadata changes. - Research reports retain standalone files and notes that previously disappeared from the classification view, and flag sources whose parent relationships remain unresolved. - Zotero research intake rejects a read whose records claim revisions newer than the library being observed, without dropping papers or changing their recorded revisions. - Zotero research intake rejects incomplete or late results after a five-minute read budget, even when individual pages arrive within their request limits. @@ -20,5 +21,7 @@ All notable changes to ConceptWeave are documented here. ### Security +- Source receipts bind complete captured metadata and actual classifier inputs; earlier report and review artifacts require regeneration under the versioned digest representation. +- Golden-set evaluation rejects changed predictions or evidence under an earlier approval. Proposal-bound approvals must be reissued; aggregate receipts identify the actual evaluated proposal run. - Model-generated semantics remain non-authoritative until deterministic validation and authorized review. - Unsafe Rust is forbidden in the core domain crate. diff --git a/Cargo.lock b/Cargo.lock index dd4cfe80..ad534722 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,12 +8,27 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bytes" version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + [[package]] name = "conceptweave-domain" version = "0.1.0" @@ -24,9 +39,49 @@ version = "0.1.0" dependencies = [ "serde", "serde_json", + "sha2", "ureq", ] +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "http" version = "1.5.0" @@ -49,6 +104,12 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + [[package]] name = "log" version = "0.4.34" @@ -128,6 +189,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "syn" version = "3.0.4" @@ -139,6 +211,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -176,6 +254,12 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "zmij" version = "1.0.23" diff --git a/crates/conceptweave-zotero/Cargo.toml b/crates/conceptweave-zotero/Cargo.toml index 7f78f15f..3f492bbe 100644 --- a/crates/conceptweave-zotero/Cargo.toml +++ b/crates/conceptweave-zotero/Cargo.toml @@ -10,6 +10,7 @@ description = "Read-only Zotero research classification for ConceptWeave" [dependencies] serde = { version = "1", features = ["derive"] } serde_json = "1" +sha2 = "0.10" ureq = { version = "3", default-features = false } [lib] diff --git a/crates/conceptweave-zotero/src/lib.rs b/crates/conceptweave-zotero/src/lib.rs index 519ad7e3..9c17a7d6 100644 --- a/crates/conceptweave-zotero/src/lib.rs +++ b/crates/conceptweave-zotero/src/lib.rs @@ -4,6 +4,7 @@ //! Deterministic, read-only classification of a Zotero library snapshot. use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, BTreeSet}; use std::fmt; use std::io::Read; @@ -13,6 +14,7 @@ use std::time::{Duration, Instant}; pub const RULE_REVISION: &str = "ontology-research-v2"; const SUPPORTED_API_VERSION: u64 = 3; +const SNAPSHOT_DIGEST_DOMAIN: &str = "conceptweave-zotero-snapshot-v2"; const SUPPORTED_API_VERSION_HEADER: &str = "3"; const PAGE_LIMIT: usize = 100; const MAX_PAGE_BYTES: u64 = 8 * 1024 * 1024; @@ -25,7 +27,7 @@ const LOCAL_API: &str = "http://127.0.0.1:23119/api/users/0/items"; static TEST_LOCAL_API: std::sync::Mutex> = std::sync::Mutex::new(None); /// A Zotero item returned by the Local API. -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Serialize)] pub struct ZoteroItem { /// Stable item key. pub key: String, @@ -33,6 +35,38 @@ pub struct ZoteroItem { pub version: u64, /// Item metadata. pub data: ItemData, + /// Complete original JSON object, captured automatically during deserialization. + /// + /// Offline callers constructing synthetic typed items use `None`. The digest + /// binds this source value together with the actual typed classifier input, + /// so later projection changes also invalidate the receipt. It retains omitted fields, + /// unknown metadata, nested objects, and array order exactly as observed. + #[serde(skip)] + pub source_record: Option, +} + +impl<'de> Deserialize<'de> for ZoteroItem { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + struct ItemProjection { + key: String, + version: u64, + data: ItemData, + } + + let source_record = serde_json::Value::deserialize(deserializer)?; + let projection = + ItemProjection::deserialize(&source_record).map_err(serde::de::Error::custom)?; + Ok(Self { + key: projection.key, + version: projection.version, + data: projection.data, + source_record: Some(source_record), + }) + } } /// Metadata used by the classifier. @@ -69,7 +103,7 @@ pub struct ItemTag { } /// One mutually exclusive proposed disposition. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] #[serde(rename_all = "snake_case")] pub enum Disposition { /// Evidence about ontology or taxonomy generation. @@ -168,6 +202,10 @@ pub struct ClassificationReport { pub rule_revision: &'static str, /// Number of items read, including child notes and attachments. pub observed_item_count: usize, + /// Complete item-revision identity of every observed record. + pub snapshot_items: Vec, + /// Canonical SHA-256 digest of every observed raw Zotero item. + pub snapshot_digest: String, /// One proposal for every top-level bibliographic item. pub classified_items: Vec, /// Metadata for every remaining record, sorted by its original key. @@ -187,6 +225,333 @@ pub struct ClassificationReport { pub duplicate_candidates: Vec, } +/// One steward-reviewed expected disposition in a local golden set. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct GoldenLabel { + /// Zotero item key used only to join the local report and local review set. + pub item_key: String, + /// Steward-approved disposition used as evaluation truth. + pub expected_disposition: Disposition, +} + +impl GoldenLabel { + /// Creates a local golden label. + pub fn new(item_key: impl Into, expected_disposition: Disposition) -> Self { + Self { + item_key: item_key.into(), + expected_disposition, + } + } +} + +/// Version-bound steward labels that remain outside the repository. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct ReviewedGoldenSet { + /// Approval receipt verified by the caller's governance boundary. + pub approval: GoldenSetApproval, + /// Item-level expected dispositions. + pub labels: Vec, +} + +/// One item revision in the exact reviewed classification snapshot. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] +pub struct SnapshotItemRevision { + /// Stable Zotero item key. + pub item_key: String, + /// Item revision observed during review. + pub item_version: u64, +} + +/// Governance receipt binding a steward approval to exact input and proposals. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct GoldenSetApproval { + /// Opaque receipt identifier. + pub receipt_id: String, + /// Stable reviewer subject understood by the governance verifier. + pub reviewer_subject: String, + /// Zotero library version reviewed by the steward. + pub library_version: u64, + /// Classifier rule revision whose proposals were reviewed. + pub rule_revision: String, + /// Immutable digest over the approved snapshot, verified by the caller. + pub snapshot_digest: String, + /// Digest of the actual proposal records reviewed and verified by the caller. + pub proposal_digest: String, + /// Complete sorted item-revision identity of the reviewed report. + pub snapshot_items: Vec, +} + +/// Computes the canonical content identity verified by a golden-set approval. +pub fn classification_snapshot_digest(report: &ClassificationReport) -> String { + report.snapshot_digest.clone() +} + +/// Computes the versioned SHA-256 identity of the report's current proposals. +/// +/// Every proposal field is covered, including its prediction, evidence, and item +/// revision, plus unclassified metadata and pending source identities. This is +/// not a full-text backup. Records are sorted by item key and revision so ordering does +/// not change their identity. No second source snapshot is stored. Governance +/// must bind this value when issuing an approval; recomputing it alone grants no +/// authority. Evaluation recomputes it rather than trusting report metadata. +pub fn classification_proposal_digest(report: &ClassificationReport) -> String { + let mut proposals = report.classified_items.iter().collect::>(); + proposals.sort_by_key(|item| (&item.item_key, item.item_version)); + let mut source_items = report.unclassified_items.iter().collect::>(); + source_items.sort_by_key(|item| (&item.key, item.version)); + let mut pending_keys = report.pending_source_item_keys.iter().collect::>(); + pending_keys.sort(); + let proposal_bytes = serde_json::to_vec(&( + "conceptweave-classification-proposals-v2", + proposals, + source_items, + pending_keys, + )) + .expect("classification proposal records contain only JSON-serializable values"); + format!("sha256:{:x}", Sha256::digest(proposal_bytes)) +} + +/// Integer evidence from which precision and recall can be calculated exactly. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)] +pub struct DispositionEvaluation { + /// Correct predictions for this disposition. + pub true_positive: usize, + /// All classifier predictions for reviewed items in this disposition. + pub predicted: usize, + /// All steward labels expecting this disposition. + pub expected: usize, +} + +/// Aggregate-only evaluation result; item keys and bibliographic text are omitted. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct GoldenSetEvaluation { + /// Opaque review receipt identifier. + pub review_id: String, + /// Zotero library revision bound to the verified receipt. + pub library_version: u64, + /// Classifier revision bound to the verified receipt. + pub rule_revision: String, + /// Opaque immutable snapshot digest from the verified receipt. + pub snapshot_digest: String, + /// Opaque digest binding the exact proposal records used for these counts. + pub proposal_digest: String, + /// Number of steward-reviewed items. + pub reviewed_count: usize, + /// Number of exact disposition matches. + pub correct_count: usize, + /// Number of reviewed items on which the classifier abstained. + pub abstention_count: usize, + /// Precision/recall numerators and denominators per observed disposition. + pub by_disposition: BTreeMap, +} + +/// A fail-closed golden-set contract violation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EvaluationError { + /// Review receipt, labels, or revisions are missing or incompatible. + InvalidReview, + /// The golden set was reviewed against another library or rule revision. + SnapshotMismatch, + /// The caller's governance boundary did not verify the approval receipt. + UnverifiedApproval, + /// Abstention cannot be used as steward-approved semantic truth. + InvalidExpectedDisposition, + /// A reviewed key is absent from the classification report. + UnknownItem, + /// A reviewed key occurs more than once. + DuplicateItem, +} + +impl fmt::Display for EvaluationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::InvalidReview => "golden-set review metadata or labels are invalid", + Self::SnapshotMismatch => "golden set does not match the report snapshot", + Self::UnverifiedApproval => "golden-set approval receipt is unverified", + Self::InvalidExpectedDisposition => { + "steward truth cannot use the classifier abstention disposition" + } + Self::UnknownItem => "golden set contains an item absent from the report", + Self::DuplicateItem => "golden set contains a duplicate item", + }) + } +} + +impl std::error::Error for EvaluationError {} + +/// Checks that every observed item belongs to exactly one report partition. +/// +/// Child links and unresolved source keys are recomputed from preserved metadata. +/// Orphans and disconnected cycles remain valid pending evidence. This checks +/// internal consistency, not source authenticity or independent approval. +pub fn validate_classification_report( + report: &ClassificationReport, +) -> Result<(), EvaluationError> { + let invalid = EvaluationError::InvalidReview; + if report.observed_item_count != report.snapshot_items.len() + || report + .classified_items + .len() + .checked_add(report.unclassified_items.len()) + != Some(report.observed_item_count) + { + return Err(invalid); + } + let mut remaining_items = BTreeMap::new(); + for item in &report.snapshot_items { + if item.item_key.trim().is_empty() + || item.item_version > report.library_version + || remaining_items + .insert(item.item_key.as_str(), item.item_version) + .is_some() + { + return Err(invalid); + } + } + let children = child_index(&report.unclassified_items); + for item in &report.classified_items { + let mut reported_children = item.child_item_keys.clone(); + reported_children.sort(); + let mut actual_children = children.get(&item.item_key).cloned().unwrap_or_default(); + actual_children.sort(); + if item.item_type.trim().is_empty() + || matches!( + item.item_type.as_str(), + "attachment" | "note" | "annotation" + ) + || remaining_items.remove(item.item_key.as_str()) != Some(item.item_version) + || reported_children != actual_children + { + return Err(invalid); + } + } + for item in &report.unclassified_items { + if item.data.item_type.trim().is_empty() + || is_bibliographic(item) + || remaining_items.remove(item.key.as_str()) != Some(item.version) + { + return Err(invalid); + } + } + let mut reported_pending = report.pending_source_item_keys.clone(); + reported_pending.sort(); + // Equal partition size and one successful removal per record prove completeness. + if reported_pending + != pending_source_keys( + &report.classified_items, + &report.unclassified_items, + children, + ) + { + return Err(invalid); + } + Ok(()) +} + +/// Evaluates reviewed labels without copying item identities into the result. +/// +/// Structural, source, proposal, and label validation run before governance is +/// contacted. The verifier must authenticate the complete reviewed set against +/// an independently issued receipt, including both digests and every label; +/// accepting a self-declared receipt identifier or digest is not verification. +pub fn evaluate_reviewed_golden_set( + report: &ClassificationReport, + golden: &ReviewedGoldenSet, + verify_approval: F, +) -> Result +where + F: FnOnce(&ReviewedGoldenSet) -> bool, +{ + validate_classification_report(report)?; + if golden.approval.receipt_id.trim().is_empty() + || golden.approval.reviewer_subject.trim().is_empty() + || golden.labels.is_empty() + || golden.approval.rule_revision.trim().is_empty() + || golden.approval.snapshot_digest.trim().is_empty() + || golden.approval.proposal_digest.trim().is_empty() + { + return Err(EvaluationError::InvalidReview); + } + let report_snapshot = report + .snapshot_items + .iter() + .cloned() + .collect::>(); + let approved_snapshot = golden + .approval + .snapshot_items + .iter() + .cloned() + .collect::>(); + if approved_snapshot.len() != golden.approval.snapshot_items.len() { + return Err(EvaluationError::InvalidReview); + } + if golden.approval.library_version != report.library_version + || golden.approval.rule_revision != report.rule_revision + || golden.approval.snapshot_digest != classification_snapshot_digest(report) + || approved_snapshot != report_snapshot + { + return Err(EvaluationError::SnapshotMismatch); + } + + let classified = report + .classified_items + .iter() + .map(|item| (item.item_key.as_str(), item.proposed_disposition)) + .collect::>(); + if golden.approval.proposal_digest != classification_proposal_digest(report) { + return Err(EvaluationError::SnapshotMismatch); + } + let mut seen = BTreeSet::new(); + let mut correct_count = 0; + let mut abstention_count = 0; + let mut by_disposition = BTreeMap::::new(); + + for label in &golden.labels { + if label.item_key.trim().is_empty() { + return Err(EvaluationError::InvalidReview); + } + if label.expected_disposition == Disposition::NeedsStewardReview { + return Err(EvaluationError::InvalidExpectedDisposition); + } + if !seen.insert(label.item_key.as_str()) { + return Err(EvaluationError::DuplicateItem); + } + let predicted = classified + .get(label.item_key.as_str()) + .copied() + .ok_or(EvaluationError::UnknownItem)?; + by_disposition.entry(predicted).or_default().predicted += 1; + by_disposition + .entry(label.expected_disposition) + .or_default() + .expected += 1; + if predicted == label.expected_disposition { + correct_count += 1; + by_disposition.entry(predicted).or_default().true_positive += 1; + } + if predicted == Disposition::NeedsStewardReview { + abstention_count += 1; + } + } + + if !verify_approval(golden) { + return Err(EvaluationError::UnverifiedApproval); + } + + Ok(GoldenSetEvaluation { + review_id: golden.approval.receipt_id.clone(), + library_version: golden.approval.library_version, + rule_revision: golden.approval.rule_revision.clone(), + snapshot_digest: golden.approval.snapshot_digest.clone(), + proposal_digest: golden.approval.proposal_digest.clone(), + reviewed_count: golden.labels.len(), + correct_count, + abstention_count, + by_disposition, + }) +} + /// Failure raised when a bounded, immutable Local API read cannot be proven. #[derive(Debug)] pub enum ReadError { @@ -482,7 +847,21 @@ pub fn classify_snapshot( mut items: Vec, ) -> ClassificationReport { items.sort_by(|left, right| left.key.cmp(&right.key)); - let mut children = child_index(&items); + let snapshot_items = items + .iter() + .map(|item| SnapshotItemRevision { + item_key: item.key.clone(), + item_version: item.version, + }) + .collect(); + let snapshot_records: Vec<_> = items + .iter() + .map(|item| (&item.source_record, item)) + .collect(); + let snapshot_bytes = serde_json::to_vec(&(SNAPSHOT_DIGEST_DOMAIN, snapshot_records)) + .expect("Zotero snapshot items contain only JSON-compatible values"); + let snapshot_digest = format!("sha256:{:x}", Sha256::digest(snapshot_bytes)); + let children = child_index(&items); let bibliographic: Vec<&ZoteroItem> = items.iter().filter(|item| is_bibliographic(item)).collect(); let duplicate_candidates = duplicate_candidates(&bibliographic); @@ -495,6 +874,31 @@ pub fn classify_snapshot( .into_iter() .filter(|item| !is_bibliographic(item)) .collect(); + let pending_source_item_keys = + pending_source_keys(&classified_items, &unclassified_items, children); + + ClassificationReport { + zotero_version, + api_version: None, + schema_version: None, + server_id, + library_version, + rule_revision: RULE_REVISION, + observed_item_count, + snapshot_items, + snapshot_digest, + classified_items, + unclassified_items, + pending_source_item_keys, + duplicate_candidates, + } +} + +fn pending_source_keys( + classified_items: &[ClassifiedItem], + unclassified_items: &[ZoteroItem], + mut children: BTreeMap>, +) -> Vec { let mut pending_source_item_keys: BTreeSet<_> = unclassified_items .iter() .map(|item| item.key.clone()) @@ -510,19 +914,7 @@ pub fn classify_snapshot( } } - ClassificationReport { - zotero_version, - api_version: None, - schema_version: None, - server_id, - library_version, - rule_revision: RULE_REVISION, - observed_item_count, - classified_items, - unclassified_items, - pending_source_item_keys: pending_source_item_keys.into_iter().collect(), - duplicate_candidates, - } + pending_source_item_keys.into_iter().collect() } fn is_bibliographic(item: &ZoteroItem) -> bool { @@ -801,6 +1193,7 @@ mod tests { collections: vec![], tags: vec![], }, + source_record: None, } } diff --git a/crates/conceptweave-zotero/tests/golden_set_evaluation.rs b/crates/conceptweave-zotero/tests/golden_set_evaluation.rs new file mode 100644 index 00000000..a363db54 --- /dev/null +++ b/crates/conceptweave-zotero/tests/golden_set_evaluation.rs @@ -0,0 +1,228 @@ +use conceptweave_zotero::{ + Disposition, EvaluationError, GoldenLabel, GoldenSetApproval, ItemData, ReviewedGoldenSet, + SnapshotItemRevision, ZoteroItem, classification_proposal_digest, + classification_snapshot_digest, classify_snapshot, evaluate_reviewed_golden_set, +}; + +fn item(key: &str, title: &str) -> ZoteroItem { + ZoteroItem { + key: key.into(), + version: 1, + data: ItemData { + item_type: "book".into(), + title: title.into(), + abstract_note: String::new(), + doi: String::new(), + parent_item: String::new(), + collections: vec![], + tags: vec![], + }, + source_record: None, + } +} + +fn report() -> conceptweave_zotero::ClassificationReport { + classify_snapshot( + "9.0.6".into(), + None, + 42, + vec![ + item("A", "ontology learning"), + item("B", "ontology evaluation"), + item("C", "unmatched"), + ], + ) +} + +fn golden(labels: Vec) -> ReviewedGoldenSet { + ReviewedGoldenSet { + approval: GoldenSetApproval { + receipt_id: "synthetic-review-1".into(), + reviewer_subject: "synthetic-steward".into(), + library_version: 42, + rule_revision: "ontology-research-v2".into(), + snapshot_digest: classification_snapshot_digest(&report()), + proposal_digest: classification_proposal_digest(&report()), + snapshot_items: ["A", "B", "C"] + .into_iter() + .map(|item_key| SnapshotItemRevision { + item_key: item_key.into(), + item_version: 1, + }) + .collect(), + }, + labels, + } +} + +fn verify_synthetic_approval(golden: &ReviewedGoldenSet) -> bool { + golden.approval.receipt_id == "synthetic-review-1" + && golden.approval.reviewer_subject == "synthetic-steward" +} + +#[test] +fn reviewed_golden_set_reports_count_based_precision_and_recall_evidence() { + let report = report(); + let evaluation = evaluate_reviewed_golden_set( + &report, + &golden(vec![ + GoldenLabel::new("A", Disposition::Generation), + GoldenLabel::new("B", Disposition::AlignmentVersioning), + GoldenLabel::new("C", Disposition::Generation), + ]), + verify_synthetic_approval, + ) + .unwrap(); + + assert_eq!(evaluation.review_id, "synthetic-review-1"); + assert_eq!(evaluation.library_version, 42); + assert_eq!(evaluation.rule_revision, "ontology-research-v2"); + assert!(evaluation.snapshot_digest.starts_with("sha256:")); + assert_eq!(evaluation.reviewed_count, 3); + assert_eq!(evaluation.correct_count, 1); + assert_eq!(evaluation.abstention_count, 1); + let generation = &evaluation.by_disposition[&Disposition::Generation]; + assert_eq!( + ( + generation.true_positive, + generation.predicted, + generation.expected + ), + (1, 1, 2) + ); + let serialized = serde_json::to_value(&evaluation).unwrap(); + assert!(serialized.get("labels").is_none()); + assert!(serialized.get("item_key").is_none()); + assert!(serialized.get("reviewer_subject").is_none()); +} + +#[test] +fn reviewed_golden_set_rejects_stale_unknown_and_duplicate_labels() { + let report = report(); + + assert_eq!( + evaluate_reviewed_golden_set(&report, &golden(vec![]), verify_synthetic_approval), + Err(EvaluationError::InvalidReview) + ); + let mut blank = golden(vec![GoldenLabel::new(" ", Disposition::Generation)]); + blank.approval.receipt_id.clear(); + assert_eq!( + evaluate_reviewed_golden_set(&report, &blank, verify_synthetic_approval), + Err(EvaluationError::InvalidReview) + ); + blank.approval.receipt_id = "synthetic-review-1".into(); + assert_eq!( + evaluate_reviewed_golden_set(&report, &blank, verify_synthetic_approval), + Err(EvaluationError::InvalidReview) + ); + blank.approval.reviewer_subject.clear(); + assert_eq!( + evaluate_reviewed_golden_set(&report, &blank, verify_synthetic_approval), + Err(EvaluationError::InvalidReview) + ); + let mut missing_revision = golden(vec![GoldenLabel::new("A", Disposition::Generation)]); + missing_revision.approval.rule_revision.clear(); + assert_eq!( + evaluate_reviewed_golden_set(&report, &missing_revision, verify_synthetic_approval), + Err(EvaluationError::InvalidReview) + ); + missing_revision.approval.rule_revision = "ontology-research-v2".into(); + missing_revision.approval.snapshot_digest.clear(); + assert_eq!( + evaluate_reviewed_golden_set(&report, &missing_revision, verify_synthetic_approval), + Err(EvaluationError::InvalidReview) + ); + + let mut stale = golden(vec![GoldenLabel::new("A", Disposition::Generation)]); + stale.approval.library_version += 1; + assert_eq!( + evaluate_reviewed_golden_set(&report, &stale, verify_synthetic_approval), + Err(EvaluationError::SnapshotMismatch) + ); + stale.approval.library_version = report.library_version; + stale.approval.rule_revision = "older-rules".into(); + assert_eq!( + evaluate_reviewed_golden_set(&report, &stale, verify_synthetic_approval), + Err(EvaluationError::SnapshotMismatch) + ); + stale.approval.rule_revision = report.rule_revision.into(); + stale.approval.snapshot_items[0].item_version += 1; + assert_eq!( + evaluate_reviewed_golden_set(&report, &stale, verify_synthetic_approval), + Err(EvaluationError::SnapshotMismatch) + ); + let mut duplicate_snapshot = golden(vec![GoldenLabel::new("A", Disposition::Generation)]); + duplicate_snapshot + .approval + .snapshot_items + .push(duplicate_snapshot.approval.snapshot_items[0].clone()); + assert_eq!( + evaluate_reviewed_golden_set(&report, &duplicate_snapshot, verify_synthetic_approval), + Err(EvaluationError::InvalidReview) + ); + + assert_eq!( + evaluate_reviewed_golden_set( + &report, + &golden(vec![GoldenLabel::new("A", Disposition::Generation)]), + |_| false + ), + Err(EvaluationError::UnverifiedApproval) + ); + assert_eq!( + evaluate_reviewed_golden_set( + &report, + &golden(vec![GoldenLabel::new("A", Disposition::NeedsStewardReview)]), + verify_synthetic_approval, + ), + Err(EvaluationError::InvalidExpectedDisposition) + ); + + assert_eq!( + evaluate_reviewed_golden_set( + &report, + &golden(vec![GoldenLabel::new("missing", Disposition::Generation)]), + verify_synthetic_approval, + ), + Err(EvaluationError::UnknownItem) + ); + assert_eq!( + evaluate_reviewed_golden_set( + &report, + &golden(vec![ + GoldenLabel::new("A", Disposition::Generation), + GoldenLabel::new("A", Disposition::Generation), + ]), + verify_synthetic_approval, + ), + Err(EvaluationError::DuplicateItem) + ); + + let duplicate_report = classify_snapshot( + "9.0.6".into(), + None, + 42, + vec![ + item("A", "ontology learning"), + item("A", "ontology evaluation"), + ], + ); + assert_eq!( + evaluate_reviewed_golden_set( + &duplicate_report, + &golden(vec![GoldenLabel::new("A", Disposition::Generation)]), + verify_synthetic_approval, + ), + Err(EvaluationError::InvalidReview) + ); + for (error, fragment) in [ + (EvaluationError::InvalidReview, "invalid"), + (EvaluationError::SnapshotMismatch, "snapshot"), + (EvaluationError::UnverifiedApproval, "unverified"), + (EvaluationError::InvalidExpectedDisposition, "abstention"), + (EvaluationError::UnknownItem, "absent"), + (EvaluationError::DuplicateItem, "duplicate"), + ] { + assert!(error.to_string().contains(fragment)); + } +} diff --git a/crates/conceptweave-zotero/tests/golden_set_integrity_contract.rs b/crates/conceptweave-zotero/tests/golden_set_integrity_contract.rs new file mode 100644 index 00000000..7ffa4f32 --- /dev/null +++ b/crates/conceptweave-zotero/tests/golden_set_integrity_contract.rs @@ -0,0 +1,544 @@ +use conceptweave_zotero::{ + Disposition, EvaluationError, GoldenLabel, GoldenSetApproval, ItemData, ReviewedGoldenSet, + SnapshotItemRevision, ZoteroItem, classification_proposal_digest, + classification_snapshot_digest, classify_snapshot, evaluate_reviewed_golden_set, + validate_classification_report, +}; +use sha2::{Digest, Sha256}; + +#[test] +fn legacy_proposal_receipt_is_rejected_without_calling_governance() { + let report = scope_report(); + let mut golden = scope_golden(&report); + let mut proposals = report.classified_items.iter().collect::>(); + proposals.sort_by_key(|item| (&item.item_key, item.item_version)); + let old_bytes = + serde_json::to_vec(&("conceptweave-classification-proposals-v1", proposals)).unwrap(); + golden.approval.proposal_digest = format!("sha256:{:x}", Sha256::digest(old_bytes)); + assert_eq!( + evaluate_reviewed_golden_set(&report, &golden, |_| panic!( + "legacy receipt reached governance" + )), + Err(EvaluationError::SnapshotMismatch) + ); +} + +#[test] +fn empty_and_unresolved_scope_remain_valid_without_becoming_reviewed_papers() { + for (items, pending) in [ + (vec![], vec![]), + (vec![child_note("S", 0, "")], vec!["S"]), + (vec![child_note("C", 1, "MISSING")], vec!["C"]), + ( + vec![child_note("C", 1, "D"), child_note("D", 1, "C")], + vec!["C", "D"], + ), + (vec![child_note("C", 1, "C")], vec!["C"]), + ] { + let report = classify_snapshot("10.0.1".into(), None, 42, items); + assert_eq!(validate_classification_report(&report), Ok(())); + assert_eq!(report.pending_source_item_keys, pending); + assert!(report.classified_items.is_empty()); + } +} + +#[test] +fn blank_snapshot_identity_is_rejected_before_governance() { + let mut report = scope_report(); + report.snapshot_items[0].item_key = " \t\n".into(); + let golden = scope_golden(&report); + assert_eq!( + evaluate_reviewed_golden_set(&report, &golden, |_| panic!( + "invalid identity reached governance" + )), + Err(EvaluationError::InvalidReview) + ); +} + +fn bibliographic(key: &str, version: u64, title: &str) -> ZoteroItem { + ZoteroItem { + key: key.into(), + version, + data: ItemData { + item_type: "book".into(), + title: title.into(), + abstract_note: String::new(), + doi: String::new(), + parent_item: String::new(), + collections: vec![], + tags: vec![], + }, + source_record: None, + } +} + +fn child_note(key: &str, version: u64, parent_item: &str) -> ZoteroItem { + ZoteroItem { + key: key.into(), + version, + data: ItemData { + item_type: "note".into(), + title: String::new(), + abstract_note: String::new(), + doi: String::new(), + parent_item: parent_item.into(), + collections: vec![], + tags: vec![], + }, + source_record: None, + } +} + +fn approval( + report: &conceptweave_zotero::ClassificationReport, + snapshot_items: Vec, +) -> GoldenSetApproval { + GoldenSetApproval { + receipt_id: "approved-review".into(), + reviewer_subject: "synthetic-steward".into(), + library_version: report.library_version, + rule_revision: report.rule_revision.into(), + snapshot_digest: classification_snapshot_digest(report), + proposal_digest: classification_proposal_digest(report), + snapshot_items, + } +} + +#[test] +fn reviewed_snapshot_binding_includes_linked_child_revisions() { + let report = classify_snapshot( + "9.0.6".into(), + None, + 42, + vec![ + bibliographic("PARENT", 7, "ontology learning"), + child_note("NOTE1", 3, "PARENT"), + ], + ); + let golden = ReviewedGoldenSet { + approval: approval( + &report, + vec![ + SnapshotItemRevision { + item_key: "PARENT".into(), + item_version: 7, + }, + SnapshotItemRevision { + item_key: "NOTE1".into(), + item_version: 3, + }, + ], + ), + labels: vec![GoldenLabel::new("PARENT", Disposition::Generation)], + }; + + assert!( + evaluate_reviewed_golden_set(&report, &golden, |_| true).is_ok(), + "an approval for the complete observed Zotero snapshot must include linked child revisions even though only bibliographic parents receive dispositions" + ); +} + +#[test] +fn verified_snapshot_receipt_cannot_authorize_mutated_steward_labels() { + let report = classify_snapshot( + "9.0.6".into(), + None, + 42, + vec![bibliographic("A", 1, "ontology learning")], + ); + let golden = ReviewedGoldenSet { + approval: approval( + &report, + vec![SnapshotItemRevision { + item_key: "A".into(), + item_version: 1, + }], + ), + labels: vec![GoldenLabel::new("A", Disposition::AlignmentVersioning)], + }; + + assert_eq!( + evaluate_reviewed_golden_set(&report, &golden, |reviewed_set| { + reviewed_set.approval.receipt_id == "approved-review" + && reviewed_set.labels == vec![GoldenLabel::new("A", Disposition::Generation)] + }), + Err(EvaluationError::UnverifiedApproval), + "approval verification must bind the reviewed labels as well as the snapshot receipt" + ); +} + +#[test] +fn duplicate_zotero_keys_fail_closed_even_when_item_revisions_differ() { + let report = classify_snapshot( + "9.0.6".into(), + None, + 42, + vec![ + bibliographic("A", 1, "ontology learning"), + bibliographic("A", 2, "ontology learning"), + ], + ); + let golden = ReviewedGoldenSet { + approval: approval( + &report, + vec![ + SnapshotItemRevision { + item_key: "A".into(), + item_version: 1, + }, + SnapshotItemRevision { + item_key: "A".into(), + item_version: 2, + }, + ], + ), + labels: vec![GoldenLabel::new("A", Disposition::Generation)], + }; + + assert_eq!( + evaluate_reviewed_golden_set(&report, &golden, |_| true), + Err(EvaluationError::InvalidReview), + "Zotero item keys are identities; duplicate keys cannot become distinct records merely because their revision counters differ" + ); +} + +#[test] +fn approved_snapshot_cannot_authorize_a_prediction_changed_to_match_the_label() { + let mut report = classify_snapshot( + "9.0.6".into(), + None, + 42, + vec![bibliographic("A", 1, "ontology learning")], + ); + let golden = ReviewedGoldenSet { + approval: approval(&report, report.snapshot_items.clone()), + labels: vec![GoldenLabel::new("A", Disposition::AlignmentVersioning)], + }; + let approved_golden = golden.clone(); + assert_eq!( + evaluate_reviewed_golden_set(&report, &golden, |candidate| candidate == &approved_golden) + .unwrap() + .correct_count, + 0 + ); + + report.classified_items[0].proposed_disposition = Disposition::AlignmentVersioning; + let verifier_called = std::cell::Cell::new(false); + assert_eq!( + evaluate_reviewed_golden_set(&report, &golden, |candidate| { + verifier_called.set(true); + candidate == &approved_golden + }), + Err(EvaluationError::SnapshotMismatch), + "a source receipt cannot authorize changed predictions under unchanged source coordinates" + ); + assert!(!verifier_called.get()); +} + +#[test] +fn rewriting_the_proposal_digest_cannot_reuse_an_independent_approval() { + let mut report = classify_snapshot( + "9.0.6".into(), + None, + 42, + vec![bibliographic("A", 1, "ontology learning")], + ); + let mut golden = ReviewedGoldenSet { + approval: approval(&report, report.snapshot_items.clone()), + labels: vec![GoldenLabel::new("A", Disposition::AlignmentVersioning)], + }; + let approved_golden = golden.clone(); + report.classified_items[0].proposed_disposition = Disposition::AlignmentVersioning; + golden.approval.proposal_digest = classification_proposal_digest(&report); + let imported_golden = + serde_json::from_slice::(&serde_json::to_vec(&golden).unwrap()).unwrap(); + assert_eq!( + evaluate_reviewed_golden_set(&report, &imported_golden, |candidate| { + candidate == &approved_golden + }), + Err(EvaluationError::UnverifiedApproval) + ); +} + +#[test] +fn approval_binds_unreviewed_proposals_and_supporting_evidence_but_not_record_order() { + let mut report = classify_snapshot( + "9.0.6".into(), + None, + 42, + vec![ + bibliographic("A", 1, "ontology learning"), + bibliographic("B", 1, "ontology evaluation"), + ], + ); + let golden = ReviewedGoldenSet { + approval: approval(&report, report.snapshot_items.clone()), + labels: vec![GoldenLabel::new("A", Disposition::Generation)], + }; + report.classified_items.reverse(); + let evaluation = + evaluate_reviewed_golden_set(&report, &golden, |candidate| candidate == &golden).unwrap(); + assert_eq!(evaluation.correct_count, 1); + assert_eq!(evaluation.proposal_digest, golden.approval.proposal_digest); + + // B is outside the reviewed sample, but belongs to the approved proposal run. + report.classified_items[0].evidence.field_values.clear(); + assert_eq!( + evaluate_reviewed_golden_set(&report, &golden, |_| { + panic!("changed proposal evidence must fail before governance") + }), + Err(EvaluationError::SnapshotMismatch) + ); +} + +#[test] +fn missing_proposal_binding_and_invalid_labels_fail_before_governance() { + let report = classify_snapshot( + "9.0.6".into(), + None, + 42, + vec![bibliographic("A", 1, "ontology learning")], + ); + let mut golden = ReviewedGoldenSet { + approval: approval(&report, report.snapshot_items.clone()), + labels: vec![GoldenLabel::new("A", Disposition::Generation)], + }; + let mut legacy_json = serde_json::to_value(&golden).unwrap(); + legacy_json["approval"] + .as_object_mut() + .unwrap() + .remove("proposal_digest"); + assert!(serde_json::from_value::(legacy_json).is_err()); + + golden.approval.proposal_digest.clear(); + assert_eq!( + evaluate_reviewed_golden_set(&report, &golden, |_| { + panic!("unbound proposals must fail before governance") + }), + Err(EvaluationError::InvalidReview) + ); + golden.approval.proposal_digest = classification_proposal_digest(&report); + for (labels, expected_error) in [ + ( + vec![GoldenLabel::new(" ", Disposition::Generation)], + EvaluationError::InvalidReview, + ), + ( + vec![GoldenLabel::new("A", Disposition::NeedsStewardReview)], + EvaluationError::InvalidExpectedDisposition, + ), + ( + vec![GoldenLabel::new("absent", Disposition::Generation)], + EvaluationError::UnknownItem, + ), + ( + vec![GoldenLabel::new("A", Disposition::Generation); 2], + EvaluationError::DuplicateItem, + ), + ] { + golden.labels = labels; + assert_eq!( + evaluate_reviewed_golden_set(&report, &golden, |_| { + panic!("invalid labels must fail before governance") + }), + Err(expected_error) + ); + } +} + +#[test] +fn malformed_proposal_identities_fail_before_governance() { + for (replacement_key, replacement_version) in [("A", 1), ("B", 9), (" ", 1)] { + let mut report = classify_snapshot( + "9.0.6".into(), + None, + 42, + vec![ + bibliographic("A", 1, "ontology learning"), + bibliographic("B", 1, "ontology evaluation"), + ], + ); + let golden = ReviewedGoldenSet { + approval: approval(&report, report.snapshot_items.clone()), + labels: vec![GoldenLabel::new("A", Disposition::Generation)], + }; + report.classified_items[1].item_key = replacement_key.into(); + report.classified_items[1].item_version = replacement_version; + assert_eq!( + evaluate_reviewed_golden_set(&report, &golden, |_| { + panic!("malformed proposals must fail before governance") + }), + Err(EvaluationError::InvalidReview) + ); + } +} + +fn scope_report() -> conceptweave_zotero::ClassificationReport { + classify_snapshot( + "10.0.1".into(), + None, + 42, + vec![ + bibliographic("A", 1, "ontology learning"), + child_note("C", 1, "A"), + child_note("S", 0, ""), + child_note("T", 1, ""), + ], + ) +} + +fn scope_golden(report: &conceptweave_zotero::ClassificationReport) -> ReviewedGoldenSet { + ReviewedGoldenSet { + approval: approval(report, report.snapshot_items.clone()), + labels: vec![GoldenLabel::new("A", Disposition::Generation)], + } +} + +#[test] +fn malformed_source_scope_fails_before_approval_even_with_recomputed_receipt() { + for mutation in [ + "count", + "snapshot_count", + "missing", + "duplicate", + "overlap", + "unknown", + "revision", + "blank", + "top_level_book", + "blank_type", + "proposal_type", + "proposal_blank_type", + "future_snapshot", + "pending_missing", + "pending_extra", + "pending_duplicate", + "child_missing", + "child_extra", + "child_duplicate", + "parent_changed", + ] { + let mut report = scope_report(); + match mutation { + "count" => report.observed_item_count += 1, + "snapshot_count" => { + report.snapshot_items.pop(); + } + "missing" => { + report.unclassified_items.pop(); + } + "duplicate" => report + .unclassified_items + .push(report.unclassified_items[0].clone()), + "overlap" => report.unclassified_items[0].key = "A".into(), + "unknown" => report.unclassified_items[0].key = "unknown".into(), + "revision" => report.unclassified_items[0].version += 1, + "blank" => report.unclassified_items[0].key = " ".into(), + "top_level_book" => report.unclassified_items[1].data.item_type = "book".into(), + "blank_type" => report.unclassified_items[0].data.item_type.clear(), + "proposal_type" => report.classified_items[0].item_type = "attachment".into(), + "proposal_blank_type" => report.classified_items[0].item_type.clear(), + "future_snapshot" => { + report.snapshot_items[0].item_version = 43; + report.classified_items[0].item_version = 43; + } + "pending_missing" => report.pending_source_item_keys.clear(), + "pending_extra" => report.pending_source_item_keys.push("A".into()), + "pending_duplicate" => report.pending_source_item_keys.push("S".into()), + "child_missing" => report.classified_items[0].child_item_keys.clear(), + "child_extra" => report.classified_items[0].child_item_keys.push("S".into()), + "child_duplicate" => report.classified_items[0].child_item_keys.push("C".into()), + "parent_changed" => report.unclassified_items[0].data.parent_item = "missing".into(), + _ => unreachable!(), + } + let golden = scope_golden(&report); + let calls = std::cell::Cell::new(0); + assert_eq!( + evaluate_reviewed_golden_set(&report, &golden, |_| { + calls.set(calls.get() + 1); + true + }), + Err(EvaluationError::InvalidReview), + "mutation {mutation}" + ); + assert_eq!(calls.get(), 0, "mutation {mutation}"); + } +} + +#[test] +fn source_metadata_mutations_invalidate_the_original_approval_before_verification() { + for mutation in [ + "title", + "abstract", + "doi", + "tags", + "collections", + "type", + "parent", + ] { + let mut report = scope_report(); + let golden = scope_golden(&report); + let source = &mut report.unclassified_items[1]; + match mutation { + "title" => source.data.title = "changed evidence".into(), + "abstract" => source.data.abstract_note = "changed evidence".into(), + "doi" => source.data.doi = "10.1/changed".into(), + "tags" => source.data.tags.push(conceptweave_zotero::ItemTag { + tag: "changed".into(), + }), + "collections" => source.data.collections.push("changed".into()), + "type" => source.data.item_type = "attachment".into(), + "parent" => { + source.data.parent_item = "C".into(); + report.pending_source_item_keys = vec!["T".into()]; + } + _ => unreachable!(), + } + let calls = std::cell::Cell::new(0); + assert_eq!( + evaluate_reviewed_golden_set(&report, &golden, |_| { + calls.set(calls.get() + 1); + true + }), + Err(EvaluationError::SnapshotMismatch), + "mutation {mutation}" + ); + assert_eq!(calls.get(), 0); + } +} + +#[test] +fn rewritten_source_scope_receipt_still_requires_independent_approval() { + let mut report = scope_report(); + let mut golden = scope_golden(&report); + let approved = golden.clone(); + report.unclassified_items[1].data.title = "changed evidence".into(); + golden.approval.proposal_digest = classification_proposal_digest(&report); + let calls = std::cell::Cell::new(0); + assert_eq!( + evaluate_reviewed_golden_set(&report, &golden, |candidate| { + calls.set(calls.get() + 1); + candidate == &approved + }), + Err(EvaluationError::UnverifiedApproval) + ); + assert_eq!(calls.get(), 1); +} + +#[test] +fn valid_pending_source_scope_is_order_independent_and_not_additional_paper_labels() { + let mut report = scope_report(); + let golden = scope_golden(&report); + report.unclassified_items.reverse(); + report.pending_source_item_keys.reverse(); + report.snapshot_items.reverse(); + let result = + evaluate_reviewed_golden_set(&report, &golden, |candidate| candidate == &golden).unwrap(); + assert_eq!(result.reviewed_count, 1); + assert_eq!(result.correct_count, 1); + assert_eq!( + classification_proposal_digest(&report), + golden.approval.proposal_digest + ); + assert_eq!(report.pending_source_item_keys.len(), 2); +} diff --git a/crates/conceptweave-zotero/tests/raw_provider_snapshot_binding.rs b/crates/conceptweave-zotero/tests/raw_provider_snapshot_binding.rs new file mode 100644 index 00000000..feae5618 --- /dev/null +++ b/crates/conceptweave-zotero/tests/raw_provider_snapshot_binding.rs @@ -0,0 +1,122 @@ +use conceptweave_zotero::{ZoteroItem, classify_snapshot}; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; + +fn snapshot_digest(raw_item: Value) -> String { + let item: ZoteroItem = serde_json::from_value(raw_item).unwrap(); + classify_snapshot("9.0.6".into(), None, 42, vec![item]).snapshot_digest +} + +#[test] +fn snapshot_digest_binds_unmodeled_provider_metadata_at_every_item_level() { + let original = json!({ + "key": "SYNTH001", + "version": 7, + "meta": {"parsedDate": "2025-01-01"}, + "data": { + "itemType": "journalArticle", + "title": "Ontology learning", + "date": "2025-01-01", + "creators": [{"creatorType": "author", "name": "Synthetic Author"}], + "tags": [{"tag": "ontology", "type": 0}] + } + }); + let original_digest = snapshot_digest(original.clone()); + + for (pointer, replacement) in [ + ("/meta/parsedDate", json!("2026-01-01")), + ("/data/date", json!("2026-01-01")), + ("/data/creators/0/name", json!("Other Synthetic Author")), + ("/data/tags/0/type", json!(1)), + ] { + let mut changed = original.clone(); + *changed.pointer_mut(pointer).unwrap() = replacement; + assert_ne!( + snapshot_digest(changed), + original_digest, + "same-revision content change at {pointer} must invalidate the snapshot receipt" + ); + } +} + +#[test] +fn snapshot_digest_is_independent_of_provider_object_field_order() { + let ordered: Value = serde_json::from_str( + r#"{"key":"SYNTH001","version":7,"meta":{"a":1,"b":{"c":2,"d":3}},"data":{"itemType":"book","date":"2025","creators":[{"name":"Synthetic Author","creatorType":"author"}]}}"#, + ) + .unwrap(); + let reordered: Value = serde_json::from_str( + r#"{"data":{"creators":[{"creatorType":"author","name":"Synthetic Author"}],"date":"2025","itemType":"book"},"meta":{"b":{"d":3,"c":2},"a":1},"version":7,"key":"SYNTH001"}"#, + ) + .unwrap(); + assert_eq!(snapshot_digest(ordered), snapshot_digest(reordered)); +} + +#[test] +fn snapshot_digest_preserves_omitted_versus_explicit_default_metadata() { + let omitted = json!({"key": "SYNTH001", "version": 7, "data": {"itemType": "book"}}); + let omitted_digest = snapshot_digest(omitted.clone()); + for (field_name, explicit_default) in [ + ("title", json!("")), + ("abstractNote", json!("")), + ("DOI", json!("")), + ("parentItem", json!("")), + ("collections", json!([])), + ("tags", json!([])), + ] { + let mut explicit = omitted.clone(); + explicit["data"][field_name] = explicit_default; + assert_ne!(snapshot_digest(explicit), omitted_digest, "{field_name}"); + } +} + +#[test] +fn snapshot_digest_binds_actual_classifier_inputs_after_provider_decode() { + let original: ZoteroItem = serde_json::from_value(json!({ + "key": "SYNTH001", "version": 7, + "data": {"itemType": "book", "title": "Ontology learning"} + })) + .unwrap(); + let original_digest = + classify_snapshot("9.0.6".into(), None, 42, vec![original.clone()]).snapshot_digest; + let mut changed = original; + changed.data.title = "Ontology alignment".into(); + assert_ne!( + classify_snapshot("9.0.6".into(), None, 42, vec![changed]).snapshot_digest, + original_digest, + "changed classifier input cannot retain the original source receipt" + ); +} + +#[test] +fn source_capture_preserves_provider_shape_validation() { + assert!(serde_json::from_str::("{").is_err()); + for invalid in [ + json!(null), + json!({"key": 7, "version": 7, "data": {"itemType": "book"}}), + json!({"key": "SYNTH001", "version": 7, "data": {}}), + ] { + assert!(serde_json::from_value::(invalid).is_err()); + } + assert!( + serde_json::from_str::( + r#"{"key":"SYNTH001","version":7,"data":{"itemType":false}}"# + ) + .is_err() + ); +} + +#[test] +fn snapshot_digest_has_versioned_domain_separation() { + let item: ZoteroItem = serde_json::from_value(json!({ + "key": "SYNTH001", "version": 7, "data": {"itemType": "book"} + })) + .unwrap(); + let unmarked_content = serde_json::to_vec(&[(&item.source_record, &item)]).unwrap(); + let unmarked_digest = format!("sha256:{:x}", Sha256::digest(unmarked_content)); + assert_ne!( + classify_snapshot("9.0.6".into(), None, 42, vec![item]).snapshot_digest, + unmarked_digest, + "snapshot receipts must be separated from unversioned content hashes" + ); +} diff --git a/crates/conceptweave-zotero/tests/review_contract.rs b/crates/conceptweave-zotero/tests/review_contract.rs index dce9aebf..e0890264 100644 --- a/crates/conceptweave-zotero/tests/review_contract.rs +++ b/crates/conceptweave-zotero/tests/review_contract.rs @@ -13,6 +13,7 @@ fn item(key: &str, title: &str, doi: &str) -> ZoteroItem { collections: vec![], tags: vec![], }, + source_record: None, } } diff --git a/crates/conceptweave-zotero/tests/review_contract_followup.rs b/crates/conceptweave-zotero/tests/review_contract_followup.rs index c3745c6b..d176f2de 100644 --- a/crates/conceptweave-zotero/tests/review_contract_followup.rs +++ b/crates/conceptweave-zotero/tests/review_contract_followup.rs @@ -13,6 +13,7 @@ fn item(key: &str, title: &str, abstract_note: &str) -> ZoteroItem { collections: vec![], tags: vec![], }, + source_record: None, } } diff --git a/crates/conceptweave-zotero/tests/snapshot_content_binding.rs b/crates/conceptweave-zotero/tests/snapshot_content_binding.rs new file mode 100644 index 00000000..fbd35c18 --- /dev/null +++ b/crates/conceptweave-zotero/tests/snapshot_content_binding.rs @@ -0,0 +1,49 @@ +use conceptweave_zotero::{ + Disposition, EvaluationError, GoldenLabel, GoldenSetApproval, ItemData, ReviewedGoldenSet, + SnapshotItemRevision, ZoteroItem, classification_proposal_digest, classify_snapshot, + evaluate_reviewed_golden_set, +}; + +fn item(title: &str) -> ZoteroItem { + ZoteroItem { + key: "A".into(), + version: 1, + data: ItemData { + item_type: "book".into(), + title: title.into(), + abstract_note: String::new(), + doi: String::new(), + parent_item: String::new(), + collections: vec![], + tags: vec![], + }, + source_record: None, + } +} + +#[test] +fn golden_approval_rejects_same_revision_coordinates_with_changed_snapshot_content() { + let changed_report = + classify_snapshot("9.0.6".into(), None, 42, vec![item("ontology evaluation")]); + let golden = ReviewedGoldenSet { + approval: GoldenSetApproval { + receipt_id: "review-original-snapshot".into(), + reviewer_subject: "synthetic-steward".into(), + library_version: 42, + rule_revision: "ontology-research-v2".into(), + snapshot_digest: "sha256:approved-original-content".into(), + proposal_digest: classification_proposal_digest(&changed_report), + snapshot_items: vec![SnapshotItemRevision { + item_key: "A".into(), + item_version: 1, + }], + }, + labels: vec![GoldenLabel::new("A", Disposition::Generation)], + }; + + assert_eq!( + evaluate_reviewed_golden_set(&changed_report, &golden, |_| true), + Err(EvaluationError::SnapshotMismatch), + "item key/version coordinates alone cannot bind a Zotero 9 local snapshot whose content changed without a synced-version change" + ); +} diff --git a/docs/PRD.md b/docs/PRD.md index 3d12e5c5..2d7d6e54 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -56,12 +56,16 @@ All LLM-backed induction uses `contextual-orchestrator`. Model output is untrust ### FR-9 Research evidence intake +Evaluation must reject omitted or inconsistent retained sources before requesting approval. Changing retained source metadata invalidates prior approval, even when paper predictions stay unchanged. Empty or unresolved-only inventories remain auditable evidence and must not acquire reviewed-paper counts. + Preserve every observed source, including standalone files and notes outside the bibliographic proposals. Keep unresolved source relationships visible instead of treating a completed bibliography worksheet as a completed library review. All standalone sources and records without a valid path to a bibliographic parent need explicit reconciliation; notes, files and annotations must not acquire paper labels from their titles. Retraction and correction evidence remains separate from topic classification and approval. The current producer retains this inventory; downstream reconciliation, completion admission and independent governance remain required, not implemented by inventory generation alone. Library reads must finish within a bounded observation window or fail visibly without returning a partial classification. Slowly arriving pages cannot keep a run open indefinitely, and missing time budget must not be handled by silently dropping papers. Read a complete Zotero Local API observation with one consistent library version and propose exactly one research disposition for every top-level bibliographic item. This consistency check does not establish an atomic provider snapshot. A record claiming a revision newer than the observed library invalidates the complete read; it must not be omitted or assigned a different revision to make the read pass. Each proposal retains the item key/version, exact matched metadata values, rule revision, linked child records, and any model receipt. Weak evidence and evidence that matches multiple specific disposition families must abstain into steward review. Duplicate DOI/title identities are review candidates only: intake never merges, deletes, or silently mutates Zotero records. +Evaluate classifier quality only against a steward-reviewed local golden set whose governance receipt is externally verified and binds both the complete source/classifier-input snapshot and every current proposal field, in addition to the item-key/item-version coordinates. Same-version changes to unmodeled provider metadata, absent/default fields, classifier inputs, predictions or supporting evidence must invalidate the corresponding binding. Evaluation recomputes proposal identity before contacting governance; a locally changed digest cannot renew an approval. Legacy unbound approvals require reissuance, never automatic backfill. Abstention is a prediction outcome, never an approved truth label. Evaluation emits the verified library revision, rule revision, opaque snapshot and proposal digests, and aggregate counts for exact matches, abstentions, and per-disposition true-positive/predicted/expected totals; it must not copy Zotero keys, reviewer identity, or bibliographic text into the result. + ## 6. First vertical slice Relational schema snapshot -> observed tables/columns/foreign keys -> concept/relation/dimension/measure/mapping candidates -> evidence-bound validation report -> reviewable proposal package. diff --git a/docs/TRD.md b/docs/TRD.md index 08a59938..710b52f1 100644 --- a/docs/TRD.md +++ b/docs/TRD.md @@ -61,7 +61,7 @@ Evaluation must separate extraction recall, semantic correctness, structural cor `ClassificationReport.unclassified_items` retains every input record excluded from bibliographic classification, using the existing `ZoteroItem` metadata projection. Bibliographic proposals and this inventory are disjoint and together account for the observed record count on reader-admitted input. The existing child index is consumed once from bibliographic roots; records never reached remain in sorted `pending_source_item_keys`, including standalone roots, their descendants, orphan trees and cycles. The traversal is iterative, uses no new dependency and costs O(n log n) time/O(n) auxiliary space. It does not validate arbitrary offline input or preserve note bodies, attachment-specific fields and unknown provider JSON. -Consumer requirement, still pending forward integration: require both inventory fields rather than defaulting absent legacy fields to empty; validate exact snapshot identity/version/parent complement and recompute pending keys before any review, duplicate evaluation or write verifier. Keep bibliographic progress distinct from whole-library completion. An empty pending list is only ancestry accounting, never semantic approval. Full-text report-digest changes require fresh bound verification, not capture rewriting; metadata proposal-only digests do not implicitly bind the new inventory. See [source-scope evidence and integration map](doctoring/zotero_source_scope.md). +Evaluation now calls `validate_classification_report` before governance: counts, disjoint complete key/version partitions, types, direct children and recomputed pending keys must agree. Equal partition count and one successful removal of each unique snapshot coordinate prove completeness. This owner has no original parent/type snapshot coordinates, so consistency is not source authentication. Later restoration, review, duplicate and write consumers must adopt this guard and require both inventory fields without empty legacy defaults. Keep bibliographic progress distinct from whole-library completion. Full-text report-digest changes require fresh bound verification, not capture rewriting. See [source-scope evidence and integration map](doctoring/zotero_source_scope.md). The shared live reader rejects empty or whitespace-only item keys before accumulating a page or requesting another one. It preserves valid keys verbatim; this does not add a new provider key-format restriction or certify arbitrary offline classifier input. @@ -73,4 +73,12 @@ After count/byte validation and before accumulating each metadata page, every re Every top-level bibliographic record receives exactly one proposed disposition. `NeedsStewardReview` also records a deterministic abstention reason so missing classification metadata, vocabulary unsupported by the current deterministic rules, and present-but-unmatched metadata are distinguishable. DOI duplicate identity normalization treats bare DOI values, `doi:`, `doi.org`, and legacy `dx.doi.org` resolver forms as the same identity when their normalized DOI is equal. +Golden-set evaluation accepts only a governance receipt verified with the complete reviewed set by a caller-owned authorization boundary. Its library version, rule revision, canonical SHA-256 content digest, and every observed parent/child item-key/item-version identity must bind the classification report. The snapshot digest covers every raw Zotero item in canonical key order. A separate required `proposal_digest` binds every field of every proposed item, including predictions, supporting evidence, and proposals outside a reviewed sample. `classification_proposal_digest` computes SHA-256 over compact JSON containing the `conceptweave-classification-proposals-v1` domain marker and proposal records sorted by item key and revision. It uses the current records, not a report's self-declared digest or a second stored source snapshot. Governance must issue and independently verify both digests together with the labels; a locally recomputed replacement digest cannot renew an old approval. Legacy approvals missing this field fail closed and require reissuance, not automatic backfill. + +The proposal-only v1 format above describes the previous receipt contract. The current source-scope amendment supersedes it with `conceptweave-classification-proposals-v2`: compact JSON binds the marker, sorted proposals, key/version-sorted projected unclassified records, and sorted pending keys. Old v1 receipts fail before governance; locally rewritten digests cannot renew approval. This is not a lossless full-text digest. + +Structural, source, proposal, and label checks precede the external verifier. Blank, duplicate, unknown, stale, content-mismatched, prediction-mismatched, label-mismatched, or abstention-as-truth inputs fail closed. The aggregate result retains the verified library version, rule revision, and opaque snapshot/proposal digests, but contains no item keys, reviewer identity, or bibliographic text. Production authorization remains Keyverse/governance-owned; this crate passes the complete reviewed labels and approval bindings to that boundary instead of minting authority. + +Provider deserialization captures each complete JSON object before projecting metadata. Snapshot hashing serializes the domain marker `conceptweave-zotero-snapshot-v2` followed by key-ordered pairs of that canonical source JSON and the actual typed classifier input. Unknown nested fields, array order, and omitted-versus-explicit default fields remain bound; changing a typed input after decoding also changes the digest. Synthetic offline typed items have no captured provider object and bind an explicit absent-source value alongside their typed input. Earlier reduced-content digests remain historical evidence and cannot establish this complete-content contract; regenerate the report and review artifacts and obtain fresh approval before any release or approved write. + The report is local JSON and contains proposals rather than governance decisions. CLI output is restricted to a new direct child of canonical `/tmp` or the operating system temporary directory; relative paths, nested paths, existing paths, and symlinks are rejected, and create-new file semantics prevent overwrite/path-swap writes. Zotero 9 writes are unsupported; no mutation path exists in this slice. A future Zotero 10+ writer requires a separate reviewed change with a Local API key, stable server identity, fresh item/library version preconditions, item-by-item before/after receipts, and rollback evidence. diff --git a/docs/UML.md b/docs/UML.md index 714c3004..0c1a99b4 100644 --- a/docs/UML.md +++ b/docs/UML.md @@ -58,5 +58,9 @@ sequenceDiagram Intake->>Report: write proposals, complete inventory and unresolved source keys Note over Report,Steward: Pending sources prevent a whole-library completion claim; inventory is not approval Report->>Steward: review dispositions and merge candidates + Steward->>Intake: reviewed labels and independently issued receipt + Intake->>Intake: validate complete partitions and recompute pending ancestry + Intake->>Intake: verify v2 proposal and retained-source binding + Note over Intake,Steward: Only locally valid reports reach independent governance verification Intake-->>Zotero: no mutation ``` diff --git a/docs/adr/0006-zotero-research-intake.md b/docs/adr/0006-zotero-research-intake.md index b0b0a81a..2ab03175 100644 --- a/docs/adr/0006-zotero-research-intake.md +++ b/docs/adr/0006-zotero-research-intake.md @@ -19,6 +19,8 @@ The adapter links child records, emits exactly one deterministic proposed dispos Matched metadata values are copied into the local-only evidence receipt for replay. This is necessary for abstract-only matches because a later Zotero revision cannot reconstruct the exact text used for an earlier proposal from item key/version alone. DOI/title matches remain reversible duplicate candidates, including legacy `dx.doi.org` resolver forms. +Classifier quality is measured only against local steward-reviewed labels whose complete reviewed set is verified outside this crate and bound to the exact library version, rule revision, canonical SHA-256 raw-snapshot digest, and every observed parent/child item-key/item-version coordinate. `NeedsStewardReview` is an abstention prediction and cannot be approved truth. Evaluation returns the verified revisions and opaque digest with aggregate integer evidence; Zotero keys, reviewer identity, and bibliographic text are omitted. Missing, stale, content- or label-mismatched, unverified, unknown, duplicate, or invalid review identities fail closed. + The reader fails closed above 50,000 items or 256 MiB of cumulative response bodies, while retaining the 8 MiB per-page bound, finite request timeouts, redirect denial, total-count checks, snapshot-version checks, and duplicate-key detection. Pagination, consistency, resource-budget, and provider-contract behavior are separated from the narrow `ureq` transport so deterministic tests exercise the production reader core rather than excluding the entire reader from coverage. In the context of reading every bibliographic source before classification, facing individually timely pages that can cumulatively hold a run open for days, we decided for a five-minute monotonic admission/completion budget in the existing reader and against rejecting legitimate short pages or adding another transport, to bound accepted work without excluding papers, accepting that an already-started request or classification computation can finish after the limit before its result is rejected. This is an application read limit, not a model timeout, hard process-cancellation deadline, wall-clock/suspend guarantee or atomic snapshot claim. Each page is checked before fetch and after return, and the complete report is checked before return. The stdlib clock has a private deterministic test seam; public APIs, provider timeouts and data/byte ceilings are unchanged. The [deadline doctoring](../doctoring/zotero_metadata_deadline.md) records the original review, RED/GREEN, alternatives and exact verification. This amendment remains Proposed and grants no Zotero mutation authority. @@ -31,6 +33,18 @@ No dedicated utility repository or Zotero mutation path is created. A future Zot ## Consequences +### 2026-09-05 integrity amendment (Proposed) + +In the context of replaying a Zotero research classification against a steward's approved labels, facing source fields lost during projection and predictions mutable after review, we decided for separate source-and-input and proposal-content digests verified with the complete reviewed set, and against typed-only source hashing or a report's self-declared cached proposal identity, to preserve the exact evidence used for evaluation, accepting a receipt-format break, report regeneration and fresh governance approval. + +The source identity uses `conceptweave-zotero-snapshot-v2` over item-key-ordered pairs of complete captured provider JSON and actual typed classifier input. Capturing only unknown flattened fields was rejected after the omitted-title versus explicit-empty-title regression showed another collision. Hashing only captured JSON was also rejected: mutating a decoded public title would otherwise change classification without changing source identity. The pair binds both representations without another cloned source snapshot; JSON object order is canonicalized while field presence, nested metadata and array order remain meaningful. + +The approval additionally requires `proposal_digest`, computed from all current proposal records under `conceptweave-classification-proposals-v1`, sorted by item key and revision. Every proposal field is bound, including evidence and records outside a reviewed sample. Evaluation recomputes this value before invoking the external verifier. Changing both the prediction and the submitted digest cannot renew an independently issued approval; the governance verifier must authenticate the complete reviewed set, not merely accept a receipt identifier. Local checks remain before this authority boundary. + +Positive consequence: same-version metadata edits, changed classifier inputs and altered evaluated predictions invalidate the relevant binding. Negative consequence: prior source digests and approval formats are incompatible; no automatic backfill or transfer of approval is allowed. Captured source and typed input also consume memory until classification finishes. Private Rust fields alone were not selected as the approval solution because they cannot authenticate a deserialized report; storing another full source snapshot was unnecessary for proposal binding. No new authority service, external dependency, or repository is introduced. + +The regressions in `raw_provider_snapshot_binding.rs`, `golden_set_integrity_contract.rs` and `snapshot_content_binding.rs` exercise the two existing PR #10 findings, including failed intermediate designs. Source repair is distinct from hosted exact-head checks, independent review, protected integration and externally approved live classification. The dependent review/finalization stack must adopt the required digest and reject old approval JSON before promotion. ADR status remains Proposed. + ### September 6 source-scope amendment (Proposed) In the context of a genuine 8,326-record library observation and a native 3,719-top-level selection, facing three standalone PDFs and one note omitted from the 3,715 bibliographic proposals, we decided to retain every nonbibliographic metadata record and derive unresolved ancestry using the existing child index. We rejected silently excluding these sources, guessing paper labels from file titles, and a standalone-only list that would still hide orphan or cyclic child relationships. Reusing `ZoteroItem` avoids another adapter or DTO, at the cost of a larger private report and an explicitly projected, not lossless, source representation. Consuming each adjacency list once makes traversal finite without recursion or repeated ancestry walks. The [executed source-scope record](../doctoring/zotero_source_scope.md) binds the failing tests, source, actual observation and consumer integration requirements. @@ -46,8 +60,20 @@ This change does not reconcile a source, validate arbitrary offline input, retai - Human review remains necessary for ambiguous records and every duplicate merge. - Zotero 9 cannot apply approved collection/tag changes automatically. +### September 6 source-scope admission amendment (Proposed) + +PR #10 is the earliest evaluation owner. Its previous head `4bb633305b04a1dd4c4ce526806c9469bcb79fd3` was normally merged with producer `51c7df6d03f072449422fd58ca24b2f9d6026f07`, preserving raw-input binding and all inventory fields. RED `3f2cf55` reproduced three authority failures: omitted scope accepted with a recomputed receipt, changed unclassified metadata accepted under the original receipt, and a rewritten receipt reusing independent approval. + +We selected one public structural validator before evaluation governance, reusing the producer's bounded ancestry traversal. Counts, unique snapshot identities/revisions, disjoint and complete partitions, record types, direct children and recomputed pending identities must agree. Orphans and disconnected cycles remain pending evidence, not extra bibliographic labels. This supersedes the earlier proposal-only approval digest with `conceptweave-classification-proposals-v2`, covering sorted proposals, projected unclassified metadata and pending identities. Existing v1 approvals require independent reapproval; recomputation is not migration authority. + +Guards only in later worksheet commands leave direct evaluation exposed. Another copied traversal or utility repository is unnecessary. The cost is an incompatible approval digest and another bounded validation pass. Snapshot coordinates here contain key/version only: internal consistency and independently approved scope binding do not prove original parent/type authenticity. Projected metadata is not a note/PDF backup. Later restoration, worksheet, duplicate and write owners must adopt the validator and required fields without weakening full-text capture bindings. + +GREEN source `f6735b585022aac1c8ceff86c150d9b64fd77ec2` passed 68 tests across 14 unfiltered workspace suites (including two doctests), up from 64 after parent integration and 58 before it. Strict Clippy passed. Full coverage, independent source review, descendant integration, hosted exact-head checks and protected merge remain separate gates. No genuine decision, approval or Zotero write was issued. Visual inspection was attempted again, but the Mac was locked; no fresh screenshot verification is claimed. + ## Alternatives considered +The follow-up `8ccb0d5b3d7705786b6c40c3bcf5a10ff32046d9` removes duplicate evaluator identity checks subsumed by the entry validator. Equal partition lengths plus one successful removal per unique coordinate prove no leftover source; testing impossible duplicate branches would require bypassing the real entry boundary. Existing malformed-report cases remain, and `d1344c7` adds legacy-v1 rejection and empty/orphan/cycle/blank-identity regressions. The unchanged pinned coverage gate now passes all normalized owned regions and branches; raw instantiated gaps remain explicitly reported in the Gap baseline. No coverage exclusion, dependency or authority service was added. + - First-match classification was rejected because FR-9 requires ambiguous evidence to abstain rather than acquire an arbitrary priority-based disposition. - Hard-coding Zotero schema 42 was rejected because the documented compatibility contract is API v3; schema revision is instead recorded and checked for within-read drift. - Direct Zotero 9 writes were rejected because the supported Local API write capability is Zotero 10+ only. diff --git a/docs/doctoring/REFERENCES.md b/docs/doctoring/REFERENCES.md index 2cbca9b0..84c5e056 100644 --- a/docs/doctoring/REFERENCES.md +++ b/docs/doctoring/REFERENCES.md @@ -98,3 +98,11 @@ Li, J., Garijo, D., & Poveda-Villalón, M. (2026). Large language models for ont - OAEI-LLM/OAEI-LLM-T add LLM-specific hallucination categories to matching evaluation. GRC remains the enterprise round-trip fixture rather than the sole benchmark. - Modular ontology engineering and explicit source provenance are preferred over one opaque prompt that attempts to generate an entire enterprise semantic layer in a single step. - Human review remains mandatory before authority promotion. Scalable validation research may inform review mechanics but cannot replace domain-owner/steward authority. + +## Provider snapshot content binding + +Serde. (n.d.). *Container attributes*. Retrieved September 5, 2026, from https://serde.rs/container-attrs.html + +Serde. (n.d.). *Struct flattening*. Retrieved September 5, 2026, from https://serde.rs/attr-flatten.html + +The derived JSON reader ignores unknown fields by default. Flattened maps retain those fields but still normalize omitted modeled fields through defaults. Therefore `ZoteroItem::deserialize` captures the original JSON object, and `classify_snapshot` hashes it together with the actual classifier projection. The regression suite `raw_provider_snapshot_binding.rs` verifies unknown top-level/data/tag fields, omitted default fields, object-order stability, post-decode input mutation, and invalid provider shapes. This repairs the raw-content finding on PR #10 without treating it as external review approval or protected release evidence. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 5405f6de..0d167f12 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -54,6 +54,14 @@ Protected central source is `.github/main@b5efbc2762e472e4a380b0503b1f050f76fbb0 - `.github#1873@41935494aa234eb458f1cc08f006daaa278b9760` is closed/unmerged because repository dependency-graph initialization, not its retry/sleep source delta, was the verified root cause of the observed public-repository 403. - #35 remains an exact consumer canary for current runner admission and Dependency Review behavior. Its central workflows are queued, so no protected recovery or dependency-review success is inferred from repository settings alone. +## September 6 source-scope admission checkpoint + +Follow-up runtime `8ccb0d5b3d7705786b6c40c3bcf5a10ff32046d9` adds explicit legacy receipt/empty/orphan/cycle/blank-identity regressions and removes only checks proven redundant after shared admission. Local workspace tests: 71/14 unfiltered suites including two doctests; strict Clippy, warnings-denied rustdoc, release build, format and diff checks passed. Independent read-only review reran 15 integrity tests and found no actionable defect. Unchanged pinned coverage gate passed: functions 136/136, normalized owned regions 1,037/1,037 and branches 174/174. Raw lines 1,448/1,451, regions 2,268/2,279 and branches 169/174 remain below 100%; no raw full-coverage claim is made. Logs: `/tmp/conceptweave-admission-coverage-baseline.log`, `/tmp/conceptweave-admission-coverage-green.log`, `/tmp/conceptweave-admission-boundary-tests.log`. + +PR #10 source `f6735b585022aac1c8ceff86c150d9b64fd77ec2` repairs evaluation admission and independently approved scope binding after normal integration of producer `51c7df6d03f072449422fd58ca24b2f9d6026f07`. RED `3f2cf55` failed three new integrity tests; GREEN passed 68 tests/14 unfiltered suites and strict Clippy. See the Proposed [ADR 0006 amendment](adr/0006-zotero-research-intake.md). This local result does not establish hosted GREEN, protection-compliant merge, release, downstream adoption or full coverage. + +Remaining work: mandatory adoption by restoration, worksheet, duplicate and write consumers without empty defaults or weakened full-text binding. Genuine reviewed decisions and approvals remain 0/3,715 bibliographic proposals, with four additional standalone sources unresolved. Native visual inspection remains unverified at this checkpoint because the Mac was locked. No UI change or new utility repository was needed. + ## P0 product gaps 1. **Concrete Source Observation adapter** — maintained Rust PostgreSQL driver behind `conceptweave-source-port`; adapter-local registry/credential resolution; explicit read-only session/transaction; exact schema allowlist; total operation and statement deadlines; cancellation plus row/byte/concurrency budgets; complete immutable snapshot or fail closed; source-disappearance handling; deterministic replay against a frozen anonymized GRC-shaped fixture.