Skip to content

Conflicting sources

The failure this prevents: your corpus holds a filing and its restatement, or a 2019 policy and its 2026 replacement. Both are real, both are indexed, both are grounded. Whichever one happens to rank first becomes the answer — and nothing tells the caller the other one exists.

That is not hypothetical. Before conflict detection existed, the corpus below returned a confident, correctly-cited answer with conflicts_detected = 0. It now refuses in all three implementations, over the same corpus and the same question, from one predicate pinned by 132 committed vectors.

Example 1 — the filing and its restatement

Section titled “Example 1 — the filing and its restatement”
rag.ingest(text="The dividend for the period was 12 cents per share.",
document_id="filing-q1")
rag.ingest(text="The dividend for the period was 30 cents per share.",
document_id="filing-q1-restated")
response = rag.ask("What was the dividend per share for the period?")
print(response.evidence.decision)
print(response.answer)
print(response.conflicts)
print(response.evidence.conflicts_detected)
for src in response.sources: # BOTH sides are cited
print(src.document, "", src.passage)
refused
The available evidence disagrees, so I can't answer that.
('value: filing-q1-restated vs filing-q1 (30 vs 12)',)
1
filing-q1-restated — The dividend for the period was 30 cents per share.
filing-q1 — The dividend for the period was 12 cents per share.

Why this conflicts. Strip stopwords and the measured numbers from both passages and what is left is identicalcent, dividend, per, period, share on both sides, a subject overlap of 1.0. The parsed unit sets match, {12} is not a superset of {30} (an elaboration would not be a conflict), and after the numbers are set aside the divergence between the two sentences is zero words. There is nothing left that could explain the difference except the disagreement itself, so the library refuses and hands you both.

In strict mode that is an abstention with both sides cited. “These sources disagree, here is each of them” is a defensible output. Silently picking one is not.

Example 2 — two numbers that are not a conflict

Section titled “Example 2 — two numbers that are not a conflict”

The interesting question is not whether a detector fires. It is whether it declines. A detector that refuses whenever it sees two different numbers in one corpus is worthless: almost every real corpus contains two different numbers.

Same shape as Example 1 — two documents, two dividends, one question — with one word changed on each side.

rag.ingest(text="The Q1 dividend was 12 cents per share.", document_id="filing-q1")
rag.ingest(text="The Q2 dividend was 15 cents per share.", document_id="filing-q2")
response = rag.ask("What was the Q1 dividend per share?")
print(response.evidence.decision)
print(response.answer)
print(response.evidence.conflicts_detected)
for src in response.sources:
print(src.document, "", src.passage)
answered
The Q1 dividend was 12 cents per share.
0
filing-q1 — The Q1 dividend was 12 cents per share.

Why this does not conflict — the residual guard. Two passages that genuinely disagree are otherwise word-identical. Two that merely look like they disagree differ by exactly one further content word, and that word is what makes them complementary — the period (q1/q2), the scope (adults/children), the route (oral/intravenous), the environment (staging/production), the metric (p50/p99).

So after the numbers are set aside, the detector counts what is left over. In Example 1 that residual was 0 words. Here it is q1 vs q2 — a residual of 2, past MAX_RESIDUAL = 1. The differing numbers are therefore treated as complementary, not contradictory, and the question is answered from the passage that actually addresses it.

The same guard is why p50 latency 200 ms and p99 latency 900 ms are not a conflict — both cases are committed vectors (identifier_tokenization, hard_negatives).

Where the thresholds came from — and why you can’t turn them

Section titled “Where the thresholds came from — and why you can’t turn them”

Every constant below is pinned and exposed as no caller parameter. That is deliberate: a conformance vector cannot pin a value the caller controls, and each one trades directly against false abstention. (Python declares them in answer/conflict.py; Go and JavaScript read the identical values from their generated conflict tables.)

Constant Value What it governs
MAX_RESIDUAL 1 content divergence still allowed after removing the polarity signal itself
SUBJECT_OVERLAP 0.60 overlap needed before two passages count as being about the same subject
MAX_SYMDIFF 3 total divergence past which the pair is simply unrelated
MIN_CONTENT 3 passages with fewer content tokens are not comparable
DUPLICATE_JACCARD 0.80 where two same-polarity passages become surface clones
CONFLICT_TOP_K 6 how many post-fusion candidates are compared pairwise

MAX_RESIDUAL does nearly all the work, and the ADR-0007 sweep shows why it is frozen at 1:

MAX_RESIDUAL recall false-conflict rate
0 0.89 0.00
1 0.89 0.00
2 0.93 0.15
3 0.93 0.19

Relaxing it by one token buys 4 points of recall and costs 15 points of false conflict — which, in strict mode, is 15 points of false refusal. Example 2 answers today because that constant is 1.

