Files
Stirling-PDF/engine/tests/contradiction/test_claim_ledger.py
T
ConnorYohandGitHub 017c8d59fa feat(ai): add Contradiction Agent on a new ChunkedMapper primitive (#6369)
## Summary

Adds a new AI specialist that finds **textual contradictions** across
one or more PDFs — conflicting claims, recommendations, points of view,
contested facts — built entirely in Python on top of the new
`DocumentService` + `ChunkedReasoner` stack from #6314. Replaces the
closed #6304, which was started before #6314 landed and therefore
over-engineered (Java orchestrator, two-round handshake, resume
artifact, discriminated-union lift).

Two commits:
1. **`refactor(engine): extract ChunkedMapper[T] from ChunkedReasoner`**
— pure refactor, public API of ChunkedReasoner unchanged. New
`ChunkedMapper[T: BaseModel]` is a generic parallel-chunk primitive
(slicing, semaphore, time-bounded extraction, cancellation drain,
progress events) that's now a peer to ChunkedReasoner rather than locked
inside it. The compression loop stays on ChunkedReasoner where it
belongs.
2. **`feat(ai): add Contradiction Agent on ChunkedMapper`** — the agent
itself, plus integrations into `PdfReviewAgent` and `PdfQuestionAgent`.

## Architecture

- **Python-only.** No Java code. No `AgentToolId.CONTRADICTION_AGENT`.
No dedicated HTTP endpoint. No resume artifact, no discriminated-union
lift in `contracts/common.py`. Detector runs inside the Python engine
and the Python engine alone.
- **Review path** (`PdfReviewAgent`): a new
`ContradictionIntentClassifier` fires on contradiction-flavoured
prompts; agent runs detection synchronously and emits a single
`EditPlanResponse(steps=[ADD_COMMENTS])`. Single-turn flow — no resume.
- **Question path** (`PdfQuestionAgent`): a new
`ContradictionCapability` joins `RagCapability` and
`WholeDocReaderCapability` in the smart-model toolset, exposing
`find_contradictions(query)`. The smart model picks it from the toolset
alongside `search_knowledge` and `read_full_document`.

## Inside `ContradictionDetector.detect()`

1. `DocumentService.read_pages(file_id)` → ordered `list[Page]`.
2. `ChunkedMapper[_ExtractedClaims].map_pages(...)` — char-budgeted
multi-page slicing; each slice runs the claim-extractor LLM in parallel
under a semaphore.
3. Page-traceability: the extractor returns `_ExtractedClaim.page`
(which `[Page N]` marker the claim came from). The wrapper validates
`page ∈ chunk.pages`; if not, mechanical fallback searches the chunk's
page text for the verbatim quote and reassigns. If still no match, drop
the claim.
4. `Claim.anchor_quality: Literal[\"verbatim\", \"paraphrased\"]` is set
by a substring check against the declared page's text. Verbatim quotes
feed `anchor_text` for snap-to-quote add-comments placement; paraphrased
ones fall back to margin geometry.
5. Subject canonicalisation: ONE fast-model LLM call collapses synonyms
across the document. Fails open to lexical bucketing.
6. Pre-filters: drop identical-quote pairs; drop same-page same-polarity
paraphrases.
7. Per-bucket pair detection in parallel (separate semaphore, cap 5).
Buckets > 12 claims chunk into windows of 12 with overlap 2; pairs
deduped across overlapping windows by frozen `(i, j)` index pair.
8. Summary fast-model call with fallback string on error.

## Prompt-injection hardening

Every prompt that interpolates user-supplied or PDF-extracted text wraps
content in `<user_message>` / `<verdict>` / `<content>` tags with an
explicit SECURITY preamble instructing the model to treat tagged content
as data only.

## Limitations

- **Combined math + contradiction intent**: when both intent classifiers
fire on the same prompt, contradiction takes precedence and the math
intent is silently dropped. Documented in the Review module docstring
and pinned by
`test_review_integration.py::test_contradiction_precedence_over_math`.
- **Cross-window contradiction reach**: within a subject bucket, pairs
more than ~10 claim indices apart in the same chunked window may be
missed by the overlap-2 strategy. Documented in `test_detector.py`.
Acceptable for v1.

## Settings (engine/src/stirling/config/settings.py)

```python
contradiction_detect_concurrency = 5     # per-bucket detector semaphore
contradiction_bucket_chunk_size = 12     # max claims per detector call
contradiction_bucket_chunk_overlap = 2   # overlap for >threshold buckets
```

`chars_per_slice` and extraction concurrency are reused from the
existing `chunked_reasoner_*` settings.

## Test plan

- [x] `uv run pytest tests/ -v` — **245/245 pass** (210 pre-existing +
35 new)
- [x] `uv run ruff check src/ tests/` — clean
- [x] `uv run pyright src/stirling/agents/contradiction/
src/stirling/contracts/contradiction.py` — 0 errors
- [x] `./gradlew :proprietary:test` — green; no Java was touched, but
verified untouched
- [x] Page-traceability tests cover: valid page kept, hallucinated page
dropped, mechanical-reassign on misattribution, anchor-quality verbatim
vs paraphrased
- [x] Review integration: ADD_COMMENTS plan with two paired CommentSpecs
per contradiction; NeedIngestResponse precheck; precedence vs math
intent pinned
- [x] Question integration: all three capabilities wired into
smart-model toolset; `find_contradictions` returns formatted report text
- [x] ChunkedMapper standalone: slicing, multi-chunk ordering, worker
failures, timeouts, cancellation drain, semaphore saturation
- [x] ChunkedReasoner regression: all pre-existing tests pass unchanged
after the internal split

## Relationship to closed #6304

#6304 was closed in favour of this PR. The closed PR predated #6314 and
modelled the agent as a Java-orchestrated two-round examine/deliberate
flow with its own HTTP endpoint and a discriminated-union resume
artifact. With #6314 making full ordered page text available to the
engine via `DocumentService.read_pages`, none of that is needed. Net
effect: drop ~600 lines of Java, drop the two-round handshake, drop the
`ToolReportArtifact` lift, while ending up with a more scalable agent
(chunk-based instead of page-based extraction; tested to
ChunkedReasoner-equivalent scale).
2026-05-22 13:23:52 +00:00

158 lines
4.8 KiB
Python

"""ClaimLedger — unit tests.
Tests the lexical-normalisation grouping, ``rekey_with_canonical``
re-grouping behaviour, and the ``buckets`` filter (>= 2 only). The
ledger is the source of truth for which canonical-subject buckets get
fed to the contradiction detector, so its grouping rules are part of
the agent's contract.
"""
from __future__ import annotations
import pytest
from stirling.agents.contradiction.validators import ClaimLedger
from stirling.contracts.contradiction import Claim, ClaimPolarity
def _claim(
subject: str,
*,
page: int = 1,
polarity: ClaimPolarity = "assert",
text: str | None = None,
quote: str | None = None,
) -> Claim:
return Claim(
page=page,
subject=subject,
polarity=polarity,
text=text or f"Paraphrase of {subject}",
quote=quote or f'"{subject}" was found here.',
)
@pytest.fixture
def ledger() -> ClaimLedger:
return ClaimLedger()
# Empty ledger
def test_empty_ledger_has_zero_entries(ledger: ClaimLedger) -> None:
assert ledger.entry_count == 0
assert ledger.buckets() == {}
assert ledger.unique_subjects == []
# Singletons are not buckets
def test_single_claim_subject_is_not_a_bucket(ledger: ClaimLedger) -> None:
"""``buckets`` only emits subjects with >= 2 claims (the detector's input shape)."""
ledger.record(_claim("Project Deadline"))
assert ledger.entry_count == 1
assert ledger.buckets() == {}
# Lexical normalisation
def test_lexical_normalisation_collapses_articles_and_punctuation(
ledger: ClaimLedger,
) -> None:
"""All three of these subjects must hash to the same key.
The lexical key strips: lowercase, articles ("the"/"a"/"an"), and
punctuation/whitespace runs.
"""
ledger.record(_claim("Project Deadline:", page=1))
ledger.record(_claim("the project deadline", page=2))
ledger.record(_claim(" PROJECT DEADLINE ", page=3))
buckets = ledger.buckets()
assert len(buckets) == 1
only_bucket = next(iter(buckets.values()))
assert len(only_bucket) == 3
assert {claim.page for claim in only_bucket} == {1, 2, 3}
def test_duplicates_not_deduped_at_ledger_level(ledger: ClaimLedger) -> None:
"""Two structurally identical claims are both kept; deduplication is the
detector's responsibility, not the ledger's."""
claim = _claim("alpha", page=1)
ledger.record(claim)
ledger.record(claim)
assert ledger.entry_count == 2
bucket = ledger.buckets()
assert len(bucket) == 1
assert len(next(iter(bucket.values()))) == 2
# rekey_with_canonical
def test_canonical_keys_collapse_multiple_raw_subjects(ledger: ClaimLedger) -> None:
"""Two distinct raw subjects must collapse once the canonicaliser tells us
they refer to the same thing."""
ledger.record(_claim("Q3 revenue", page=1))
ledger.record(_claim("third-quarter sales", page=2))
# Before rekeying, they live in separate (singleton) lexical buckets.
assert ledger.buckets() == {}
ledger.rekey_with_canonical(
{
"Q3 revenue": "quarterly revenue",
"third-quarter sales": "quarterly revenue",
}
)
buckets = ledger.buckets()
assert len(buckets) == 1
only_bucket = next(iter(buckets.values()))
assert len(only_bucket) == 2
assert {claim.page for claim in only_bucket} == {1, 2}
def test_rekey_with_missing_canonical_falls_back_to_lexical(
ledger: ClaimLedger,
) -> None:
"""A claim whose subject is missing from the mapping must still survive
re-keying — its lexical-normalised form takes over as the key."""
ledger.record(_claim("alpha", page=1))
ledger.record(_claim("alpha", page=2))
ledger.rekey_with_canonical({})
assert ledger.entry_count == 2
buckets = ledger.buckets()
assert len(buckets) == 1
assert len(next(iter(buckets.values()))) == 2
def test_rekey_with_empty_canonical_does_not_lose_record(
ledger: ClaimLedger,
) -> None:
"""A canonical of "" or whitespace must NOT cause silent drop — the
lexical fallback kicks in instead.
"""
ledger.record(_claim("alpha", page=1))
ledger.record(_claim("alpha", page=2))
ledger.rekey_with_canonical({"alpha": " "})
assert ledger.entry_count == 2
def test_unique_subjects_returns_each_raw_subject_once(ledger: ClaimLedger) -> None:
ledger.record(_claim("alpha", page=1))
ledger.record(_claim("alpha", page=2))
ledger.record(_claim("beta", page=1))
subjects = ledger.unique_subjects
assert sorted(subjects) == ["alpha", "beta"]
def test_empty_subject_after_normalisation_is_dropped(ledger: ClaimLedger) -> None:
"""A subject made entirely of punctuation collapses to empty and is dropped."""
ledger.record(_claim(" --- ", page=1))
ledger.record(_claim("real", page=2))
assert ledger.entry_count == 1