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)refusedThe available evidence disagrees, so I can't answer that.('value: filing-q1-restated vs filing-q1 (30 vs 12)',)1filing-q1-restated — The dividend for the period was 30 cents per share.filing-q1 — The dividend for the period was 12 cents per share.package main
import ( "fmt"
"github.com/muthuishere/citenexus/golang/answer")
func main() { corpus := []answer.Doc{ {DocumentID: "filing-q1", Text: "The dividend for the period was 12 cents per share."}, {DocumentID: "filing-q1-restated", Text: "The dividend for the period was 30 cents per share."}, }
res := answer.Ask(corpus, "What was the dividend per share for the period?", answer.DefaultTopK)
fmt.Println(res.Evidence.Decision) fmt.Println(res.Answer) fmt.Println(res.Conflicts) fmt.Println(res.Evidence.ConflictsDetected) for _, s := range res.Sources { // BOTH sides are cited fmt.Println(s.Document, "—", s.Passage) }}refusedThe available evidence disagrees, so I can't answer that.[value: filing-q1 vs filing-q1-restated (12 vs 30)]1filing-q1 — The dividend for the period was 12 cents per share.filing-q1-restated — The dividend for the period was 30 cents per share.The Go port has no ask() facade and no config layer, so answer.Ask is the
whole flow: embed, rank, ground, check for conflict, generate, gate. There is no
retrieval index in front of it — you hand it the corpus.
import { ask } from "@muthuishere/citenexus"
const corpus = [ { document_id: "filing-q1", text: "The dividend for the period was 12 cents per share." }, { document_id: "filing-q1-restated", text: "The dividend for the period was 30 cents per share." },]
const res = ask(corpus, "What was the dividend per share for the period?")
console.log(res.evidence.decision)console.log(res.answer)console.log(res.conflicts)console.log(res.evidence.conflicts_detected)for (const s of res.sources) console.log(s.document, "—", s.passage) // BOTH sidesrefusedThe available evidence disagrees, so I can't answer that.[ 'value: filing-q1 vs filing-q1-restated (12 vs 30)' ]1filing-q1 — The dividend for the period was 12 cents per share.filing-q1-restated — The dividend for the period was 30 cents per share.Like Go, JavaScript has no ask() facade over an index — ask(corpus, question)
takes the corpus directly. Same predicate, same decision, same count.
Why this conflicts. Strip stopwords and the measured numbers from both
passages and what is left is identical — cent, 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)answeredThe Q1 dividend was 12 cents per share.0filing-q1 — The Q1 dividend was 12 cents per share.corpus := []answer.Doc{ {DocumentID: "filing-q1", Text: "The Q1 dividend was 12 cents per share."}, {DocumentID: "filing-q2", Text: "The Q2 dividend was 15 cents per share."},}
res := answer.Ask(corpus, "What was the Q1 dividend per share?", answer.DefaultTopK)
fmt.Println(res.Evidence.Decision)fmt.Println(res.Answer)fmt.Println(res.Evidence.ConflictsDetected)for _, s := range res.Sources { fmt.Println(s.Document, "—", s.Passage)}answeredThe Q1 dividend was 12 cents per share.0filing-q1 — The Q1 dividend was 12 cents per share.const corpus = [ { document_id: "filing-q1", text: "The Q1 dividend was 12 cents per share." }, { document_id: "filing-q2", text: "The Q2 dividend was 15 cents per share." },]
const res = ask(corpus, "What was the Q1 dividend per share?")
console.log(res.evidence.decision)console.log(res.answer)console.log(res.evidence.conflicts_detected)for (const s of res.sources) console.log(s.document, "—", s.passage)answeredThe Q1 dividend was 12 cents per share.0filing-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:
- Antonym pair — one content word on each side forms a known antonym pair
(
permitted/prohibited,required/optional, …). - 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.
- 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?
Non-Latin scripts: one rule of three
Section titled “Non-Latin scripts: one rule of three”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 Nonear value ConflictFinding(rule='value', detail='30 vs 60')ta negation Noneta added scope NoneDetectConflict returns the finding and an ok flag, in the Go idiom:
cases := [][3]string{ {"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 _, c := range cases { if f, ok := answer.DetectConflict(c[1], c[2]); ok { fmt.Printf("%-15s %s: %s\n", c[0], f.Rule, f.Detail) } else { fmt.Printf("%-15s no conflict\n", c[0]) }}ta value value: 30 vs 60ja flush value: 30 vs 60ja full-width no conflictar value value: 30 vs 60ta negation no conflictta added scope no conflictimport { detectConflict } from "@muthuishere/citenexus"
const 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 (const [label, left, right] of cases) { console.log(label.padEnd(15), detectConflict(left, right))}ta value { rule: 'value', detail: '30 vs 60' }ja flush { rule: 'value', detail: '30 vs 60' }ja full-width nullar value { rule: 'value', detail: '30 vs 60' }ta negation nullta added scope nullTwo of those Nones are capability, and two are known misses:
- ✅ Same-subject rejection still works.
notice period 30 daysvsrent amount 60 rupeesin 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, labelledenglish-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, labelledKNOWN MISS: full-width digits are not matched by the number pattern.
Choosing how much to enforce
Section titled “Choosing how much to enforce”from citenexus.domain.trust import TrustMode
rag.ask(q, mode=TrustMode.strict) # conflict on the claim -> abstain, cite bothrag.ask(q, mode=TrustMode.normal) # answer, but surface the conflictrag.ask(q, mode=TrustMode.exploratory) # record onlyDetection is at parity; the mode selector is not. result.TrustModeStrict is
the only TrustMode constant Go defines and Ask takes no mode argument, so
this port always behaves like the strict row above: it abstains and cites both
sides. There is no normal here that answers-and-flags.
Detection is at parity; the mode selector is not. TrustMode exports the
single value "strict" and ask takes no mode argument, so this port always
abstains on a conflict rather than answering with a flag.
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.
Duplicates stop inflating your confidence
Section titled “Duplicates stop inflating your confidence”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 5response.evidence.supporting_sources # 1line := "The employee shall not disclose confidential information."corpus := []answer.Doc{}for _, id := range []string{"a", "b", "c", "d", "e"} { corpus = append(corpus, answer.Doc{DocumentID: "mirror-" + id, Text: line})}
res := answer.Ask(corpus, "Can the employee disclose confidential information?", answer.DefaultTopK)
fmt.Println(res.Evidence.Decision)fmt.Println("DistinctDocuments", res.Evidence.DistinctDocuments) // 1, not 5fmt.Println("SupportingSources", res.Evidence.SupportingSources)answeredDistinctDocuments 1SupportingSources 1const line = "The employee shall not disclose confidential information."const corpus = ["a", "b", "c", "d", "e"].map((id) => ({ document_id: "mirror-" + id, text: line }))
const res = ask(corpus, "Can the employee disclose confidential information?")
console.log(res.evidence.decision)console.log("distinct_documents", res.evidence.distinct_documents) // 1, not 5console.log("supporting_sources", res.evidence.supporting_sources)answereddistinct_documents 1supporting_sources 1This 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.
Related
Section titled “Related”- Regulated audit — for when the superseded document should not be in the index at all.
- Ask & abstain — the full decision surface.