-
Notifications
You must be signed in to change notification settings - Fork 0
feat(research): add reversible duplicate review manifest #12
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: autoresearch/zotero-reclassification-sep04
Are you sure you want to change the base?
Changes from all commits
4324218
44114d1
667cbe6
f7ef650
43c8a52
e4a9cd0
7fb7bfa
89a3ffe
c32d3f0
6df2967
e77a2d2
eba815b
5bf58aa
2d503ab
0fa629e
42103a8
5ce1a18
3bed887
ee2c494
a4a7c2d
b758991
4656d6b
a27363a
fc0465e
0c825d9
5fff9d0
3d2c252
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -175,16 +175,122 @@ pub struct ClassifiedItem { | |
| } | ||
|
|
||
| /// A duplicate candidate group; no item is merged or deleted. | ||
| #[derive(Debug, Serialize)] | ||
| #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] | ||
| pub struct DuplicateCandidate { | ||
| /// Identity kind used for the candidate group. | ||
| pub identity_kind: &'static str, | ||
| pub identity_kind: String, | ||
| /// Normalized identity value. | ||
| pub normalized_identity: String, | ||
| /// Zotero item keys sharing the identity. | ||
| pub item_keys: Vec<String>, | ||
| } | ||
|
|
||
| /// One steward decision selecting the canonical identity for a duplicate cluster. | ||
| #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] | ||
| pub struct DuplicateMergeDecision { | ||
| /// Candidate identity kind from the classification report. | ||
| pub identity_kind: String, | ||
| /// Candidate normalized identity from the classification report. | ||
| pub normalized_identity: String, | ||
| /// Existing Zotero item retained as the canonical reference. | ||
| pub retained_item_key: String, | ||
| } | ||
|
|
||
| /// Snapshot-bound duplicate decisions verified by the caller's governance boundary. | ||
| #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] | ||
| pub struct ReviewedDuplicateMergeSet { | ||
| /// Opaque steward review receipt. | ||
| pub review_id: String, | ||
| /// Opaque governance authority receipt; contains no person identity. | ||
| pub authority_receipt: String, | ||
| /// Exact Zotero library revision reviewed by the steward. | ||
| pub library_version: u64, | ||
| /// Exact classifier rule revision reviewed by the steward. | ||
| pub rule_revision: String, | ||
| /// Exact raw-snapshot digest reviewed by the steward. | ||
| pub snapshot_digest: String, | ||
| /// Required v2 identity of proposals, retained metadata and pending sources. | ||
| pub proposal_digest: String, | ||
| /// Exact item-key/item-version coordinates reviewed by the steward. | ||
| pub snapshot_items: Vec<SnapshotItemRevision>, | ||
| /// Exact duplicate membership reviewed by the steward. | ||
| pub duplicate_candidates: Vec<DuplicateCandidate>, | ||
| /// Exactly one decision for every duplicate candidate. | ||
| pub decisions: Vec<DuplicateMergeDecision>, | ||
| } | ||
|
|
||
| /// One reversible canonical-key mapping; Zotero source records remain unchanged. | ||
| #[derive(Debug, Clone, PartialEq, Eq, Serialize)] | ||
| pub struct DuplicateMergeOperation { | ||
| /// Candidate identity kind. | ||
| pub identity_kind: String, | ||
| /// Candidate normalized identity retained only in the local manifest. | ||
| pub normalized_identity: String, | ||
| /// Steward-selected canonical Zotero item key. | ||
| pub retained_item_key: String, | ||
| /// Exact source revisions participating in the decision. | ||
| pub source_items: Vec<SnapshotItemRevision>, | ||
| /// Mapping before the reviewed canonicalization; every item maps to itself. | ||
| pub before_canonical_keys: BTreeMap<String, String>, | ||
| /// Reviewed mapping after canonicalization; every item maps to the retained key. | ||
| pub after_canonical_keys: BTreeMap<String, String>, | ||
| /// Exact inverse plan restoring the pre-review mapping. | ||
| pub rollback_canonical_keys: BTreeMap<String, String>, | ||
| } | ||
|
|
||
| /// Aggregate of reviewed, reversible duplicate identity operations. | ||
| #[derive(Debug, Clone, PartialEq, Eq, Serialize)] | ||
| pub struct DuplicateMergeReviewManifest { | ||
| /// Opaque steward review receipt. | ||
| pub review_id: String, | ||
| /// Opaque governance authority receipt. | ||
| pub authority_receipt: String, | ||
| /// Exact Zotero library revision reviewed by the steward. | ||
| pub library_version: u64, | ||
| /// Exact classifier rule revision reviewed by the steward. | ||
| pub rule_revision: String, | ||
| /// Exact raw-snapshot digest shared with the reviewed decisions. | ||
| pub snapshot_digest: String, | ||
| /// Verified identity of the proposals and complete retained source scope. | ||
| pub proposal_digest: String, | ||
| /// Deterministically ordered canonical-key operations. | ||
| pub operations: Vec<DuplicateMergeOperation>, | ||
| /// Classification never deletes or mutates Zotero source records. | ||
| pub source_records_preserved: bool, | ||
| } | ||
|
|
||
| /// A fail-closed duplicate review contract violation. | ||
| #[derive(Debug, Clone, Copy, PartialEq, Eq)] | ||
| pub enum DuplicateReviewError { | ||
| /// Required review metadata or a decision is missing. | ||
| InvalidReview, | ||
| /// The review belongs to another raw snapshot. | ||
| SnapshotMismatch, | ||
| /// A decision does not identify a report candidate. | ||
| UnknownCandidate, | ||
| /// More than one decision targets the same candidate. | ||
| DuplicateDecision, | ||
| /// The retained key is not a member of the candidate cluster. | ||
| InvalidRetainedItem, | ||
| /// The caller's governance boundary rejected the complete review set. | ||
| UnverifiedApproval, | ||
| } | ||
|
|
||
| impl fmt::Display for DuplicateReviewError { | ||
| fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { | ||
| formatter.write_str(match self { | ||
| Self::InvalidReview => "duplicate review metadata or decisions are invalid", | ||
| Self::SnapshotMismatch => "duplicate review does not match the report snapshot", | ||
| Self::UnknownCandidate => "duplicate review contains an unknown candidate", | ||
| Self::DuplicateDecision => "duplicate review repeats a candidate decision", | ||
| Self::InvalidRetainedItem => "retained item is absent from its duplicate candidate", | ||
| Self::UnverifiedApproval => "duplicate review approval is unverified", | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| impl std::error::Error for DuplicateReviewError {} | ||
|
|
||
| /// Complete local classification report for one immutable library version. | ||
| #[derive(Debug, Serialize)] | ||
| pub struct ClassificationReport { | ||
|
|
@@ -584,6 +690,159 @@ where | |
| }) | ||
| } | ||
|
|
||
| /// Builds a local-only reversible canonical-key manifest from steward-reviewed decisions. | ||
| /// | ||
| /// Validates source scope, receipt bindings and all operations before contacting | ||
| /// governance. The verifier must authenticate the complete independently issued | ||
| /// review, including candidate membership and the v2 proposal digest. Legacy | ||
| /// receipts require reapproval; digest recomputation alone grants no authority. | ||
| pub fn build_duplicate_merge_review_manifest<F>( | ||
| report: &ClassificationReport, | ||
| reviewed: &ReviewedDuplicateMergeSet, | ||
| verify_review: F, | ||
| ) -> Result<DuplicateMergeReviewManifest, DuplicateReviewError> | ||
| where | ||
| F: FnOnce(&ReviewedDuplicateMergeSet) -> bool, | ||
| { | ||
| if reviewed.review_id.trim().is_empty() | ||
| || reviewed.authority_receipt.trim().is_empty() | ||
| || reviewed.snapshot_digest.trim().is_empty() | ||
| || reviewed.proposal_digest.trim().is_empty() | ||
| || reviewed.rule_revision.trim().is_empty() | ||
| || reviewed.decisions.len() != report.duplicate_candidates.len() | ||
| { | ||
| return Err(DuplicateReviewError::InvalidReview); | ||
| } | ||
| if reviewed.snapshot_digest != report.snapshot_digest | ||
| || reviewed.proposal_digest != classification_proposal_digest(report) | ||
| || reviewed.library_version != report.library_version | ||
| || reviewed.rule_revision != report.rule_revision | ||
| || reviewed.snapshot_items != report.snapshot_items | ||
| || reviewed.duplicate_candidates != report.duplicate_candidates | ||
| { | ||
| return Err(DuplicateReviewError::SnapshotMismatch); | ||
| } | ||
| validate_classification_report(report).map_err(|_| DuplicateReviewError::InvalidReview)?; | ||
| let item_revisions = report | ||
| .snapshot_items | ||
| .iter() | ||
| .map(|item| (item.item_key.as_str(), item.item_version)) | ||
| .collect::<BTreeMap<_, _>>(); | ||
|
Comment on lines
+726
to
+730
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the public AGENTS.md reference: AGENTS.md:L18-L18 Useful? React with 👍 / 👎. |
||
| let candidates = report | ||
| .duplicate_candidates | ||
| .iter() | ||
| .map(|candidate| { | ||
| ( | ||
| ( | ||
| candidate.identity_kind.as_str(), | ||
| candidate.normalized_identity.as_str(), | ||
| ), | ||
| candidate, | ||
| ) | ||
| }) | ||
| .collect::<BTreeMap<_, _>>(); | ||
| let mut seen_candidates = BTreeSet::new(); | ||
| let mut operations = Vec::with_capacity(reviewed.decisions.len()); | ||
|
|
||
| for decision in &reviewed.decisions { | ||
| let candidate_key = ( | ||
| decision.identity_kind.as_str(), | ||
| decision.normalized_identity.as_str(), | ||
| ); | ||
| if !seen_candidates.insert(candidate_key) { | ||
| return Err(DuplicateReviewError::DuplicateDecision); | ||
| } | ||
| let candidate = candidates | ||
| .get(&candidate_key) | ||
| .ok_or(DuplicateReviewError::UnknownCandidate)?; | ||
| // ponytail: quadratic component expansion is enough for a steward-sized review; | ||
| // replace with union-find only if measured duplicate sets become large. | ||
| let mut component_keys = candidate.item_keys.iter().collect::<BTreeSet<_>>(); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a reviewed AGENTS.md reference: AGENTS.md:L18-L18 Useful? React with 👍 / 👎. |
||
| loop { | ||
| let previous_len = component_keys.len(); | ||
| for related in &report.duplicate_candidates { | ||
| if related | ||
| .item_keys | ||
| .iter() | ||
| .any(|item_key| component_keys.contains(item_key)) | ||
| { | ||
| component_keys.extend(&related.item_keys); | ||
| } | ||
| } | ||
| if component_keys.len() == previous_len { | ||
| break; | ||
| } | ||
| } | ||
| if !component_keys.contains(&decision.retained_item_key) { | ||
| return Err(DuplicateReviewError::InvalidRetainedItem); | ||
| } | ||
| if reviewed.decisions.iter().any(|related_decision| { | ||
| candidates | ||
| .get(&( | ||
| related_decision.identity_kind.as_str(), | ||
| related_decision.normalized_identity.as_str(), | ||
| )) | ||
| .is_some_and(|related_candidate| { | ||
| related_candidate | ||
| .item_keys | ||
| .iter() | ||
| .any(|item_key| component_keys.contains(item_key)) | ||
| && related_decision.retained_item_key != decision.retained_item_key | ||
| }) | ||
| }) { | ||
| return Err(DuplicateReviewError::InvalidReview); | ||
| } | ||
|
|
||
| let source_items = component_keys | ||
| .iter() | ||
| .map(|item_key| { | ||
| item_revisions | ||
| .get(item_key.as_str()) | ||
| .map(|item_version| SnapshotItemRevision { | ||
| item_key: (*item_key).clone(), | ||
| item_version: *item_version, | ||
|
Comment on lines
+799
to
+803
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The new key-only validation still permits a caller to alter a public AGENTS.md reference: AGENTS.md:L18-L18 Useful? React with 👍 / 👎. |
||
| }) | ||
| .ok_or(DuplicateReviewError::InvalidReview) | ||
| }) | ||
| .collect::<Result<Vec<_>, _>>()?; | ||
| let before_canonical_keys = component_keys | ||
| .iter() | ||
| .map(|item_key| ((*item_key).clone(), (*item_key).clone())) | ||
| .collect::<BTreeMap<_, _>>(); | ||
| let after_canonical_keys = component_keys | ||
| .iter() | ||
| .map(|item_key| ((*item_key).clone(), decision.retained_item_key.clone())) | ||
| .collect::<BTreeMap<_, _>>(); | ||
| operations.push(DuplicateMergeOperation { | ||
| identity_kind: decision.identity_kind.clone(), | ||
| normalized_identity: decision.normalized_identity.clone(), | ||
| retained_item_key: decision.retained_item_key.clone(), | ||
| source_items, | ||
| rollback_canonical_keys: before_canonical_keys.clone(), | ||
| before_canonical_keys, | ||
| after_canonical_keys, | ||
| }); | ||
| } | ||
|
|
||
| if !verify_review(reviewed) { | ||
| return Err(DuplicateReviewError::UnverifiedApproval); | ||
| } | ||
| operations.sort_by(|left, right| { | ||
| (&left.identity_kind, &left.normalized_identity) | ||
| .cmp(&(&right.identity_kind, &right.normalized_identity)) | ||
| }); | ||
| Ok(DuplicateMergeReviewManifest { | ||
| review_id: reviewed.review_id.clone(), | ||
| authority_receipt: reviewed.authority_receipt.clone(), | ||
| library_version: reviewed.library_version, | ||
| rule_revision: reviewed.rule_revision.clone(), | ||
| snapshot_digest: reviewed.snapshot_digest.clone(), | ||
| proposal_digest: reviewed.proposal_digest.clone(), | ||
| operations, | ||
| source_records_preserved: true, | ||
| }) | ||
| } | ||
|
|
||
| /// Failure raised when a bounded, immutable Local API read cannot be proven. | ||
| #[derive(Debug)] | ||
| pub enum ReadError { | ||
|
|
@@ -1211,7 +1470,7 @@ fn duplicate_candidates(items: &[&ZoteroItem]) -> Vec<DuplicateCandidate> { | |
| .into_iter() | ||
| .filter_map(|((identity_kind, normalized_identity), item_keys)| { | ||
| (item_keys.len() > 1).then_some(DuplicateCandidate { | ||
| identity_kind, | ||
| identity_kind: identity_kind.into(), | ||
| normalized_identity, | ||
| item_keys, | ||
| }) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This introduces new public domain concepts—including the reviewed duplicate set, authority receipt, canonical-key operation, and the Research Intake-to-Governance verification handoff—but neither
docs/UBIQUITOUS_LANGUAGE.mdnordocs/CONTEXT_MAP.mddefines that language or relationship. Update those DDD artifacts so the newly documented workflow does not diverge from the repository's canonical domain model.AGENTS.md reference: AGENTS.md:L11-L11
Useful? React with 👍 / 👎.