Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
f8600f3
experiment: add classification audit summary
seonghobae Sep 4, 2026
a520107
experiment: bind golden evaluation to verified snapshot
seonghobae Sep 4, 2026
eda4d7a
fix: retain golden snapshot evaluation identity
seonghobae Sep 4, 2026
31e2447
merge: inherit golden snapshot content contract
seonghobae Sep 4, 2026
6f2b988
merge: adopt current golden-set snapshot contract
seonghobae Sep 4, 2026
50c2eb1
test(research): preserve Zotero 9 zero-version provenance
seonghobae Sep 4, 2026
35a18fd
fix: bind golden approval to report content
seonghobae Sep 4, 2026
f17acfb
merge: inherit zero-version provenance contract
seonghobae Sep 4, 2026
9f6a18a
fix: preserve zero-version Zotero provenance
seonghobae Sep 4, 2026
393c514
merge: restack audit evidence on current golden contract
seonghobae Sep 4, 2026
f509ebf
merge: adopt current golden-set integrity REDs
seonghobae Sep 4, 2026
6d88115
fix: bind golden review to complete snapshot
seonghobae Sep 4, 2026
551e6cb
merge: restack audit evidence on complete golden contract
seonghobae Sep 4, 2026
b836db9
test(zotero): require linked child provenance identity
seonghobae Sep 4, 2026
c28fcca
fix(zotero): audit linked child provenance
seonghobae Sep 4, 2026
e0ec7da
chore(research): restack audit evidence
seonghobae Sep 4, 2026
472dd34
merge(research): adopt current golden-set parent
seonghobae Sep 4, 2026
cb365fb
merge(research): adopt current golden-set parent and gap baseline
seonghobae Sep 5, 2026
1dd81bc
merge(research): inherit verified source and proposal approval binding
seonghobae Sep 5, 2026
082710e
test(research): adopt captured-source fixture contract
seonghobae Sep 5, 2026
11d158b
merge(research): inherit metadata transport owner repair into PR #11
seonghobae Sep 5, 2026
1dc0325
merge(research): inherit bounded metadata reads into PR #11
seonghobae Sep 6, 2026
39c487a
merge: preserve source scope admission in classification audit
seonghobae Sep 6, 2026
ebdd852
test: reject forged audit and ambiguous provenance totals
seonghobae Sep 6, 2026
cb4c06b
fix: recompute audit evidence and exclude ambiguous identities
seonghobae Sep 6, 2026
935e035
fix: preserve owned snapshot vector at audit boundary
seonghobae Sep 6, 2026
23178a9
test: retain approval attack coverage with coherent audit counts
seonghobae Sep 6, 2026
bec7e31
docs: record derived audit repair and fresh visual evidence
seonghobae Sep 6, 2026
6dff8c2
docs: record terminal audit coverage result
seonghobae Sep 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 83 additions & 2 deletions crates/conceptweave-zotero/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,29 @@ pub struct ClassificationReport {
pub pending_source_item_keys: Vec<String>,
/// Reversible DOI/title duplicate candidates.
pub duplicate_candidates: Vec<DuplicateCandidate>,
/// Aggregate completeness evidence for this successful snapshot.
pub audit_summary: ClassificationAudit,
Comment thread
seonghobae marked this conversation as resolved.
}

/// 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<Disposition, usize>,
}

/// One steward-reviewed expected disposition in a local golden set.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -847,7 +879,7 @@ pub fn classify_snapshot(
mut items: Vec<ZoteroItem>,
) -> 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(),
Expand All @@ -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<ClassifiedItem> = bibliographic
.into_iter()
.map(|item| classify_item(item, children.get(&item.key).cloned().unwrap_or_default()))
.collect();
Expand All @@ -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,
Expand All @@ -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(),
Comment on lines +957 to +967

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include linked child identities in the provenance count

When a bibliographic item has a linked child with a blank key, child_index retains that child in the proposal, but this predicate examines only the parent's key and counts the proposal as provenance-complete. Since the linked child can no longer be identified by a stable source coordinate, the audit overstates provenance completeness for malformed/untrusted snapshots; validate the proposal's linked child revisions as well as its own key before incrementing this count.

AGENTS.md reference: AGENTS.md:L18-L18

Useful? React with 👍 / 👎.

abstention_count: classified_items
.iter()
.filter(|item| item.proposed_disposition == Disposition::NeedsStewardReview)
.count(),
duplicate_candidate_count,
failure_count: 0,
disposition_counts,
}
}

Expand Down
22 changes: 22 additions & 0 deletions crates/conceptweave-zotero/tests/golden_set_evaluation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<usize>(),
3
);
let evaluation = evaluate_reviewed_golden_set(
&report,
&golden(vec![
Expand Down Expand Up @@ -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);
}
56 changes: 56 additions & 0 deletions crates/conceptweave-zotero/tests/golden_set_integrity_contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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| {
Expand All @@ -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::<ReviewedGoldenSet>(&serde_json::to_vec(&golden).unwrap()).unwrap();
Expand Down
73 changes: 73 additions & 0 deletions crates/conceptweave-zotero/tests/provenance_version_contract.rs
Original file line number Diff line number Diff line change
@@ -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"
);
}
2 changes: 2 additions & 0 deletions docs/PRD.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions docs/TRD.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
9 changes: 9 additions & 0 deletions docs/adr/0006-zotero-research-intake.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.
Expand Down
Loading