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