Detection is deterministic, and it never resolves

Section titled “Detection is deterministic, and it never resolves”

No model is involved. Detection runs over already-grounded candidates using content-derived signals only, across an otherwise-shared token set. There are exactly three rules, tried in order:

  1. Antonym pair — one content word on each side forms a known antonym pair (permitted/prohibited, required/optional, …).
  2. Negation parity — the two passages differ in the parity of their negations. A negation that is reported speech (“the vendor claimed it was not…”) is excluded: it belongs to a third party, not to the source.
  3. Numeric-value divergence — the measured values differ, the units match, and neither set is a superset of the other (an elaboration is not a conflict). Dates participate only when they read as digit-leading numbers (2019); there is no date rule as such, and no date parsing.

It deliberately does not resolve. Deciding which of two contradictory sources wins is a policy question about authority, recency and jurisdiction. That belongs to you, not to a retrieval library.

This is also the one refusal that does not emit the pinned refusal string — it says “The available evidence disagrees, so I can’t answer that.” and evidence.conflicts_detected is above zero. That pair is how you tell it apart from the other five ways ask() can refuse: Why did it abstain?

Conflict detection runs on the Unicode-aware v2 tokenizer (ADR-0011), so a Tamil, Telugu, Arabic, Japanese or Chinese contradiction is now seen rather than silently skipped. Only the value rule crosses the script boundary, and the page is going to be specific about what that costs, because a partial capability described as a whole one is the failure this library exists to prevent.

from citenexus.answer.conflict import detect_conflict
cases = [
("ta value", "அறிவிப்பு காலம் 30 நாட்கள் ஆகும்.", "அறிவிப்பு காலம் 60 நாட்கள் ஆகும்."),
("ja flush", "通知期間は30日です。", "通知期間は60日です。"),
("ja full-width", "通知期間は30日です。", "通知期間は60日です。"),
("ar value", "مدة الإشعار هي 30 يوما.", "مدة الإشعار هي 60 يوما."),
("ta negation", "ஊழியர் ரகசியத் தகவலை வெளியிடலாம்.", "ஊழியர் ரகசியத் தகவலை வெளியிடக் கூடாது."),
("ta added scope", "அறிவிப்பு காலம் 30 நாட்கள் ஆகும்.", "முதல் ஆண்டில் அறிவிப்பு காலம் 60 நாட்கள் ஆகும்."),
]
for label, left, right in cases:
print(f"{label:15} {detect_conflict(left, right)}")
ta value ConflictFinding(rule='value', detail='30 vs 60')
ja flush ConflictFinding(rule='value', detail='30 vs 60')
ja full-width None
ar value ConflictFinding(rule='value', detail='30 vs 60')
ta negation None
ta added scope None

Two of those Nones are capability, and two are known misses:

  • Same-subject rejection still works. notice period 30 days vs rent amount 60 rupees in Tamil, and the added-scope qualifier (in the first year, the notice period is 60 days), are both declined — the subject-overlap and residual guards are set arithmetic and are script-neutral.
  • KNOWN MISS — negation and antonym cannot fire outside Latin script. Both rules are driven by English wordlists (CONFLICT_NEGATIONS, CONFLICT_ANTONYMS). No Tamil, Telugu, Arabic, Japanese or Chinese negation or antonym is in them, so a permitted/prohibited contradiction in those scripts is not detected. This is pinned as expected behaviour in the vectors, labelled english-table: negation does not fire in tamil — the suite records the gap rather than hiding it.
  • KNOWN MISS — full-width digits are not matched. 30 / 60 (U+FF10–U+FF19) do not match the number pattern, so a Japanese or Chinese passage that writes its numbers full-width gets no value rule either. Also pinned, labelled KNOWN MISS: full-width digits are not matched by the number pattern.
from citenexus.domain.trust import TrustMode
rag.ask(q, mode=TrustMode.strict) # conflict on the claim -> abstain, cite both
rag.ask(q, mode=TrustMode.normal) # answer, but surface the conflict
rag.ask(q, mode=TrustMode.exploratory) # record only

Use strict where a wrong answer is worse than none. Use normal when a human reads every answer anyway and wants the flag, not the block.

The same comparison, inverted, catches near-duplicates. One sentence mirrored across five document IDs used to report distinct_documents = 5 — five independent confirmations of a fact that had exactly one source. Conflict is checked first, always: a contradiction must never be collapsed into a corroboration.

response.evidence.distinct_documents # 1, not 5
response.evidence.supporting_sources # 1

This claims surface clones only. Telling a genuinely independent restatement apart from a paraphrase of the same origin is not decidable from text — independence is a fact about provenance — so the rule is biased to under-collapse. Two sources it keeps separate may still share an origin.