From 7c44d332e3bae0e8c3dcfeaf8c5898762d6e45e4 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 19 Oct 2026 20:41:38 -0400 Subject: [PATCH] jouleclaw-compose: attribute a claim to its most authoritative citation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `compose_verified_answer` built each segment's `ClaimAttribution` from ds.cited_item_ids.iter().filter_map(resolve).next() under a comment reading "Source URI from the most authoritative cited item". `.next()` is the first cited id that happens to resolve, in whatever order the ids were listed — a stated selection criterion the code did not perform. Authority tier 1 is `AuthorityTier::Primary` and 4 is `Community`, so the most authoritative cited item is the **minimum** tier. A segment citing a forum post before a peer-reviewed source was therefore attributed to the forum post, and the whole attribution rides on that one choice: `retrieval_source_uri`, `retrieval_timestamp` and `retrieval_axes` all come from it. Selection is now `min_by_key` on the tier, with ties broken on `item_id` so the result is reproducible rather than a function of citation order. A second problem sat in the same expression. `filter_map` drops a cited id that matches no retrieved item **silently**, so a segment whose citations all dangle came out with the ids still attached and an all-`None` attribution, with nothing anywhere stating why. A reader sees citations and no source. Dangling citations are now counted and reported as a caveat, using the mechanism the function already uses for dropped segments. This is the honest shape of the problem the sweep called "citation laundering": the citations are not read back as provenance, and the gap was invisible. Three tests, each pinning one property, every one of them discriminating on *order* rather than on a single arrangement: - the same two items, tier 1 and tier 4, cited both ways round, must attribute to the tier-1 source both times; - two items of **equal** authority, cited both ways round, must resolve the same way — the tie-break is the only thing that makes this true, and it is the only test that catches its removal; - a segment with one real and one dangling citation reports the dangling one and still attributes from the real one; a segment where every citation dangles has no attribution at all, which is when saying so matters most. The sweep's own description of this finding was wrong in its specifics — `cited_item_ids` is read in three places, and there is no `jouleclaw-diagnose` crate — but reading the code to check surfaced the real defect beside it. Co-Authored-By: Claude Opus 5 --- .../crates/jouleclaw-compose/src/verified.rs | 209 +++++++++++++++++- 1 file changed, 205 insertions(+), 4 deletions(-) diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-compose/src/verified.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-compose/src/verified.rs index 746a20a74..29a585f22 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-compose/src/verified.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-compose/src/verified.rs @@ -179,13 +179,38 @@ fn build_answer(inputs: &ComposeInputs<'_>) -> Answer { .and_then(|id| claims_by_id.get(&id).map(|c| c.knowledge_axes.clone())) .unwrap_or_else(default_axes); - // Build per-claim attribution. Source URI from the most - // authoritative cited item. - let cited_item = ds + // Build per-claim attribution from the most authoritative cited item. + // + // This took `.next()` — the first cited id that happened to resolve, + // in whatever order the ids were listed. Tier 1 is the *most* + // authoritative (`AuthorityTier::Primary`) and 4 the least, so the + // most authoritative item is the **minimum** tier, and a segment + // citing a community post before a peer-reviewed source was + // attributed to the community post. The whole attribution — source + // URI, retrieval timestamp and axes — rides on this choice. + // + // Ties break on `item_id` so the choice is reproducible rather than + // dependent on citation order. + let resolved: Vec<&RetrievedItem> = ds .cited_item_ids .iter() .filter_map(|id| inputs.items.iter().find(|it| it.item_id == *id)) - .next(); + .collect(); + // A cited id that matches no retrieved item is not provenance, and + // `filter_map` drops it silently: with every citation dangling the + // attribution degrades to all-`None` while the segment still carries + // the ids, so a reader sees citations and no source with nothing + // saying why. Say why. + let dangling = ds.cited_item_ids.len() - resolved.len(); + if dangling > 0 { + caveats.push(format!( + "Segment {} cites {dangling} item(s) that are not in the retrieved set", + ds.segment_id + )); + } + let cited_item = resolved + .into_iter() + .min_by_key(|it| (it.knowledge_axes.source_authority_tier, it.item_id)); let attribution = ClaimAttribution { schema_version: "6.0".into(), epistemic_mode: EpistemicMode::FromRetrieval, @@ -571,6 +596,182 @@ mod tests { .contains(&"I13".to_string()), "I13 should be added"); } + /// **The attribution must come from the most authoritative cited item, + /// not the first one listed.** + /// + /// Tier 1 is `AuthorityTier::Primary` and 4 is `Community`, so the most + /// authoritative item is the *minimum* tier. This selection carries the + /// whole attribution — source URI, retrieval timestamp and axes — so + /// taking whichever id happened to be listed first attributed a claim to + /// a forum post while a peer-reviewed source sat in the same citation + /// list. + /// + /// The discriminating pair is citation *order*: the same two items, listed + /// both ways round, must attribute to the same one. + #[test] + fn attribution_takes_the_most_authoritative_cited_item_not_the_first() { + for community_first in [true, false] { + let p = plan(); + let mut community = item("A forum post about France."); + community.knowledge_axes = axes(4); + community.source_url = Some("https://forum.example/thread/1".into()); + let mut primary = item("Paris is the capital of France."); + primary.knowledge_axes = axes(1); + primary.source_url = Some("https://www.wikidata.org/wiki/Q90".into()); + + let cited = if community_first { + vec![community.item_id, primary.item_id] + } else { + vec![primary.item_id, community.item_id] + }; + let seg = DraftSegment { + segment_id: "s0".into(), + text: "Paris is the capital of France.".into(), + cited_item_ids: cited, + }; + let claim = claim_for("s0", "Paris is the capital of France."); + let report = report_proceed(p.plan_id); + let items = [community.clone(), primary.clone()]; + let inputs = ComposeInputs { + plan: &p, + draft_segments: &[seg], + claims: &[claim], + items: &items, + report: &report, + joules_spent_total: 100.0, + latency_ms: 1500, + }; + let result = compose_verified_answer(&inputs).unwrap(); + let ans = result.as_answer().expect("Answer"); + let attr = &ans.segments[0].attribution; + assert_eq!( + attr.retrieval_source_uri.as_deref(), + Some("https://www.wikidata.org/wiki/Q90"), + "community_first={community_first}: the tier-1 source must carry the attribution" + ); + assert_eq!( + attr.retrieval_axes.as_ref().map(|a| a.source_authority_tier), + Some(1), + "and the axes must be the tier-1 item's, not the first listed" + ); + } + } + + /// Two cited items of **equal** authority must resolve the same way + /// whichever order they are cited in, or the attribution of an answer + /// depends on citation order — which is not a property of the sources. + /// The tie breaks on `item_id`, so the choice is reproducible. + #[test] + fn an_authority_tie_breaks_reproducibly_not_on_citation_order() { + let low = Uuid::from_u128(1); + let high = Uuid::from_u128(2); + assert!(low < high, "the test's own premise about Uuid ordering"); + + for low_first in [true, false] { + let p = plan(); + let mut a = item("One tier-1 source."); + a.item_id = low; + a.knowledge_axes = axes(1); + a.source_url = Some("https://example.org/low".into()); + let mut b = item("Another tier-1 source."); + b.item_id = high; + b.knowledge_axes = axes(1); + b.source_url = Some("https://example.org/high".into()); + + let seg = DraftSegment { + segment_id: "s0".into(), + text: "Paris is the capital of France.".into(), + cited_item_ids: if low_first { vec![low, high] } else { vec![high, low] }, + }; + let claim = claim_for("s0", "Paris is the capital of France."); + let report = report_proceed(p.plan_id); + let items = [a, b]; + let inputs = ComposeInputs { + plan: &p, + draft_segments: &[seg], + claims: &[claim], + items: &items, + report: &report, + joules_spent_total: 100.0, + latency_ms: 1500, + }; + let result = compose_verified_answer(&inputs).unwrap(); + let ans = result.as_answer().expect("Answer"); + assert_eq!( + ans.segments[0].attribution.retrieval_source_uri.as_deref(), + Some("https://example.org/low"), + "low_first={low_first}: an equal-authority tie must not follow citation order" + ); + } + } + + /// A cited id matching no retrieved item is not provenance. It was dropped + /// silently, so a segment whose every citation dangled came out with the + /// ids still attached and an all-`None` attribution, with nothing stating + /// why. The answer now says so. + #[test] + fn a_citation_that_resolves_to_nothing_is_reported_not_dropped() { + let p = plan(); + let it = item("Paris is the capital of France."); + let seg = DraftSegment { + segment_id: "s0".into(), + text: "Paris is the capital of France.".into(), + // One real citation and one that is in no retrieved set. + cited_item_ids: vec![it.item_id, Uuid::new_v4()], + }; + let claim = claim_for("s0", "Paris is the capital of France."); + let report = report_proceed(p.plan_id); + let items = [it.clone()]; + let inputs = ComposeInputs { + plan: &p, + draft_segments: &[seg], + claims: &[claim], + items: &items, + report: &report, + joules_spent_total: 100.0, + latency_ms: 1500, + }; + let result = compose_verified_answer(&inputs).unwrap(); + let ans = result.as_answer().expect("Answer"); + assert!( + ans.caveats.iter().any(|c| c.contains("not in the retrieved set")), + "a dangling citation must be stated, got caveats {:?}", + ans.caveats + ); + // The resolvable citation still carries the attribution. + assert_eq!( + ans.segments[0].attribution.retrieval_source_uri, + it.source_url, + "the one real citation still attributes" + ); + + // And where every citation dangles there is no attribution at all — + // which is exactly when saying so matters most. + let seg = DraftSegment { + segment_id: "s0".into(), + text: "Paris is the capital of France.".into(), + cited_item_ids: vec![Uuid::new_v4(), Uuid::new_v4()], + }; + let claim = claim_for("s0", "Paris is the capital of France."); + let inputs = ComposeInputs { + plan: &p, + draft_segments: &[seg], + claims: &[claim], + items: &items, + report: &report, + joules_spent_total: 100.0, + latency_ms: 1500, + }; + let result = compose_verified_answer(&inputs).unwrap(); + let ans = result.as_answer().expect("Answer"); + assert!(ans.segments[0].attribution.retrieval_source_uri.is_none()); + assert!( + ans.caveats.iter().any(|c| c.contains("cites 2 item(s)")), + "both dangling citations must be counted, got {:?}", + ans.caveats + ); + } + /// The answer's provenance carries the backing the verifier declared, /// beside the entailment results it covers, so an answer read back from /// a cache can be held to the same claim. Any backing, the floor -- 2.54.0 (Apple Git-157)