diff --git a/crates/conceptweave-zotero/src/lib.rs b/crates/conceptweave-zotero/src/lib.rs index 9c17a7d6..b17c8608 100644 --- a/crates/conceptweave-zotero/src/lib.rs +++ b/crates/conceptweave-zotero/src/lib.rs @@ -223,6 +223,29 @@ pub struct ClassificationReport { pub pending_source_item_keys: Vec, /// Reversible DOI/title duplicate candidates. pub duplicate_candidates: Vec, + /// Aggregate completeness evidence for this successful snapshot. + pub audit_summary: ClassificationAudit, +} + +/// Aggregate-only evidence that a successful report covers its input and proposals. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ClassificationAudit { + /// Records captured from the immutable snapshot. + pub snapshot_item_count: usize, + /// Top-level bibliographic records eligible for classification. + pub bibliographic_item_count: usize, + /// Eligible records with exactly one proposed disposition. + pub proposed_disposition_count: usize, + /// Proposals retaining required item and classifier provenance. + pub provenance_complete_count: usize, + /// Proposals routed to steward review. + pub abstention_count: usize, + /// Reversible duplicate identity groups. + pub duplicate_candidate_count: usize, + /// Reader or classifier failures; successful reports always record zero. + pub failure_count: usize, + /// Proposal totals by disposition. + pub disposition_counts: BTreeMap, } /// One steward-reviewed expected disposition in a local golden set. @@ -433,6 +456,15 @@ pub fn validate_classification_report( return Err(invalid); } } + if report.audit_summary + != classification_audit( + &report.snapshot_items, + &report.classified_items, + report.duplicate_candidates.len(), + ) + { + 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. @@ -847,7 +879,7 @@ pub fn classify_snapshot( mut items: Vec, ) -> ClassificationReport { items.sort_by(|left, right| left.key.cmp(&right.key)); - let snapshot_items = items + let snapshot_items: Vec<_> = items .iter() .map(|item| SnapshotItemRevision { item_key: item.key.clone(), @@ -865,7 +897,7 @@ pub fn classify_snapshot( let bibliographic: Vec<&ZoteroItem> = items.iter().filter(|item| is_bibliographic(item)).collect(); let duplicate_candidates = duplicate_candidates(&bibliographic); - let classified_items: Vec<_> = bibliographic + let classified_items: Vec = bibliographic .into_iter() .map(|item| classify_item(item, children.get(&item.key).cloned().unwrap_or_default())) .collect(); @@ -877,6 +909,12 @@ pub fn classify_snapshot( let pending_source_item_keys = pending_source_keys(&classified_items, &unclassified_items, children); + let audit_summary = classification_audit( + &snapshot_items, + &classified_items, + duplicate_candidates.len(), + ); + ClassificationReport { zotero_version, api_version: None, @@ -891,6 +929,49 @@ pub fn classify_snapshot( unclassified_items, pending_source_item_keys, duplicate_candidates, + audit_summary, + } +} + +fn classification_audit( + snapshot_items: &[SnapshotItemRevision], + classified_items: &[ClassifiedItem], + duplicate_candidate_count: usize, +) -> ClassificationAudit { + let mut identity_counts = BTreeMap::new(); + for item in snapshot_items { + *identity_counts + .entry(item.item_key.as_str()) + .or_insert(0usize) += 1; + } + let mut disposition_counts = BTreeMap::new(); + for item in classified_items { + *disposition_counts + .entry(item.proposed_disposition) + .or_insert(0) += 1; + } + ClassificationAudit { + snapshot_item_count: snapshot_items.len(), + bibliographic_item_count: classified_items.len(), + proposed_disposition_count: classified_items.len(), + provenance_complete_count: classified_items + .iter() + .filter(|item| { + !item.item_key.trim().is_empty() + && identity_counts.get(item.item_key.as_str()) == Some(&1) + && item.child_item_keys.iter().all(|child_key| { + !child_key.trim().is_empty() + && identity_counts.get(child_key.as_str()) == Some(&1) + }) + }) + .count(), + abstention_count: classified_items + .iter() + .filter(|item| item.proposed_disposition == Disposition::NeedsStewardReview) + .count(), + duplicate_candidate_count, + failure_count: 0, + disposition_counts, } } diff --git a/crates/conceptweave-zotero/tests/golden_set_evaluation.rs b/crates/conceptweave-zotero/tests/golden_set_evaluation.rs index a363db54..dae82e9f 100644 --- a/crates/conceptweave-zotero/tests/golden_set_evaluation.rs +++ b/crates/conceptweave-zotero/tests/golden_set_evaluation.rs @@ -63,6 +63,20 @@ fn verify_synthetic_approval(golden: &ReviewedGoldenSet) -> bool { #[test] fn reviewed_golden_set_reports_count_based_precision_and_recall_evidence() { let report = report(); + assert_eq!(report.audit_summary.snapshot_item_count, 3); + assert_eq!(report.audit_summary.bibliographic_item_count, 3); + assert_eq!(report.audit_summary.proposed_disposition_count, 3); + assert_eq!(report.audit_summary.provenance_complete_count, 3); + assert_eq!(report.audit_summary.abstention_count, 1); + assert_eq!(report.audit_summary.failure_count, 0); + assert_eq!( + report + .audit_summary + .disposition_counts + .values() + .sum::(), + 3 + ); let evaluation = evaluate_reviewed_golden_set( &report, &golden(vec![ @@ -225,4 +239,12 @@ fn reviewed_golden_set_rejects_stale_unknown_and_duplicate_labels() { ] { assert!(error.to_string().contains(fragment)); } + + let blank_key_report = classify_snapshot( + "9.0.6".into(), + None, + 42, + vec![item(" ", "ontology learning")], + ); + assert_eq!(blank_key_report.audit_summary.provenance_complete_count, 0); } diff --git a/crates/conceptweave-zotero/tests/golden_set_integrity_contract.rs b/crates/conceptweave-zotero/tests/golden_set_integrity_contract.rs index 7ffa4f32..7399394a 100644 --- a/crates/conceptweave-zotero/tests/golden_set_integrity_contract.rs +++ b/crates/conceptweave-zotero/tests/golden_set_integrity_contract.rs @@ -6,6 +6,58 @@ use conceptweave_zotero::{ }; use sha2::{Digest, Sha256}; +#[test] +fn derived_audit_mutation_fails_before_governance() { + for mutation in 0..8 { + let mut report = scope_report(); + let audit = &mut report.audit_summary; + match mutation { + 0 => audit.snapshot_item_count += 1, + 1 => audit.bibliographic_item_count += 1, + 2 => audit.proposed_disposition_count += 1, + 3 => audit.provenance_complete_count += 1, + 4 => audit.abstention_count += 1, + 5 => audit.duplicate_candidate_count += 1, + 6 => audit.failure_count += 1, + _ => audit.disposition_counts.clear(), + } + let golden = scope_golden(&report); + assert_eq!( + evaluate_reviewed_golden_set(&report, &golden, |_| panic!( + "forged audit reached governance" + )), + Err(EvaluationError::InvalidReview), + "audit mutation {mutation}" + ); + } +} + +#[test] +fn ambiguous_source_coordinates_never_count_as_complete_provenance() { + for items in [ + vec![ + bibliographic("A", 1, "ontology learning"), + bibliographic("A", 2, "ontology learning"), + ], + vec![ + bibliographic("A", 1, "ontology learning"), + child_note("C", 1, "A"), + child_note("C", 2, "A"), + ], + vec![ + bibliographic("A", 1, "ontology learning"), + child_note("A", 1, ""), + ], + ] { + let report = classify_snapshot("10.0.1".into(), None, 42, items); + assert_eq!(report.audit_summary.provenance_complete_count, 0); + assert_eq!( + validate_classification_report(&report), + Err(EvaluationError::InvalidReview) + ); + } +} + #[test] fn legacy_proposal_receipt_is_rejected_without_calling_governance() { let report = scope_report(); @@ -223,6 +275,8 @@ fn approved_snapshot_cannot_authorize_a_prediction_changed_to_match_the_label() ); report.classified_items[0].proposed_disposition = Disposition::AlignmentVersioning; + report.audit_summary.disposition_counts = + std::collections::BTreeMap::from([(Disposition::AlignmentVersioning, 1)]); let verifier_called = std::cell::Cell::new(false); assert_eq!( evaluate_reviewed_golden_set(&report, &golden, |candidate| { @@ -249,6 +303,8 @@ fn rewriting_the_proposal_digest_cannot_reuse_an_independent_approval() { }; let approved_golden = golden.clone(); report.classified_items[0].proposed_disposition = Disposition::AlignmentVersioning; + report.audit_summary.disposition_counts = + std::collections::BTreeMap::from([(Disposition::AlignmentVersioning, 1)]); golden.approval.proposal_digest = classification_proposal_digest(&report); let imported_golden = serde_json::from_slice::(&serde_json::to_vec(&golden).unwrap()).unwrap(); diff --git a/crates/conceptweave-zotero/tests/provenance_version_contract.rs b/crates/conceptweave-zotero/tests/provenance_version_contract.rs new file mode 100644 index 00000000..dfe0730e --- /dev/null +++ b/crates/conceptweave-zotero/tests/provenance_version_contract.rs @@ -0,0 +1,73 @@ +use conceptweave_zotero::{ItemData, ZoteroItem, classify_snapshot}; + +#[test] +fn zotero_nine_zero_item_version_is_still_a_valid_provenance_coordinate() { + let report = classify_snapshot( + "9.0.6".into(), + None, + 42, + vec![ZoteroItem { + source_record: None, + key: "UNSYNCED1".into(), + version: 0, + data: ItemData { + item_type: "book".into(), + title: "ontology learning".into(), + abstract_note: String::new(), + doi: String::new(), + parent_item: String::new(), + collections: vec![], + tags: vec![], + }, + }], + ); + + assert_eq!( + report.audit_summary.provenance_complete_count, 1, + "Zotero 9 may report version 0 for never-synced items; zero is a valid observed version, not missing provenance" + ); +} + +#[test] +fn provenance_completeness_requires_stable_linked_child_identity() { + let report = classify_snapshot( + "9.0.6".into(), + None, + 42, + vec![ + ZoteroItem { + source_record: None, + key: "PARENT01".into(), + version: 4, + data: ItemData { + item_type: "journalArticle".into(), + title: "ontology learning".into(), + abstract_note: String::new(), + doi: String::new(), + parent_item: String::new(), + collections: vec![], + tags: vec![], + }, + }, + ZoteroItem { + source_record: None, + key: String::new(), + version: 0, + data: ItemData { + item_type: "note".into(), + title: String::new(), + abstract_note: String::new(), + doi: String::new(), + parent_item: "PARENT01".into(), + collections: vec![], + tags: vec![], + }, + }, + ], + ); + + assert_eq!( + report.audit_summary.provenance_complete_count, 0, + "a proposal with a linked child lacking a stable Zotero key is not provenance-complete" + ); +} diff --git a/docs/PRD.md b/docs/PRD.md index 2d7d6e54..791c4cee 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -66,6 +66,8 @@ Read a complete Zotero Local API observation with one consistent library version 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. +Every successful classification report includes aggregate evidence for snapshot coverage, proposal coverage, provenance completeness, abstentions, duplicate candidates, disposition totals, and zero unreported failures. + ## 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 710b52f1..aa2a3585 100644 --- a/docs/TRD.md +++ b/docs/TRD.md @@ -81,4 +81,6 @@ Structural, source, proposal, and label checks precede the external verifier. Bl 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. +A successful classification report carries an `audit_summary` whose snapshot, bibliographic, proposed-disposition, provenance-complete, abstention, duplicate-candidate, failure, and per-disposition counts are derived from the same in-memory immutable snapshot. Zotero item version zero remains a valid observed coordinate for never-synced Zotero 9 records; provenance completeness rejects a missing item key rather than inventing a positive-only version invariant. Reader failures return an error instead of a partial report; therefore a returned report records `failure_count=0` rather than hiding partial failures. + 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/adr/0006-zotero-research-intake.md b/docs/adr/0006-zotero-research-intake.md index 2ab03175..69299e6e 100644 --- a/docs/adr/0006-zotero-research-intake.md +++ b/docs/adr/0006-zotero-research-intake.md @@ -20,6 +20,7 @@ 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. +Every successful report includes an aggregate audit summary computed from the same captured snapshot. Zotero 9 item version zero is preserved as a valid never-synced source coordinate, not treated as missing provenance. Partial reads never produce a report, so successful output explicitly records zero failures alongside snapshot, proposal, provenance, abstention, duplicate, and per-disposition totals. 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. @@ -72,6 +73,14 @@ GREEN source `f6735b585022aac1c8ceff86c150d9b64fd77ec2` passed 68 tests across 1 ## Alternatives considered +### September 6 derived audit repair (Proposed) + +Live PR #11 findings [3934799129](https://github.com/ContextualWisdomLab/ConceptWeave/pull/11#discussion_r3934799129) and [3934994550](https://github.com/ContextualWisdomLab/ConceptWeave/pull/11#discussion_r3934994550) were rechecked after source-scope integration. Source identity must not absorb derived audit fields, but accepting arbitrary audit values alongside a verified report can still misstate completeness. RED `ebdd852` proved both forged audit reaching governance and duplicate source keys counted as complete provenance. + +We extracted the existing aggregate computation and reuse it during report construction and shared admission. A parent and every linked child must each have one nonblank identity in the complete snapshot to count as provenance-complete. Audit fields are recomputed before independent verification; zero item revisions remain valid. This adds an O(n log n) identity-count pass and rejects inconsistent audit reports, without changing the raw source digest or minting approval. Copying a second audit implementation or mixing counters into source identity was rejected. Duplicate candidate semantics remain owned by the subsequent duplicate boundary; comparing its count is not candidate authentication. + +`cb4c06b` exposed a slice-inference compilation error, repaired by the explicit owned vector in `935e035`. Existing prediction-tampering tests then needed coherent attacker-controlled counters to reach their original gates; `23178a9` retains their original mismatch and unverified-approval expectations. All 75 workspace tests passed. Full coverage, hosted checks, descendant adoption and protection-compliant merge remain separate requirements. + 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. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 0d167f12..49eb6b43 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -56,6 +56,12 @@ Protected central source is `.github/main@b5efbc2762e472e4a380b0503b1f050f76fbb0 ## September 6 source-scope admission checkpoint +PR #11 local successor checkpoint `23178a9768aa216692d77918d56357ae1269535c` normally inherits #10 `fdf8b8d70c05bcb76c55cb6336c9bf31b5e42ce4` while preserving prior #11 `1dc032598b41a35d52c09d8690c871e07365d7e3`. The stable isolated baseline passed 60 tests/15 suites; an earlier run contaminated by merge timing is invalid evidence. RED `ebdd852` reproduced forged derived audit and ambiguous provenance totals. Extracted audit computation now counts unique parent/child identities and is recomputed before governance. Final local tests passed 75/15 suites including two doctests; independent integrity review passed 17 tests with no new regression. Strict Clippy passed at unchanged runtime `935e035`. Pinned coverage is still running; no inherited coverage claim or remote PR update is made here. + +The pinned coverage run subsequently finished with exit 0: 140/140 functions, 1,101/1,101 normalized owned regions, 182/182 normalized branches. Raw lines 1,502/1,505, regions 2,332/2,343 and branches 177/182 remain below 100%. The coverage script and exclusions are unchanged; the earlier pending sentence records chronology, not current execution state. + +Residual duplicate-owner gap: changing duplicate candidates and their matching audit count is not bound by the v2 proposal receipt; derived count consistency does not authenticate duplicate proposals. No duplicate merge/write authority is granted. Fresh native screenshot and accessibility state both showed 3,719 items in the library view, with list rows and attachment icons rendered. The earlier Mac-lock limitation is no longer current. No screenshot or bibliographic identities are committed; this view check does not establish full-library reclassification. + 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.