jouleclaw-compose: attribute a claim to its most authoritative citation #344

Merged
dcharlot merged 1 commit from fix/cited-item-authority into main 2026-10-19 22:51:54 -04:00

View file

@ -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