diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 928602fdd..527954752 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,7 +16,7 @@ repos: hooks: - id: codespell args: - - --ignore-words-list=thirdParty,tabEl,tabEls,Sie,ist + - --ignore-words-list=thirdParty,tabEl,tabEls,Sie,ist,fulfilment - --skip="./.*,*.csv,*.json,*.ambr" - --quiet-level=2 files: \.(html|css|js|py|md)$ diff --git a/app/proprietary/build.gradle b/app/proprietary/build.gradle index 141734a90..1c03a435f 100644 --- a/app/proprietary/build.gradle +++ b/app/proprietary/build.gradle @@ -59,6 +59,14 @@ dependencies { // https://mvnrepository.com/artifact/com.bucket4j/bucket4j_jdk17 implementation "org.bouncycastle:bcprov-jdk18on:$bouncycastleVersion" + // Tabula table extraction — used by MathAuditorOrchestrator + implementation ('technology.tabula:tabula:1.0.5') { + exclude group: 'org.slf4j', module: 'slf4j-simple' + exclude group: 'org.bouncycastle', module: 'bcprov-jdk15on' + exclude group: 'com.google.code.gson', module: 'gson' + } + implementation 'com.google.code.gson:gson:2.13.2' + api 'io.micrometer:micrometer-registry-prometheus' api "io.jsonwebtoken:jjwt-api:$jwtVersion" diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/MathAuditorAgentController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/MathAuditorAgentController.java new file mode 100644 index 000000000..3691266b7 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/MathAuditorAgentController.java @@ -0,0 +1,97 @@ +package stirling.software.proprietary.controller.api; + +import java.io.IOException; +import java.math.BigDecimal; + +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.model.api.ai.Verdict; +import stirling.software.proprietary.service.MathAuditorOrchestrator; + +/** + * Public entry point for the Math Auditor Agent (mathAuditorAgent). + * + *
Accepts a PDF from the client, hands it to the {@link MathAuditorOrchestrator} which runs the + * multi-round Java-Python negotiation, and returns the Auditor's {@link Verdict}. + * + *
The raw PDF never leaves Java. Python receives only structured text and CSV data.
+ */
+@Slf4j
+@RestController
+@RequestMapping("/api/v1/ai")
+@RequiredArgsConstructor
+@Tag(name = "AI Engine", description = "AI-powered document analysis endpoints.")
+public class MathAuditorAgentController {
+
+ private final MathAuditorOrchestrator orchestrator;
+
+ @PostMapping(value = "/math-auditor-agent", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+ @Operation(
+ summary = "Validate mathematical calculations in a PDF",
+ description =
+ """
+ Analyses a PDF document for mathematical errors using the Math Auditor Agent.
+
+ The auditor checks:
+ - Table row and column totals (tally errors)
+ - Inline arithmetic expressions (e.g. "100 + 200 = 300")
+ - Cross-page figure consistency (same figure cited differently on different pages)
+ - Prose claims about percentages, growth rates, and comparisons
+
+ The PDF is processed entirely on the Java side; only extracted text and table data
+ are sent to the AI engine.
+
+ Input: PDF Output: JSON Type: SISO
+ """)
+ public ResponseEntity Sent after Java has fulfilled a {@link Requisition}. When {@code finalRound} is {@code true},
+ * the Auditor must return a {@link Verdict} — Java will not honour further Requisitions.
+ *
+ * @param sessionId Matches the session opened by the original client request.
+ * @param folios The extracted page content for each page in the Requisition.
+ * @param round Which negotiation round this Evidence belongs to (1–3).
+ * @param finalRound When {@code true}, the Auditor must commit to a Verdict this round.
+ * @param unauditablePages Pages that were requested but could not be fulfilled — e.g. OCR was asked
+ * for but is not yet wired. The Auditor echoes these into {@link Verdict#unauditablePages()} so
+ * the client knows coverage is incomplete.
+ */
+public record Evidence(
+ String sessionId,
+ List Only the fields explicitly requested will be populated; unused fields are {@code null}.
+ *
+ * @param page 0-indexed page number.
+ * @param text PDFBox plain-text extraction result (null if not requested).
+ * @param tables Tabula CSV strings, one per table found on the page (null if not requested).
+ * @param ocrText OCRmyPDF output text (null if not requested or OCR not available).
+ * @param ocrConfidence Mean character confidence from OCRmyPDF, 0.0–1.0 (null if OCR not run).
+ */
+public record Folio(
+ int page, String text, List Built from a cheap PDFBox scan (character count + image detection) with no OCR or Tabula
+ * involved. Sent to the Python Examiner, which replies with a {@link Requisition}.
+ *
+ * @param sessionId Opaque handle Java uses to locate the PDF on disk during this audit session.
+ * @param pageCount Total number of pages in the document.
+ * @param folioTypes One {@link FolioType} per page (0-indexed). {@code folioTypes.size() ==
+ * pageCount}.
+ * @param round Which negotiation round this manifest belongs to (1–3).
+ */
+public record FolioManifest(
+ String sessionId, int pageCount, List Java parses this from the Examiner's response, fulfils it (text / tables / OCR), and sends the
+ * results back as an {@link Evidence} payload.
+ *
+ * @param type Discriminator — always {@code "requisition"}.
+ * @param needText 0-indexed page numbers requiring PDFBox plain-text extraction.
+ * @param needTables 0-indexed page numbers requiring Tabula CSV extraction.
+ * @param needOcr 0-indexed page numbers requiring OCRmyPDF.
+ * @param rationale Human-readable reason logged for observability.
+ */
+public record Requisition(
+ String type,
+ List This is the terminal message in the audit negotiation; Java returns it to the client once
+ * received from Python.
+ *
+ * @param type Discriminator — always {@code "verdict"}.
+ * @param sessionId Matches the session opened by the original client request.
+ * @param discrepancies Every mathematical error found, sorted by page.
+ * @param pagesExamined 0-indexed page numbers the Auditor actually inspected.
+ * @param roundsTaken How many negotiation rounds were needed (1–3).
+ * @param summary One or two sentences suitable for the end user.
+ * @param clean {@code true} iff no errors were found (warnings are tolerated).
+ * @param unauditablePages Pages that could not be audited — typically image-only pages for which
+ * OCR was requested but is not yet wired. The client should indicate that these pages were not
+ * checked.
+ */
+public record Verdict(
+ String type,
+ String sessionId,
+ List Manages a four-step Java-Python protocol:
+ *
+ * The raw PDF never leaves Java. Python only receives structured text and CSV data.
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class MathAuditorOrchestrator {
+
+ private static final String EXAMINE_PATH = "/api/v1/ai/math-auditor-agent/examine";
+ private static final String DELIBERATE_PATH = "/api/v1/ai/math-auditor-agent/deliberate";
+
+ private final AiEngineClient aiEngineClient;
+ private final CustomPDFDocumentFactory pdfDocumentFactory;
+ private final PdfContentExtractor pdfContentExtractor;
+ private final ObjectMapper objectMapper;
+
+ /**
+ * Run a full math audit against the supplied PDF file.
+ *
+ * @param pdfFile The uploaded PDF to audit.
+ * @param tolerance Arithmetic tolerance — differences smaller than this are ignored.
+ * @return The Auditor's final Verdict.
+ */
+ public Verdict audit(MultipartFile pdfFile, BigDecimal tolerance) throws IOException {
+ String sessionId = UUID.randomUUID().toString();
+ log.info(
+ "[math-auditor-agent] audit started session={} file={} tolerance={}",
+ sessionId,
+ pdfFile.getOriginalFilename(),
+ tolerance);
+
+ try (PDDocument document = pdfDocumentFactory.load(pdfFile)) {
+ // Round 1: classify pages cheaply; send manifest; get requisition
+ List
+ *
+ *
+ * tables = sea.extract(tabulaPage);
+
+ for (Table table : tables) {
+ StringWriter sw = new StringWriter();
+ FlexibleCSVWriter csvWriter = new FlexibleCSVWriter(format);
+ csvWriter.write(sw, Collections.singletonList(table));
+ csvStrings.add(sw.toString());
+ }
+ }
+ return csvStrings;
+ }
+
+ // -----------------------------------------------------------------------
+ // Workflow extraction (used by AiWorkflowService)
+ // -----------------------------------------------------------------------
+
/**
* Extracts content from the loaded files according to the requested content types and budget
* constraints.
diff --git a/engine/config/.env.example b/engine/config/.env.example
index 55e2cfc30..bfcbe3f57 100644
--- a/engine/config/.env.example
+++ b/engine/config/.env.example
@@ -12,3 +12,9 @@ STIRLING_FAST_MODEL_MAX_TOKENS=2048
STIRLING_POSTHOG_ENABLED=false
STIRLING_POSTHOG_API_KEY=phc_VOdeYnlevc2T63m3myFGjeBlRcIusRgmhfx6XL5a1iz
STIRLING_POSTHOG_HOST=https://eu.i.posthog.com
+
+# Log level for the stirling logger hierarchy (DEBUG, INFO, WARNING, ERROR)
+STIRLING_LOG_LEVEL=INFO
+
+# Path to log file. Rolls daily, keeps 1 backup. Leave empty for console only.
+STIRLING_LOG_FILE=
diff --git a/engine/pyproject.toml b/engine/pyproject.toml
index 17da20b1b..b410cc6d2 100644
--- a/engine/pyproject.toml
+++ b/engine/pyproject.toml
@@ -46,6 +46,7 @@ select = [
"UP",
"PYI", # flake8-pyi: flags deprecated typing constructs
"FA", # flake8-future-annotations: flags missing future annotations imports
+ "BLE", # flake8-blind-except: flags bare `except Exception`
]
[tool.pyright]
diff --git a/engine/src/stirling/agents/ledger/__init__.py b/engine/src/stirling/agents/ledger/__init__.py
new file mode 100644
index 000000000..a6b4831f4
--- /dev/null
+++ b/engine/src/stirling/agents/ledger/__init__.py
@@ -0,0 +1,5 @@
+"""Math Auditor Agent (mathAuditorAgent) — AI-powered math validation for PDF documents."""
+
+from .agent import MathAuditorAgent
+
+__all__ = ["MathAuditorAgent"]
diff --git a/engine/src/stirling/agents/ledger/agent.py b/engine/src/stirling/agents/ledger/agent.py
new file mode 100644
index 000000000..f000b604e
--- /dev/null
+++ b/engine/src/stirling/agents/ledger/agent.py
@@ -0,0 +1,550 @@
+"""
+Math Auditor Agent (mathAuditorAgent) — pydantic-ai agents for PDF math validation.
+
+Examiner (Round 1, /api/v1/ai/math-auditor-agent/examine)
+ Receives a FolioManifest and returns a Requisition declaring what
+ Java must extract before validation can begin.
+
+Audit pipeline (Round 2, /api/v1/ai/math-auditor-agent/deliberate)
+ Processes Evidence per-page:
+ 1. Deterministic pass — ArithmeticScanner on every folio
+ 2. Fast-model pass — extract named figures from each page
+ 3. FigureTracker — cross-page consistency check
+ 4. Fast-model call — generate human-readable summary
+ 5. Assemble Verdict programmatically
+
+Neither agent ever touches a PDF file. All content arrives pre-extracted
+by Java, which owns the PDF from start to finish.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+from collections.abc import Coroutine
+from decimal import Decimal, InvalidOperation
+from typing import Any
+
+from pydantic import BaseModel, Field
+from pydantic_ai import Agent
+from pydantic_ai.exceptions import AgentRunError
+
+from stirling.contracts.ledger import (
+ Discrepancy,
+ DiscrepancyKind,
+ Evidence,
+ Folio,
+ FolioManifest,
+ Requisition,
+ Severity,
+ Verdict,
+)
+from stirling.logging import Pretty
+from stirling.services import AppRuntime
+
+from .prompts import (
+ EXAMINER_SYSTEM_PROMPT,
+ FIGURE_EXTRACTOR_PROMPT,
+ STATEMENT_VERIFIER_PROMPT,
+ SUMMARY_PROMPT,
+ TABLE_FORMULA_PROMPT,
+)
+from .validators import ArithmeticScanner, FigureTracker, FormulaEvaluator
+
+logger = logging.getLogger(__name__)
+
+
+# ---------------------------------------------------------------------------
+# Structured output models for the per-page figure extractor
+# ---------------------------------------------------------------------------
+
+
+class ExtractedFigure(BaseModel):
+ """A single named figure found on a page."""
+
+ label: str = Field(description="Normalised name, e.g. 'Total Revenue', 'VAT'.")
+ value: str = Field(description="Numeric value as a string, e.g. '1200.00'.")
+ raw: str = Field(description="Original text from the document, e.g. '£1,200.00'.")
+
+
+class FigureExtractionResult(BaseModel):
+ """All named figures found on a single page."""
+
+ figures: list[ExtractedFigure] = Field(default_factory=list)
+
+
+class FormulaCheck(BaseModel):
+ """One verifiable mathematical relationship in a table."""
+
+ description: str = Field(description="Human-readable, e.g. 'Line Total = Qty × Unit Price'")
+ formula: str = Field(description="Expression: 'col3 = col1 * col2' or 'cell(4,3) = sum(col3, 1-3)'")
+ scope: str = Field(description="'each_row' | 'column_total' | 'single_cell'")
+ row_range: list[int] | None = Field(default=None, description="Data rows to check (for each_row scope)")
+ target_row: int | None = Field(default=None, description="Row index of total (for column_total/single_cell)")
+ target_col: int | None = Field(default=None, description="Column index (for column_total/single_cell)")
+
+
+class TableFormulas(BaseModel):
+ """All verifiable formulas found in one table."""
+
+ formulas: list[FormulaCheck] = Field(default_factory=list)
+
+
+class StatementCheck(BaseModel):
+ """One prose claim and its verification result."""
+
+ claim: str = Field(description="The exact text of the claim")
+ verification: str = Field(description="Type: percentage_change, comparison, ratio, trend, average, other")
+ values_referenced: list[str] = Field(default_factory=list, description="Numbers used in the check")
+ expected_result: str = Field(description="What the calculation actually yields")
+ actual_claim: str = Field(description="What the text claims")
+ is_valid: bool = Field(description="True if the claim is correct within tolerance")
+ explanation: str = Field(description="One-line working showing the calculation")
+
+
+class StatementsResult(BaseModel):
+ """All verifiable prose claims found on a page."""
+
+ statements: list[StatementCheck] = Field(default_factory=list)
+
+
+# ---------------------------------------------------------------------------
+# MathAuditorAgent — main entry point, instantiated once at startup
+# ---------------------------------------------------------------------------
+
+
+class MathAuditorAgent:
+ """
+ Encapsulates the Ledger Auditor pipeline.
+
+ Instantiated once at app startup with an AppRuntime, which provides
+ pre-built Model objects and ModelSettings.
+ """
+
+ def __init__(self, runtime: AppRuntime) -> None:
+ fast_model = runtime.fast_model
+ model_settings = runtime.fast_model_settings
+ self._runtime = runtime
+ self._examiner = Agent(
+ model=fast_model,
+ deps_type=FolioManifest,
+ output_type=Requisition,
+ system_prompt=EXAMINER_SYSTEM_PROMPT,
+ model_settings=model_settings,
+ )
+ self._figure_extractor = Agent(
+ model=fast_model,
+ output_type=FigureExtractionResult,
+ system_prompt=FIGURE_EXTRACTOR_PROMPT,
+ model_settings=model_settings,
+ )
+ self._table_analyser = Agent(
+ model=fast_model,
+ output_type=TableFormulas,
+ system_prompt=TABLE_FORMULA_PROMPT,
+ model_settings=model_settings,
+ )
+ self._statement_verifier = Agent(
+ model=fast_model,
+ output_type=StatementsResult,
+ system_prompt=STATEMENT_VERIFIER_PROMPT,
+ model_settings=model_settings,
+ )
+ self._summary_agent = Agent(
+ model=fast_model,
+ output_type=str,
+ system_prompt=SUMMARY_PROMPT,
+ model_settings=model_settings,
+ )
+ self._llm_semaphore = asyncio.Semaphore(10)
+
+ # ------------------------------------------------------------------
+ # Round 1: Examine
+ # ------------------------------------------------------------------
+
+ async def examine(self, manifest: FolioManifest) -> Requisition:
+ """Inspect a FolioManifest and declare the Requisition."""
+ logger.info(
+ "[math-auditor-agent] session=%s round=%d examining %d folios",
+ manifest.session_id,
+ manifest.round,
+ manifest.page_count,
+ )
+
+ user_prompt = "Examine this folio manifest and declare your requisition:\n" + manifest.model_dump_json()
+ logger.debug("REQUEST (examine)\n%s", Pretty({"user_prompt": user_prompt}))
+
+ result = await self._examiner.run(user_prompt, deps=manifest)
+ req = result.output
+
+ logger.debug("RESPONSE (examine)\n%s", Pretty(req.model_dump()))
+ logger.info(
+ "[math-auditor-agent] session=%s requisition: text=%s tables=%s ocr=%s",
+ manifest.session_id,
+ req.need_text,
+ req.need_tables,
+ req.need_ocr,
+ )
+ return req
+
+ # ------------------------------------------------------------------
+ # Round 2: Deliberate (deterministic-first pipeline)
+ # ------------------------------------------------------------------
+
+ async def audit(self, evidence: Evidence, tolerance: Decimal = Decimal("0.01")) -> Verdict:
+ """
+ Audit the evidence using a deterministic-first pipeline:
+
+ 1. Run ArithmeticScanner on every folio (no LLM)
+ 2. Extract named figures per-page with fast model
+ 3. Run FigureTracker cross-page consistency check (no LLM)
+ 4. Generate human summary with fast model
+ 5. Assemble Verdict
+ """
+ return await self._audit_inner(evidence, tolerance)
+
+ async def _audit_inner(
+ self,
+ evidence: Evidence,
+ tolerance: Decimal,
+ ) -> Verdict:
+ logger.info(
+ "[math-auditor-agent] session=%s round=%d auditing %d folios (final=%s)",
+ evidence.session_id,
+ evidence.round,
+ len(evidence.folios),
+ evidence.final_round,
+ )
+
+ all_discrepancies: list[Discrepancy] = []
+ pages_examined: list[int] = []
+ figure_tracker = FigureTracker(tolerance=tolerance)
+
+ # Step 1: Arithmetic scanning (deterministic, instant)
+ arithmetic_scanner = ArithmeticScanner(tolerance=tolerance)
+ for folio in evidence.folios:
+ pages_examined.append(folio.page)
+ text = folio.readable_text
+ if text and text.strip():
+ results = arithmetic_scanner.scan(folio.page, text)
+ all_discrepancies.extend(results)
+ logger.debug(
+ "TOOL (scan_arithmetic)\nArgs: %s\nResult: %s",
+ Pretty({"page": folio.page, "text_length": len(text)}),
+ Pretty([d.model_dump() for d in results]),
+ )
+
+ # Step 2: Parallel LLM calls — formula inference + figure extraction
+ # These are independent per-page so we fire them all concurrently.
+ formula_evaluator = FormulaEvaluator(tolerance=tolerance)
+ folios_with_text = [f for f in evidence.folios if f.readable_text.strip()]
+
+ # Collect all tables as (page, csv) pairs for formula inference
+ table_tasks: list[tuple[int, str]] = []
+ for folio in evidence.folios:
+ if folio.tables:
+ for table_csv in folio.tables:
+ table_tasks.append((folio.page, table_csv))
+
+ logger.info(
+ "[math-auditor-agent] session=%s step 2: %d formula + %d figure LLM calls (parallel)",
+ evidence.session_id,
+ len(table_tasks),
+ len(folios_with_text),
+ )
+
+ # Fire all LLM calls concurrently (bounded by _llm_semaphore)
+ formula_coros = [self._throttled(self._infer_formulas(csv)) for _, csv in table_tasks]
+ figure_coros = [self._throttled(self._extract_figures_for_page(f)) for f in folios_with_text]
+ statement_coros = [self._throttled(self._verify_statements(f)) for f in folios_with_text]
+ all_results = await asyncio.gather(
+ *formula_coros,
+ *figure_coros,
+ *statement_coros,
+ return_exceptions=True,
+ )
+
+ n_formulas = len(table_tasks)
+ n_figures = len(folios_with_text)
+
+ # Process formula results
+ for i, (page, table_csv) in enumerate(table_tasks):
+ result = all_results[i]
+ if isinstance(result, BaseException):
+ logger.warning("[math-auditor-agent] formula inference failed for page %d: %s", page, result)
+ continue
+ assert isinstance(result, TableFormulas)
+ formulas = result
+ if not formulas.formulas:
+ logger.info("[math-auditor-agent] page %d: no verifiable formulas found", page)
+ continue
+ for fc in formulas.formulas:
+ checked = formula_evaluator.evaluate(
+ page=page,
+ table_csv=table_csv,
+ formula=fc.formula,
+ scope=fc.scope,
+ description=fc.description,
+ row_range=fc.row_range,
+ target_row=fc.target_row,
+ target_col=fc.target_col,
+ )
+ all_discrepancies.extend(checked)
+ logger.debug(
+ "TOOL (check_formula)\nArgs: %s\nResult: %s",
+ Pretty({"page": page, "formula": fc.formula, "scope": fc.scope, "description": fc.description}),
+ Pretty([d.model_dump() for d in checked]),
+ )
+
+ # Process figure results
+ for i, folio in enumerate(folios_with_text):
+ result = all_results[n_formulas + i]
+ if isinstance(result, BaseException):
+ logger.warning("[math-auditor-agent] figure extraction failed for page %d: %s", folio.page, result)
+ continue
+ assert isinstance(result, list)
+ for fig, page in result:
+ try:
+ decimal_value = Decimal(fig.value.replace(",", "").strip())
+ except (InvalidOperation, ValueError):
+ logger.warning(
+ "[math-auditor-agent] skipping figure %r on page %d: non-numeric value %r",
+ fig.label,
+ page,
+ fig.value,
+ )
+ continue
+ figure_tracker.record(
+ label=fig.label,
+ value=decimal_value,
+ page=page,
+ raw=fig.raw,
+ )
+
+ # Process statement verification results
+ for i, folio in enumerate(folios_with_text):
+ result = all_results[n_formulas + n_figures + i]
+ if isinstance(result, BaseException):
+ logger.warning("[math-auditor-agent] statement verification failed for page %d: %s", folio.page, result)
+ continue
+ assert isinstance(result, StatementsResult)
+ stmts = result
+ for sc in stmts.statements:
+ if not sc.is_valid:
+ all_discrepancies.append(
+ Discrepancy(
+ page=folio.page,
+ kind=DiscrepancyKind.STATEMENT,
+ severity=Severity.ERROR,
+ description=f"{sc.claim}: {sc.explanation}",
+ stated=sc.actual_claim,
+ expected=sc.expected_result,
+ context=sc.claim,
+ )
+ )
+ logger.debug(
+ "TOOL (verify_statement)\nArgs: %s\nResult: %s",
+ Pretty({"page": folio.page, "claim": sc.claim}),
+ Pretty(sc.model_dump()),
+ )
+
+ logger.info(
+ "[math-auditor-agent] session=%s step 2 complete: %d figures registered",
+ evidence.session_id,
+ figure_tracker.entry_count,
+ )
+
+ # Step 3: Cross-page consistency — deterministic
+ consistency_discrepancies = figure_tracker.conflicts()
+ all_discrepancies.extend(consistency_discrepancies)
+ if consistency_discrepancies:
+ logger.debug(
+ "TOOL (check_figure_consistency)\nResult: %s",
+ Pretty([d.model_dump() for d in consistency_discrepancies]),
+ )
+
+ # Step 4: Summary — fast model, small payload
+ # Collect verification stats for the summary
+ total_tables = sum(len(f.tables) for f in evidence.folios if f.tables)
+ total_formulas_checked = sum(len(r.formulas) for r in all_results[:n_formulas] if isinstance(r, TableFormulas))
+ total_statements_checked = sum(
+ len(r.statements) for r in all_results[n_formulas + n_figures :] if isinstance(r, StatementsResult)
+ )
+ verification_stats = (
+ f"Verified: {len(pages_examined)} pages, {total_tables} tables "
+ f"({total_formulas_checked} formulas), "
+ f"{figure_tracker.entry_count} figures tracked, "
+ f"{total_statements_checked} prose claims checked."
+ )
+
+ logger.info(
+ "[math-auditor-agent] session=%s step 4: generating summary (%d discrepancies)",
+ evidence.session_id,
+ len(all_discrepancies),
+ )
+ pages_examined.sort()
+ summary = await self._generate_summary(
+ all_discrepancies,
+ pages_examined,
+ evidence.unauditable_pages,
+ verification_stats,
+ )
+
+ # Step 5: Assemble Verdict
+ error_count = sum(1 for d in all_discrepancies if d.severity == Severity.ERROR)
+ verdict = Verdict(
+ session_id=evidence.session_id,
+ discrepancies=all_discrepancies,
+ pages_examined=pages_examined,
+ rounds_taken=evidence.round,
+ summary=summary,
+ clean=error_count == 0,
+ unauditable_pages=evidence.unauditable_pages,
+ )
+
+ logger.debug("RESPONSE (deliberate)\n%s", Pretty(verdict.model_dump()))
+ logger.info(
+ "[math-auditor-agent] session=%s verdict: %d errors, %d warnings, clean=%s",
+ evidence.session_id,
+ verdict.error_count,
+ verdict.warning_count,
+ verdict.clean,
+ )
+ return verdict
+
+ # ------------------------------------------------------------------
+ # Internal helpers
+ # ------------------------------------------------------------------
+
+ async def _throttled[T](self, coro: Coroutine[Any, Any, T]) -> T:
+ """Wrap a coroutine with the LLM concurrency semaphore."""
+ async with self._llm_semaphore:
+ return await coro
+
+ async def _infer_formulas(self, table_csv: str) -> TableFormulas:
+ """Ask the fast model to infer verifiable formulas from a CSV table."""
+ try:
+ result = await self._table_analyser.run(f"CSV table:\n{table_csv}")
+ formulas = result.output
+ except AgentRunError:
+ logger.warning("[math-auditor-agent] formula inference failed, skipping table", exc_info=True)
+ formulas = TableFormulas(formulas=[])
+
+ logger.debug(
+ "TOOL (infer_formulas)\nArgs: %s\nResult: %s",
+ Pretty({"table_csv": table_csv[:300]}),
+ Pretty(formulas.model_dump()),
+ )
+ return formulas
+
+ async def _verify_statements(
+ self,
+ folio: Folio,
+ ) -> StatementsResult:
+ """Ask the fast model to find and verify prose claims on a page."""
+ text = folio.readable_text
+ if not text or not text.strip():
+ return StatementsResult(statements=[])
+
+ # Build context: page text + any table CSVs
+ prompt = f"Page {folio.page + 1} text:\n{text}"
+ if folio.tables:
+ prompt += "\n\nTable data on this page:\n"
+ for i, csv in enumerate(folio.tables):
+ prompt += f"\nTable {i + 1}:\n{csv}"
+
+ try:
+ result = await self._statement_verifier.run(prompt)
+ stmts = result.output
+ except AgentRunError:
+ logger.warning("[math-auditor-agent] statement verification failed for page %d", folio.page, exc_info=True)
+ stmts = StatementsResult(statements=[])
+
+ if stmts.statements:
+ logger.debug(
+ "TOOL (verify_statements)\nArgs: %s\nResult: %s",
+ Pretty({"page": folio.page, "text_length": len(text), "n_tables": len(folio.tables or [])}),
+ Pretty([s.model_dump() for s in stmts.statements]),
+ )
+ return stmts
+
+ async def _extract_figures_for_page(
+ self,
+ folio: Folio,
+ ) -> list[tuple[ExtractedFigure, int]]:
+ text = folio.readable_text
+ if not text or not text.strip():
+ return []
+
+ logger.info("[math-auditor-agent] extracting figures from page %d (%d chars)", folio.page, len(text))
+ prompt = f"Page {folio.page + 1} text:\n{text}"
+ try:
+ result = await self._figure_extractor.run(prompt)
+ figures = result.output.figures
+ except AgentRunError:
+ logger.warning(
+ "[math-auditor-agent] figure extraction failed for page %d, skipping",
+ folio.page,
+ exc_info=True,
+ )
+ figures = []
+
+ logger.debug(
+ "TOOL (extract_figures)\nArgs: %s\nResult: %s",
+ Pretty({"page": folio.page, "text_length": len(text)}),
+ Pretty([f.model_dump() for f in figures]),
+ )
+
+ return [(fig, folio.page) for fig in figures]
+
+ async def _generate_summary(
+ self,
+ discrepancies: list[Discrepancy],
+ pages_examined: list[int],
+ unauditable_pages: list[int],
+ verification_stats: str,
+ ) -> str:
+ error_count = sum(1 for d in discrepancies if d.severity == Severity.ERROR)
+ warning_count = sum(1 for d in discrepancies if d.severity == Severity.WARNING)
+
+ prompt = (
+ f"{verification_stats}\n"
+ f"Errors: {error_count}, Warnings: {warning_count}, "
+ f"Pages examined: {len(pages_examined)}, "
+ f"Unauditable pages: {unauditable_pages or 'none'}.\n"
+ )
+ if discrepancies:
+ prompt += "Discrepancies:\n"
+ for d in discrepancies:
+ prompt += f" - [{d.severity}] p{d.page + 1}: {d.description}\n"
+
+ try:
+ result = await self._summary_agent.run(prompt)
+ summary = result.output
+ except AgentRunError:
+ logger.warning("[math-auditor-agent] summary generation failed, using fallback", exc_info=True)
+ summary = self._fallback_summary(error_count, warning_count, pages_examined, unauditable_pages)
+
+ logger.debug("RESPONSE (summary)\n%s", Pretty({"summary": summary}))
+ return summary
+
+ @staticmethod
+ def _fallback_summary(
+ error_count: int,
+ warning_count: int,
+ pages_examined: list[int],
+ unauditable_pages: list[int],
+ ) -> str:
+ parts = []
+ if error_count == 0 and warning_count == 0:
+ parts.append(f"No mathematical errors found across {len(pages_examined)} pages.")
+ else:
+ if error_count:
+ parts.append(f"Found {error_count} error{'s' if error_count != 1 else ''}.")
+ if warning_count:
+ parts.append(f"Found {warning_count} warning{'s' if warning_count != 1 else ''}.")
+ if unauditable_pages:
+ parts.append(
+ f"Pages {', '.join(str(p + 1) for p in unauditable_pages)} could not be audited (OCR unavailable)."
+ )
+ return " ".join(parts)
diff --git a/engine/src/stirling/agents/ledger/prompts.py b/engine/src/stirling/agents/ledger/prompts.py
new file mode 100644
index 000000000..78e97c4f9
--- /dev/null
+++ b/engine/src/stirling/agents/ledger/prompts.py
@@ -0,0 +1,147 @@
+"""
+Ledger Auditor — system prompts.
+
+One prompt per role; keep them short and directive. Each agent is a
+specialist with a narrow remit, not a general assistant.
+"""
+
+EXAMINER_SYSTEM_PROMPT = """\
+You are the Examiner, the first stage of the Ledger Auditor pipeline.
+
+You receive a FolioManifest: a list of page types (text / image / mixed) \
+for a PDF document. Your sole task is to declare exactly which pages you \
+need Java to extract content from so that the Auditor can verify the \
+document's mathematics.
+
+Rules:
+- Request BOTH text AND table extraction for every 'text' or 'mixed' page. \
+ Tables are critical — the Auditor cannot verify totals without them. \
+ Tabula extraction is cheap; missing a table is not.
+- Request OCR for any page classified as 'image' or 'mixed' (PDFBox cannot \
+ read image-only content).
+- Be conservative — if in doubt, request the page. False negatives \
+ (missed errors) are worse than false positives (wasted extraction).
+- Do not request pages that are clearly decorative (cover pages, blank pages) \
+ unless you cannot tell from the manifest alone.
+- Return a Requisition with your page lists and a plain-English rationale \
+ that will appear in server logs.
+"""
+
+FIGURE_EXTRACTOR_PROMPT = """\
+You are a figure extractor for financial document auditing.
+
+You receive the text content of a single PDF page. Your task is to \
+identify every significant named numeric figure on the page.
+
+A "named figure" is a labelled number that could appear elsewhere in \
+the document under the same name — for example:
+ "Total Revenue: £1,200,000"
+ "Net Profit $45,000"
+ "VAT (20%): 240.00"
+ "Subtotal ......... 3,500"
+
+For each figure, return:
+- label: a normalised name (e.g. "Total Revenue", "Net Profit", "VAT")
+- value: the numeric value as a plain decimal string (e.g. "1200000")
+- raw: the original text as it appears in the document
+
+Rules:
+- Only extract figures that have a clear label/name attached.
+- Do not extract bare numbers without context.
+- Strip currency symbols and thousands separators from value.
+- If a figure appears multiple times on the same page, extract each.
+- Return an empty list if no named figures are found.
+- Be precise — do not invent figures that are not in the text.
+"""
+
+TABLE_FORMULA_PROMPT = """\
+You are a table formula analyser for financial document auditing.
+
+You receive a CSV table extracted from a PDF. Your task is to identify \
+every verifiable mathematical relationship between cells.
+
+Relationships fall into three scopes:
+
+1. "each_row" — a formula that should hold for every data row.
+ Example: "col3 = col1 * col2" (Line Total = Qty × Unit Price)
+
+2. "column_total" — a total row where cells = sum of the column above.
+ Example: a Subtotal row where each cell sums the column.
+
+3. "single_cell" — one specific cell computed from others.
+ Example: "cell(5,3) = cell(4,3) * 0.1" (Tax = Subtotal × 10%)
+
+Formula syntax (use exactly this):
+ - Column references: col0, col1, col2 ... (0-indexed)
+ - Cell references: cell(row, col) — 0-indexed, header is row 0
+ - Operators: + - * /
+ - sum(colN, start-end) — sum of colN from row start to row end inclusive
+ - Decimal numbers: 0.1, 100, etc.
+
+Rules:
+- Row 0 is the header. First data row is row 1.
+- Include the left-hand side: "col3 = col1 * col2" not just "col1 * col2"
+- For column_total scope, set target_row to the total row index. \
+ Set target_col to a specific column or null to check all numeric columns.
+- For each_row scope, set row_range to the data rows (exclude header \
+ and total rows).
+- Only return formulas you are confident about. Skip columns/rows \
+ where the relationship is unclear.
+- Return an empty list if the table has no verifiable math.
+"""
+
+STATEMENT_VERIFIER_PROMPT = """\
+You are a statement verifier for financial document auditing.
+
+You receive the text of a single PDF page, plus any table data from \
+that page. Your task is to find prose claims that make mathematical \
+assertions, and verify whether each claim is correct.
+
+A "verifiable claim" is a sentence that states a mathematical fact \
+about numbers present on the page or derivable from the data. Examples:
+ - "Revenue grew 15% year-over-year"
+ - "Costs decreased month on month"
+ - "Department A represents 40% of total spend"
+ - "Net margin improved to 12.4%"
+ - "Average transaction value was $250"
+
+For each claim you find:
+1. Identify the numbers referenced in the claim
+2. Perform the calculation yourself using the data on the page
+3. Compare your result to what the claim states
+4. Determine if the claim is valid (within reasonable rounding)
+
+Return:
+- claim: the exact text of the claim
+- verification: the type — "percentage_change", "comparison", \
+ "ratio", "trend", "average", or "other"
+- values_referenced: the specific numbers used in your check
+- expected_result: what the calculation actually yields
+- actual_claim: what the text claims
+- is_valid: true if the claim is correct within 1% tolerance
+- explanation: show your working, one line
+
+Rules:
+- Only check claims that can be verified from data on this page.
+- If a claim references data not on the page, skip it.
+- "Decreased month on month" means EVERY consecutive pair decreased.
+- Percentage claims allow 1% absolute tolerance (14.8% ≈ 15%).
+- Return an empty list if there are no verifiable claims.
+- Do not fabricate claims that are not in the text.
+"""
+
+SUMMARY_PROMPT = """\
+You are a summary writer for a PDF math audit tool.
+
+You receive a list of discrepancies (errors and warnings) found in a \
+document, plus coverage statistics and a breakdown of what was verified. \
+Write a two to three sentence summary suitable for an end user.
+
+Rules:
+- Start with what was verified: e.g. "Audited 6 pages: checked 4 tables \
+ (12 formulas), scanned 6 pages for arithmetic, extracted 20 figures \
+ for cross-page consistency, and verified 3 prose claims."
+- Then state the outcome: errors found or clean.
+- Mention unauditable pages if any exist.
+- Be concise and factual. Do not repeat individual discrepancy details.
+"""
diff --git a/engine/src/stirling/agents/ledger/validators/__init__.py b/engine/src/stirling/agents/ledger/validators/__init__.py
new file mode 100644
index 000000000..d4ba9dc1c
--- /dev/null
+++ b/engine/src/stirling/agents/ledger/validators/__init__.py
@@ -0,0 +1,5 @@
+from .arithmetic import ArithmeticScanner
+from .figures import FigureTracker
+from .formula import FormulaEvaluator
+
+__all__ = ["ArithmeticScanner", "FigureTracker", "FormulaEvaluator"]
diff --git a/engine/src/stirling/agents/ledger/validators/_parsing.py b/engine/src/stirling/agents/ledger/validators/_parsing.py
new file mode 100644
index 000000000..52a06271c
--- /dev/null
+++ b/engine/src/stirling/agents/ledger/validators/_parsing.py
@@ -0,0 +1,31 @@
+"""Shared parsing helpers for ledger validators."""
+
+from __future__ import annotations
+
+import csv
+import io
+import re
+from decimal import Decimal, InvalidOperation
+
+# Strip common currency symbols and thousands separators before parsing.
+STRIP_PATTERN = re.compile(r"[£$€¥,\s]")
+
+
+def to_decimal(raw: str) -> Decimal | None:
+ """Parse a cell value to Decimal, returning None for non-numeric cells."""
+ cleaned = STRIP_PATTERN.sub("", raw.strip())
+ if not cleaned or cleaned in {"-", "—", "n/a", "N/A", "na", "NA"}:
+ return None
+ # Handle parenthesised negatives: (123.45) → -123.45
+ if cleaned.startswith("(") and cleaned.endswith(")"):
+ cleaned = "-" + cleaned[1:-1]
+ try:
+ return Decimal(cleaned)
+ except InvalidOperation:
+ return None
+
+
+def parse_csv(table_csv: str) -> list[list[str]]:
+ """Parse a CSV string into rows, dropping completely empty rows."""
+ reader = csv.reader(io.StringIO(table_csv.strip()))
+ return [row for row in reader if any(cell.strip() for cell in row)]
diff --git a/engine/src/stirling/agents/ledger/validators/arithmetic.py b/engine/src/stirling/agents/ledger/validators/arithmetic.py
new file mode 100644
index 000000000..31856ffa8
--- /dev/null
+++ b/engine/src/stirling/agents/ledger/validators/arithmetic.py
@@ -0,0 +1,152 @@
+"""
+ArithmeticScanner — finds and verifies inline arithmetic expressions in text.
+
+Targets patterns commonly found in financial documents:
+ "100 + 200 + 150 = 450"
+ "Total: 1,250 (500 + 400 + 350)"
+ "Net profit of £1,200 (£2,000 revenue less £800 costs)"
+
+All arithmetic is performed in Decimal. The scanner does not use an LLM —
+it is a deterministic regex-and-eval pipeline.
+"""
+
+from __future__ import annotations
+
+import logging
+import re
+from decimal import Decimal
+
+from stirling.contracts.ledger import Discrepancy, DiscrepancyKind, Severity
+
+from ._parsing import STRIP_PATTERN as _STRIP
+from ._parsing import to_decimal as _to_decimal
+
+logger = logging.getLogger(__name__)
+
+# ---------------------------------------------------------------------------
+# Regex patterns
+# ---------------------------------------------------------------------------
+
+# Currency / number token: optional sign, optional currency symbol,
+# digits with optional thousands separator and decimal point.
+_NUM = r"[£$€¥]?-?[\d,]+(?:\.\d+)?"
+
+# "A + B + C = D" or "A + B + C = D" with arbitrary spacing
+_EQUALS_EXPR = re.compile(
+ rf"({_NUM}(?:\s*[+\-]\s*{_NUM})+)\s*=\s*({_NUM})",
+ re.IGNORECASE,
+)
+
+# "Total: X (A + B + C)" — the total comes before the addends
+_TOTAL_THEN_ADDENDS = re.compile(
+ rf"(?:total|sum|grand total|subtotal)\s*[:\-]?\s*({_NUM})\s*\(({_NUM}(?:\s*[+\-]\s*{_NUM})+)\)",
+ re.IGNORECASE,
+)
+
+
+def _parse(token: str) -> Decimal | None:
+ """Parse a regex-matched token to Decimal."""
+ return _to_decimal(token)
+
+
+def _eval_expression(expr: str) -> Decimal | None:
+ """
+ Evaluate a simple additive expression of the form A +/- B +/- C ...
+ Returns None if the expression cannot be parsed.
+ """
+ # Tokenise: split on + or -, keep the operator.
+ tokens = re.split(r"([+\-])", _STRIP.sub("", expr.strip()))
+ result = Decimal(0)
+ operator = "+"
+ for token in tokens:
+ token = token.strip()
+ if not token:
+ continue # skip empty tokens (e.g. from leading negative)
+ if token in ("+", "-"):
+ operator = token
+ continue
+ val = _parse(token)
+ if val is None:
+ return None
+ result = result + val if operator == "+" else result - val
+ return result
+
+
+class ArithmeticScanner:
+ """
+ Scans a block of text for arithmetic expressions and checks them.
+
+ Parameters
+ ----------
+ tolerance:
+ Maximum absolute difference before an expression is flagged as wrong.
+ """
+
+ def __init__(self, tolerance: Decimal = Decimal("0.01")) -> None:
+ self.tolerance = tolerance
+
+ def scan(self, page: int, text: str) -> list[Discrepancy]:
+ """
+ Find all verifiable arithmetic expressions in *text* and return
+ a Discrepancy for each one that does not balance within tolerance.
+ """
+ discrepancies: list[Discrepancy] = []
+ discrepancies.extend(self._check_equals_expressions(page, text))
+ discrepancies.extend(self._check_total_then_addends(page, text))
+ return discrepancies
+
+ # ------------------------------------------------------------------
+ # Internal helpers
+ # ------------------------------------------------------------------
+
+ def _check_equals_expressions(self, page: int, text: str) -> list[Discrepancy]:
+ """Handle patterns like '100 + 200 = 300'."""
+ found: list[Discrepancy] = []
+ for match in _EQUALS_EXPR.finditer(text):
+ expr_str = match.group(1)
+ stated_str = match.group(2)
+
+ computed = _eval_expression(expr_str)
+ stated = _parse(stated_str)
+ if computed is None or stated is None:
+ continue
+
+ if abs(computed - stated) > self.tolerance:
+ found.append(
+ Discrepancy(
+ page=page,
+ kind=DiscrepancyKind.ARITHMETIC,
+ severity=Severity.ERROR,
+ description=f"Arithmetic error: {expr_str.strip()} should equal {computed}, not {stated}",
+ stated=str(stated),
+ expected=str(computed),
+ context=match.group(0),
+ )
+ )
+ return found
+
+ def _check_total_then_addends(self, page: int, text: str) -> list[Discrepancy]:
+ """Handle patterns like 'Total: 450 (100 + 200 + 150)'."""
+ found: list[Discrepancy] = []
+ for match in _TOTAL_THEN_ADDENDS.finditer(text):
+ stated_str = match.group(1)
+ expr_str = match.group(2)
+
+ stated = _parse(stated_str)
+ computed = _eval_expression(expr_str)
+ if stated is None or computed is None:
+ continue
+
+ if abs(computed - stated) > self.tolerance:
+ found.append(
+ Discrepancy(
+ page=page,
+ kind=DiscrepancyKind.ARITHMETIC,
+ severity=Severity.ERROR,
+ description=f"Stated total {stated} does not match addends ({expr_str.strip()} = {computed})",
+ stated=str(stated),
+ expected=str(computed),
+ context=match.group(0),
+ )
+ )
+ return found
diff --git a/engine/src/stirling/agents/ledger/validators/figures.py b/engine/src/stirling/agents/ledger/validators/figures.py
new file mode 100644
index 000000000..7790c8dfb
--- /dev/null
+++ b/engine/src/stirling/agents/ledger/validators/figures.py
@@ -0,0 +1,98 @@
+"""
+FigureTracker — cross-page consistency checker for named figures.
+
+Collects named numeric figures as the auditor encounters them (e.g.
+"Total Revenue: £1,200,000") and surfaces any that appear under the same
+label but with a different value on another page — a classic symptom of
+copy-paste errors or stale data in executive summaries.
+
+The tracker is intentionally simple: normalise labels, compare values
+within tolerance, emit Discrepancy for each conflict.
+"""
+
+from __future__ import annotations
+
+import logging
+import re
+from decimal import Decimal
+
+from pydantic import BaseModel
+
+from stirling.contracts.ledger import Discrepancy, DiscrepancyKind, Severity
+
+logger = logging.getLogger(__name__)
+
+
+class FigureRecord(BaseModel):
+ """A named numeric figure seen on a specific page."""
+
+ label: str
+ value: Decimal
+ page: int
+ raw: str
+
+
+# Strip punctuation that varies between contexts ("revenue:" vs "revenue —")
+_LABEL_NOISE = re.compile(r"[:\-—\s]+")
+
+
+def _normalise_label(label: str) -> str:
+ return _LABEL_NOISE.sub(" ", label.lower()).strip()
+
+
+class FigureTracker:
+ """
+ Accumulates named figures during an audit and checks them for consistency.
+
+ Typical usage:
+ tracker = FigureTracker()
+ tracker.record("Net Profit", Decimal("1200.00"), page=3, raw="£1,200.00")
+ tracker.record("Net Profit", Decimal("1250.00"), page=7, raw="£1,250.00")
+ discrepancies = tracker.conflicts() # returns one Discrepancy
+ """
+
+ def __init__(self, tolerance: Decimal = Decimal("0.01")) -> None:
+ self.tolerance = tolerance
+ self._ledger: dict[str, list[FigureRecord]] = {}
+
+ def record(self, label: str, value: Decimal, page: int, raw: str) -> None:
+ """Register a named figure sighting."""
+ key = _normalise_label(label)
+ self._ledger.setdefault(key, []).append(FigureRecord(label=key, value=value, page=page, raw=raw))
+
+ def conflicts(self) -> list[Discrepancy]:
+ """
+ Return a Discrepancy for every label that has sightings whose value
+ differs from the first-seen (canonical) value by more than tolerance.
+
+ O(n) per label — each record is compared against the canonical only.
+ """
+ discrepancies: list[Discrepancy] = []
+
+ for label, records in self._ledger.items():
+ if len(records) < 2:
+ continue
+ canonical = records[0]
+ for other in records[1:]:
+ if abs(canonical.value - other.value) > self.tolerance:
+ discrepancies.append(
+ Discrepancy(
+ page=other.page,
+ kind=DiscrepancyKind.CONSISTENCY,
+ severity=Severity.WARNING,
+ description=(
+ f'"{label}" stated as {canonical.raw} on page'
+ f" {canonical.page + 1}"
+ f" but {other.raw} on page {other.page + 1}"
+ ),
+ stated=other.raw,
+ expected=canonical.raw,
+ context=(f"First seen: page {canonical.page + 1} | Later: page {other.page + 1}"),
+ )
+ )
+
+ return discrepancies
+
+ @property
+ def entry_count(self) -> int:
+ return sum(len(v) for v in self._ledger.values())
diff --git a/engine/src/stirling/agents/ledger/validators/formula.py b/engine/src/stirling/agents/ledger/validators/formula.py
new file mode 100644
index 000000000..fe9ae81c5
--- /dev/null
+++ b/engine/src/stirling/agents/ledger/validators/formula.py
@@ -0,0 +1,375 @@
+"""
+FormulaEvaluator — verifies LLM-inferred formulas against CSV table data.
+
+Supports a safe expression syntax:
+ - Column refs: col0, col1, col2 ...
+ - Cell refs: cell(row, col)
+ - Operators: + - * /
+ - Functions: sum(colN, rows start-end)
+
+All arithmetic is Decimal. No eval(), no arbitrary code execution.
+"""
+
+from __future__ import annotations
+
+import logging
+import re
+from decimal import Decimal, InvalidOperation
+
+from stirling.contracts.ledger import Discrepancy, DiscrepancyKind, Severity
+
+from ._parsing import parse_csv as _parse_csv
+from ._parsing import to_decimal as _to_decimal
+
+logger = logging.getLogger(__name__)
+
+
+class FormulaEvaluator:
+ """
+ Evaluates formula expressions against parsed CSV rows.
+
+ Formulas use a simple syntax:
+ "col3 = col1 * col2" — per-row check
+ "cell(4,3) = sum(col3, 1-3)" — single cell check
+ """
+
+ def __init__(self, tolerance: Decimal = Decimal("0.01")) -> None:
+ self.tolerance = tolerance
+
+ def evaluate(
+ self,
+ page: int,
+ table_csv: str,
+ formula: str,
+ scope: str,
+ description: str,
+ row_range: list[int] | None = None,
+ target_row: int | None = None,
+ target_col: int | None = None,
+ ) -> list[Discrepancy]:
+ """
+ Evaluate a formula against table data.
+
+ scope: "each_row" | "column_total" | "single_cell"
+ """
+ rows = _parse_csv(table_csv)
+ if len(rows) < 2:
+ return []
+
+ if scope == "each_row":
+ return self._check_each_row(page, rows, formula, description, row_range)
+ elif scope == "column_total":
+ return self._check_column_total(page, rows, formula, description, target_row, target_col)
+ elif scope == "single_cell":
+ return self._check_single_cell(page, rows, formula, description, target_row, target_col)
+ else:
+ logger.warning("[formula] unknown scope %r, skipping", scope)
+ return []
+
+ def _check_each_row(
+ self,
+ page: int,
+ rows: list[list[str]],
+ formula: str,
+ description: str,
+ row_range: list[int] | None,
+ ) -> list[Discrepancy]:
+ """Verify formula holds for each data row."""
+ discrepancies: list[Discrepancy] = []
+
+ # Parse "colX = expr" format
+ parts = formula.split("=", 1)
+ if len(parts) != 2:
+ return []
+ lhs = parts[0].strip()
+ rhs = parts[1].strip()
+
+ lhs_col = self._parse_col_ref(lhs)
+ if lhs_col is None:
+ return []
+
+ check_rows = row_range if row_range else list(range(1, len(rows)))
+
+ for row_idx in check_rows:
+ if row_idx >= len(rows):
+ continue
+ row = rows[row_idx]
+
+ stated = self._get_cell(row, lhs_col)
+ if stated is None:
+ continue
+
+ computed = self._eval_row_expr(rhs, row, rows)
+ if computed is None:
+ continue
+
+ if abs(stated - computed) > self.tolerance:
+ discrepancies.append(
+ Discrepancy(
+ page=page,
+ kind=DiscrepancyKind.TALLY,
+ severity=Severity.ERROR,
+ description=f"{description}: row {row_idx} — stated {stated}, expected {computed}",
+ stated=str(stated),
+ expected=str(computed),
+ context=f"row {row_idx}, {formula}",
+ )
+ )
+
+ return discrepancies
+
+ def _check_column_total(
+ self,
+ page: int,
+ rows: list[list[str]],
+ formula: str,
+ description: str,
+ target_row: int | None,
+ target_col: int | None,
+ ) -> list[Discrepancy]:
+ """Verify that a total row contains correct column sums."""
+ if target_row is None or target_row >= len(rows):
+ return []
+
+ discrepancies: list[Discrepancy] = []
+ total_row = rows[target_row]
+
+ # Determine which columns to check
+ cols_to_check: list[int] = []
+ if target_col is not None:
+ cols_to_check = [target_col]
+ else:
+ # Check all numeric columns in the total row
+ cols_to_check = list(range(len(total_row)))
+
+ # Determine addend rows (all rows between header and total row)
+ addend_rows = list(range(1, target_row))
+
+ for col in cols_to_check:
+ stated = self._get_cell(total_row, col)
+ if stated is None:
+ continue
+
+ computed = Decimal(0)
+ has_addends = False
+ for r_idx in addend_rows:
+ if r_idx >= len(rows):
+ continue
+ val = self._get_cell(rows[r_idx], col)
+ if val is not None:
+ computed += val
+ has_addends = True
+
+ if not has_addends:
+ continue
+
+ if abs(stated - computed) > self.tolerance:
+ discrepancies.append(
+ Discrepancy(
+ page=page,
+ kind=DiscrepancyKind.TALLY,
+ severity=Severity.ERROR,
+ description=f"{description}: column {col} — stated {stated}, expected {computed}",
+ stated=str(stated),
+ expected=str(computed),
+ context=f"column {col}, total row {target_row}",
+ )
+ )
+
+ return discrepancies
+
+ def _check_single_cell(
+ self,
+ page: int,
+ rows: list[list[str]],
+ formula: str,
+ description: str,
+ target_row: int | None,
+ target_col: int | None,
+ ) -> list[Discrepancy]:
+ """Verify a single cell formula (e.g. Grand Total = Subtotal + Tax)."""
+ parts = formula.split("=", 1)
+ if len(parts) != 2:
+ return []
+
+ # Parse target from LHS cell(r,c) if not provided explicitly
+ if target_row is None or target_col is None:
+ lhs_match = re.match(r"cell\(\s*(\d+)\s*,\s*(\d+)\s*\)", parts[0].strip())
+ if lhs_match:
+ target_row = int(lhs_match.group(1))
+ target_col = int(lhs_match.group(2))
+ else:
+ return []
+
+ if target_row >= len(rows):
+ return []
+
+ rhs = parts[1].strip()
+
+ stated = self._get_cell(rows[target_row], target_col)
+ if stated is None:
+ return []
+
+ computed = self._eval_row_expr(rhs, rows[target_row], rows)
+ if computed is None:
+ return []
+
+ if abs(stated - computed) > self.tolerance:
+ return [
+ Discrepancy(
+ page=page,
+ kind=DiscrepancyKind.TALLY,
+ severity=Severity.ERROR,
+ description=f"{description}: stated {stated}, expected {computed}",
+ stated=str(stated),
+ expected=str(computed),
+ context=f"cell({target_row},{target_col}), {formula}",
+ )
+ ]
+ return []
+
+ # ------------------------------------------------------------------
+ # Expression evaluation
+ # ------------------------------------------------------------------
+
+ def _eval_row_expr(self, expr: str, row: list[str], all_rows: list[list[str]]) -> Decimal | None:
+ """
+ Evaluate an expression in the context of a specific row.
+ Supports: colN refs, cell(r,c) refs, +, -, *, /
+ Also supports: sum(colN, start-end)
+ """
+ # Handle sum() function first
+ sum_pattern = re.compile(r"sum\(\s*col(\d+)\s*,\s*(\d+)\s*-\s*(\d+)\s*\)")
+ resolved = expr
+ for match in sum_pattern.finditer(expr):
+ col = int(match.group(1))
+ start = int(match.group(2))
+ end = int(match.group(3))
+ total = Decimal(0)
+ for r_idx in range(start, end + 1):
+ if r_idx < len(all_rows):
+ val = self._get_cell(all_rows[r_idx], col)
+ if val is not None:
+ total += val
+ resolved = resolved.replace(match.group(0), str(total))
+
+ # Handle cell(r, c) references
+ cell_pattern = re.compile(r"cell\(\s*(\d+)\s*,\s*(\d+)\s*\)")
+ for match in cell_pattern.finditer(resolved):
+ r = int(match.group(1))
+ c = int(match.group(2))
+ if r < len(all_rows):
+ val = self._get_cell(all_rows[r], c)
+ if val is not None:
+ resolved = resolved.replace(match.group(0), str(val))
+ else:
+ return None
+ else:
+ return None
+
+ # Replace colN references with values from the current row.
+ # Use re.sub with word boundaries to avoid col1 corrupting col12.
+ _failed = False
+
+ def _col_replacer(m: re.Match[str]) -> str:
+ nonlocal _failed
+ col_idx = int(m.group(1))
+ val = self._get_cell(row, col_idx)
+ if val is None:
+ _failed = True
+ return m.group(0)
+ return str(val)
+
+ resolved = re.sub(r"\bcol(\d+)\b", _col_replacer, resolved)
+ if _failed:
+ return None
+
+ # Evaluate the resulting arithmetic expression safely
+ return self._safe_eval(resolved)
+
+ def _safe_eval(self, expr: str) -> Decimal | None:
+ """
+ Evaluate a simple arithmetic expression containing only
+ numbers and +, -, *, / operators. Respects standard operator
+ precedence (* and / bind tighter than + and -). No eval().
+ """
+ try:
+ raw = re.findall(r"\d+(?:\.\d+)?|[+\-*/]", expr.strip())
+ if not raw:
+ return None
+
+ # Build (values, ops) lists, merging a leading '-' or an
+ # operator-adjacent '-' into the next number token.
+ values: list[Decimal] = []
+ ops: list[str] = []
+ i = 0
+ while i < len(raw):
+ tok = raw[i]
+ if tok in "+-*/" and not values and tok == "-":
+ # Leading negative: merge with next number
+ i += 1
+ if i >= len(raw):
+ return None
+ values.append(Decimal("-" + raw[i]))
+ elif tok in "+-*/":
+ # Operator followed by '-' → negative operand
+ if (
+ tok in "+-*/"
+ and i + 1 < len(raw)
+ and raw[i + 1] == "-"
+ and i + 2 < len(raw)
+ and raw[i + 2] not in "+-*/"
+ ):
+ ops.append(tok)
+ values.append(Decimal("-" + raw[i + 2]))
+ i += 2 # skip the '-' and the number
+ else:
+ ops.append(tok)
+ else:
+ values.append(Decimal(tok))
+ i += 1
+
+ if not values:
+ return None
+
+ # Pass 1: evaluate * and /
+ j = 0
+ while j < len(ops):
+ if ops[j] in ("*", "/"):
+ if ops[j] == "*":
+ values[j] = values[j] * values[j + 1]
+ else:
+ if values[j + 1] == 0:
+ return None
+ values[j] = values[j] / values[j + 1]
+ values.pop(j + 1)
+ ops.pop(j)
+ else:
+ j += 1
+
+ # Pass 2: evaluate + and -
+ result = values[0]
+ for j, op in enumerate(ops):
+ if op == "+":
+ result += values[j + 1]
+ elif op == "-":
+ result -= values[j + 1]
+
+ return result
+ except (InvalidOperation, IndexError, ValueError):
+ return None
+
+ # ------------------------------------------------------------------
+ # Helpers
+ # ------------------------------------------------------------------
+
+ @staticmethod
+ def _parse_col_ref(ref: str) -> int | None:
+ match = re.match(r"col(\d+)", ref.strip())
+ return int(match.group(1)) if match else None
+
+ @staticmethod
+ def _get_cell(row: list[str], col: int) -> Decimal | None:
+ if col >= len(row):
+ return None
+ return _to_decimal(row[col])
diff --git a/engine/src/stirling/agents/orchestrator.py b/engine/src/stirling/agents/orchestrator.py
index 9b58b4540..3ddefcf43 100644
--- a/engine/src/stirling/agents/orchestrator.py
+++ b/engine/src/stirling/agents/orchestrator.py
@@ -21,8 +21,11 @@ from stirling.contracts import (
PdfQuestionRequest,
PdfQuestionResponse,
SupportedCapability,
+ ToolOperationStep,
UnsupportedCapabilityResponse,
)
+from stirling.contracts.pdf_edit import EditPlanResponse
+from stirling.models.agent_tool_models import AgentToolId, MathAuditorAgentParams
from stirling.services import AppRuntime
@@ -53,6 +56,14 @@ class OrchestratorAgent:
name="delegate_user_spec",
description="Delegate requests to create or revise a user agent spec and return the draft result.",
),
+ ToolOutput(
+ self.math_auditor_agent,
+ name="math_auditor_agent",
+ description=(
+ "Delegate requests to check arithmetic, validate table totals, "
+ "audit financial calculations, or verify mathematical accuracy in PDFs."
+ ),
+ ),
ToolOutput(
self.unsupported_capability,
name="unsupported_capability",
@@ -66,6 +77,8 @@ class OrchestratorAgent:
"Use delegate_pdf_edit for requested PDF modifications. "
"Use delegate_pdf_question for questions about PDF contents. "
"Use delegate_user_spec for requests to create or define an agent spec. "
+ "Use math_auditor_agent for requests to check arithmetic, validate "
+ "table totals, audit financial calculations, or verify math in PDFs. "
"Use unsupported_capability only when none of the other outputs fit."
),
model_settings=runtime.fast_model_settings,
@@ -93,6 +106,7 @@ class OrchestratorAgent:
SupportedCapability.ORCHESTRATE
| SupportedCapability.AGENT_REVISE
| SupportedCapability.AGENT_NEXT_ACTION
+ | SupportedCapability.MATH_AUDITOR_AGENT
):
raise ValueError(f"Cannot resume orchestrator with capability: {capability}")
case _ as unreachable:
@@ -123,6 +137,17 @@ class OrchestratorAgent:
async def _run_agent_draft(self, request: OrchestratorRequest) -> AgentDraftWorkflowResponse:
return await UserSpecAgent(self.runtime).draft(AgentDraftRequest(user_message=request.user_message))
+ async def math_auditor_agent(self, ctx: RunContext[OrchestratorDeps]) -> EditPlanResponse:
+ return EditPlanResponse(
+ summary="Validate mathematical calculations in the document.",
+ steps=[
+ ToolOperationStep(
+ tool=AgentToolId.MATH_AUDITOR_AGENT,
+ parameters=MathAuditorAgentParams(),
+ )
+ ],
+ )
+
async def unsupported_capability(
self,
ctx: RunContext[OrchestratorDeps],
diff --git a/engine/src/stirling/api/app.py b/engine/src/stirling/api/app.py
index 76a915001..2afef6208 100644
--- a/engine/src/stirling/api/app.py
+++ b/engine/src/stirling/api/app.py
@@ -8,10 +8,12 @@ from pydantic_ai import Agent
from pydantic_ai.models.instrumented import InstrumentationSettings
from stirling.agents import ExecutionPlanningAgent, OrchestratorAgent, PdfEditAgent, PdfQuestionAgent, UserSpecAgent
+from stirling.agents.ledger import MathAuditorAgent
from stirling.api.middleware import UserIdMiddleware
from stirling.api.routes import (
agent_draft_router,
execution_router,
+ ledger_router,
orchestrator_router,
pdf_edit_router,
pdf_question_router,
@@ -40,6 +42,7 @@ async def lifespan(fast_api: FastAPI):
fast_api.state.pdf_question_agent = PdfQuestionAgent(runtime)
fast_api.state.user_spec_agent = UserSpecAgent(runtime)
fast_api.state.execution_planning_agent = ExecutionPlanningAgent(runtime)
+ fast_api.state.math_auditor_agent = MathAuditorAgent(runtime)
tracer_provider = setup_posthog_tracking(settings)
if tracer_provider:
Agent.instrument_all(InstrumentationSettings(tracer_provider=tracer_provider))
@@ -55,6 +58,7 @@ app.include_router(pdf_edit_router)
app.include_router(pdf_question_router)
app.include_router(agent_draft_router)
app.include_router(execution_router)
+app.include_router(ledger_router)
@app.get("/health", response_model=HealthResponse)
diff --git a/engine/src/stirling/api/dependencies.py b/engine/src/stirling/api/dependencies.py
index c25e488d1..7101f4274 100644
--- a/engine/src/stirling/api/dependencies.py
+++ b/engine/src/stirling/api/dependencies.py
@@ -3,6 +3,7 @@ from __future__ import annotations
from fastapi import Request
from stirling.agents import ExecutionPlanningAgent, OrchestratorAgent, PdfEditAgent, PdfQuestionAgent, UserSpecAgent
+from stirling.agents.ledger import MathAuditorAgent
from stirling.services import AppRuntime
@@ -28,3 +29,7 @@ def get_user_spec_agent(request: Request) -> UserSpecAgent:
def get_execution_planning_agent(request: Request) -> ExecutionPlanningAgent:
return request.app.state.execution_planning_agent
+
+
+def get_math_auditor_agent(request: Request) -> MathAuditorAgent:
+ return request.app.state.math_auditor_agent
diff --git a/engine/src/stirling/api/routes/__init__.py b/engine/src/stirling/api/routes/__init__.py
index 3a920ab5e..a4a0acdb9 100644
--- a/engine/src/stirling/api/routes/__init__.py
+++ b/engine/src/stirling/api/routes/__init__.py
@@ -1,5 +1,6 @@
from .agent_drafts import router as agent_draft_router
from .execution import router as execution_router
+from .ledger import router as ledger_router
from .orchestrator import router as orchestrator_router
from .pdf_edit import router as pdf_edit_router
from .pdf_questions import router as pdf_question_router
@@ -7,6 +8,7 @@ from .pdf_questions import router as pdf_question_router
__all__ = [
"agent_draft_router",
"execution_router",
+ "ledger_router",
"orchestrator_router",
"pdf_edit_router",
"pdf_question_router",
diff --git a/engine/src/stirling/api/routes/ledger.py b/engine/src/stirling/api/routes/ledger.py
new file mode 100644
index 000000000..82e220fbf
--- /dev/null
+++ b/engine/src/stirling/api/routes/ledger.py
@@ -0,0 +1,60 @@
+"""
+Math Auditor Agent (mathAuditorAgent) — FastAPI routes.
+
+Two internal endpoints, called only by the Java MathAuditorOrchestrator:
+
+ POST /api/v1/ai/math-auditor-agent/examine
+ Java sends a FolioManifest (cheap page classification).
+ Python returns a Requisition (what Java must extract).
+
+ POST /api/v1/ai/math-auditor-agent/deliberate
+ Java sends Evidence (fulfilled extraction results).
+ Python returns a Verdict directly.
+"""
+
+from __future__ import annotations
+
+import logging
+from decimal import Decimal, InvalidOperation
+from typing import Annotated
+
+from fastapi import APIRouter, Depends, HTTPException, Query
+
+from stirling.agents.ledger import MathAuditorAgent
+from stirling.api.dependencies import get_math_auditor_agent
+from stirling.contracts.ledger import (
+ Evidence,
+ FolioManifest,
+ Requisition,
+ Verdict,
+)
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/api/v1/ai/math-auditor-agent", tags=["math-auditor-agent"])
+
+
+@router.post("/examine", response_model=Requisition)
+async def examine_endpoint(
+ manifest: FolioManifest,
+ agent: Annotated[MathAuditorAgent, Depends(get_math_auditor_agent)],
+) -> Requisition:
+ """Round 1: Java presents a FolioManifest; Python declares its Requisition."""
+ return await agent.examine(manifest)
+
+
+@router.post("/deliberate", response_model=Verdict)
+async def deliberate_endpoint(
+ evidence: Evidence,
+ agent: Annotated[MathAuditorAgent, Depends(get_math_auditor_agent)],
+ tolerance: str = Query(default="0.01"),
+) -> Verdict:
+ """Round 2: Java presents fulfilled Evidence; Python returns a Verdict."""
+ try:
+ tol = Decimal(tolerance)
+ if tol < 0:
+ raise HTTPException(status_code=400, detail="tolerance must be non-negative")
+ except InvalidOperation:
+ raise HTTPException(status_code=400, detail=f"Invalid tolerance value: {tolerance!r}")
+
+ return await agent.audit(evidence, tol)
diff --git a/engine/src/stirling/config/settings.py b/engine/src/stirling/config/settings.py
index 92fcf4795..0844c3ff4 100644
--- a/engine/src/stirling/config/settings.py
+++ b/engine/src/stirling/config/settings.py
@@ -1,5 +1,7 @@
from __future__ import annotations
+import logging
+import logging.handlers
from functools import lru_cache
from pathlib import Path
@@ -19,12 +21,44 @@ class AppSettings(BaseSettings):
smart_model_max_tokens: int = Field(validation_alias="STIRLING_SMART_MODEL_MAX_TOKENS")
fast_model_max_tokens: int = Field(validation_alias="STIRLING_FAST_MODEL_MAX_TOKENS")
+ log_level: str = Field(default="INFO", validation_alias="STIRLING_LOG_LEVEL")
+ log_file: str = Field(default="", validation_alias="STIRLING_LOG_FILE")
+
posthog_enabled: bool = Field(validation_alias="STIRLING_POSTHOG_ENABLED")
posthog_api_key: str = Field(validation_alias="STIRLING_POSTHOG_API_KEY")
posthog_host: str = Field(validation_alias="STIRLING_POSTHOG_HOST")
+def _configure_logging(level_name: str, log_file: str) -> None:
+ """Configure the ``stirling`` logger hierarchy."""
+ level = logging.getLevelNamesMapping().get(level_name.upper())
+ if level is None:
+ logging.getLogger("stirling").warning(
+ "Unknown STIRLING_LOG_LEVEL %r, defaulting to INFO",
+ level_name,
+ )
+ level = logging.INFO
+
+ root = logging.getLogger("stirling")
+ root.setLevel(level)
+
+ if log_file:
+ log_path = Path(log_file)
+ log_path.parent.mkdir(parents=True, exist_ok=True)
+ fh = logging.handlers.TimedRotatingFileHandler(
+ log_path,
+ when="midnight",
+ backupCount=1,
+ encoding="utf-8",
+ )
+ fh.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s [%(funcName)s] %(message)s"))
+ fh.setLevel(level)
+ root.addHandler(fh)
+
+
@lru_cache(maxsize=1)
def load_settings() -> AppSettings:
load_dotenv(ENV_FILE)
- return AppSettings.model_validate({})
+ settings = AppSettings.model_validate({})
+ _configure_logging(settings.log_level, settings.log_file)
+ return settings
diff --git a/engine/src/stirling/contracts/__init__.py b/engine/src/stirling/contracts/__init__.py
index 0593f4dd2..3f0b1fb87 100644
--- a/engine/src/stirling/contracts/__init__.py
+++ b/engine/src/stirling/contracts/__init__.py
@@ -29,6 +29,17 @@ from .execution import (
ToolCallExecutionAction,
)
from .health import HealthResponse
+from .ledger import (
+ Discrepancy,
+ DiscrepancyKind,
+ Evidence,
+ Folio,
+ FolioManifest,
+ FolioType,
+ Requisition,
+ Severity,
+ Verdict,
+)
from .orchestrator import (
ExtractedTextArtifact,
OrchestratorRequest,
@@ -53,7 +64,6 @@ from .pdf_questions import (
)
__all__ = [
- "ArtifactKind",
"AgentDraft",
"AgentDraftRequest",
"AgentDraftResponse",
@@ -65,35 +75,45 @@ __all__ = [
"AgentSpec",
"AgentSpecStep",
"AiToolAgentStep",
+ "ArtifactKind",
"CannotContinueExecutionAction",
- "ConversationMessage",
- "ExtractedFileText",
"CompletedExecutionAction",
+ "ConversationMessage",
+ "Discrepancy",
+ "DiscrepancyKind",
"EditCannotDoResponse",
"EditClarificationRequest",
"EditPlanResponse",
+ "Evidence",
"ExecutionContext",
"ExecutionStepResult",
+ "ExtractedFileText",
+ "ExtractedTextArtifact",
+ "Folio",
+ "FolioManifest",
+ "FolioType",
"HealthResponse",
"NeedContentFileRequest",
"NextExecutionAction",
- "ExtractedTextArtifact",
"OrchestratorRequest",
"OrchestratorResponse",
+ "PdfContentType",
"PdfEditRequest",
"PdfEditResponse",
"PdfQuestionAnswerResponse",
- "PdfQuestionNotFoundResponse",
- "PdfContentType",
"PdfQuestionNeedContentResponse",
+ "PdfQuestionNotFoundResponse",
"PdfQuestionRequest",
"PdfQuestionResponse",
"PdfTextSelection",
+ "Requisition",
+ "Severity",
"StepKind",
"SupportedCapability",
- "ToolOperationStep",
"ToolCallExecutionAction",
- "WorkflowOutcome",
+ "ToolOperationStep",
"UnsupportedCapabilityResponse",
+ "Verdict",
"WorkflowArtifact",
+ "WorkflowOutcome",
]
diff --git a/engine/src/stirling/contracts/common.py b/engine/src/stirling/contracts/common.py
index 7fd2af9aa..20c467002 100644
--- a/engine/src/stirling/contracts/common.py
+++ b/engine/src/stirling/contracts/common.py
@@ -1,11 +1,12 @@
from __future__ import annotations
from enum import StrEnum
-from typing import Literal
+from typing import Literal, assert_never
from pydantic import Field, model_validator
-from stirling.models import OPERATIONS, ApiModel, OperationId, ParamToolModel
+from stirling.models import OPERATIONS, ApiModel, OperationId
+from stirling.models.agent_tool_models import AGENT_OPERATIONS, AgentToolId, AnyParamModel, AnyToolId
class PdfContentType(StrEnum):
@@ -82,6 +83,7 @@ class SupportedCapability(StrEnum):
AGENT_DRAFT = "agent_draft"
AGENT_REVISE = "agent_revise"
AGENT_NEXT_ACTION = "agent_next_action"
+ MATH_AUDITOR_AGENT = "math_auditor_agent"
class ConversationMessage(ApiModel):
@@ -101,14 +103,19 @@ class ExtractedFileText(ApiModel):
class ToolOperationStep(ApiModel):
kind: Literal[StepKind.TOOL] = StepKind.TOOL
- tool: OperationId
- parameters: ParamToolModel
+ tool: AnyToolId
+ parameters: AnyParamModel
@model_validator(mode="after")
def validate_tool_parameter_pairing(self) -> ToolOperationStep:
- expected_type = OPERATIONS[self.tool]
+ if isinstance(self.tool, AgentToolId):
+ expected_type = AGENT_OPERATIONS[self.tool]
+ elif isinstance(self.tool, OperationId):
+ expected_type = OPERATIONS[self.tool]
+ else:
+ assert_never(self.tool)
+
if not isinstance(self.parameters, expected_type):
actual_type = type(self.parameters).__name__
- expected_type_name = expected_type.__name__
- raise ValueError(f"Parameters for tool {self.tool.value} must be {expected_type_name}, got {actual_type}.")
+ raise ValueError(f"Parameters for tool {self.tool} must be {expected_type.__name__}, got {actual_type}.")
return self
diff --git a/engine/src/stirling/contracts/ledger.py b/engine/src/stirling/contracts/ledger.py
new file mode 100644
index 000000000..bc31b960e
--- /dev/null
+++ b/engine/src/stirling/contracts/ledger.py
@@ -0,0 +1,196 @@
+"""
+Ledger Auditor — shared models for the Java-Python protocol.
+
+Every struct that crosses the wire lives here so the contract is
+impossible to miss or partially implement.
+"""
+
+from __future__ import annotations
+
+from enum import StrEnum
+from typing import Literal
+
+from pydantic import Field
+
+from stirling.models import ApiModel
+
+# ---------------------------------------------------------------------------
+# Page classification — Java's side of the conversation
+# ---------------------------------------------------------------------------
+
+
+class FolioType(StrEnum):
+ """How Java classifies each page after a cheap PDFBox scan.
+
+ Java counterpart: FolioType.java - values must stay in sync.
+ """
+
+ TEXT = "text" # selectable text layer present
+ IMAGE = "image" # image-only, will need OCR
+ MIXED = "mixed" # partial text layer + embedded images
+
+
+class FolioManifest(ApiModel):
+ """
+ Java's opening move: a fast, cheap page classification with no OCR or
+ table extraction — just PDFBox character counts and image detection.
+
+ Python inspects this and returns a Requisition declaring what it needs.
+ """
+
+ session_id: str = Field(description="Opaque handle Java uses to find the PDF on disk.")
+ page_count: int = Field(ge=1)
+ folio_types: list[FolioType] = Field(description="One entry per page (0-indexed). len(folio_types) == page_count.")
+ round: int = Field(default=1, ge=1, le=3, description="Which negotiation round this is.")
+
+
+# ---------------------------------------------------------------------------
+# Requisition — Python's declaration of what it needs
+# ---------------------------------------------------------------------------
+
+
+class Requisition(ApiModel):
+ """
+ Python's reply to a FolioManifest: a precise shopping list of what Java
+ must extract before the auditor can form an opinion.
+
+ Java fulfils this and sends back an Evidence payload.
+ """
+
+ type: Literal["requisition"] = "requisition"
+ need_text: list[int] = Field(
+ default_factory=list,
+ description="0-indexed page numbers. Java runs PDFBox text extraction on these.",
+ )
+ need_tables: list[int] = Field(
+ default_factory=list,
+ description="0-indexed page numbers. Java runs Tabula CSV extraction on these.",
+ )
+ need_ocr: list[int] = Field(
+ default_factory=list,
+ description="0-indexed page numbers. Java runs OCRmyPDF on these.",
+ )
+ rationale: str = Field(description="Plain-language reason, written for log readability, not the client.")
+
+
+# ---------------------------------------------------------------------------
+# Evidence — Java's fulfilment of a Requisition
+# ---------------------------------------------------------------------------
+
+
+class Folio(ApiModel):
+ """
+ One page's worth of extracted content — whatever Java was able to provide
+ in response to the Requisition for that page.
+ """
+
+ page: int = Field(ge=0, description="0-indexed page number.")
+ text: str | None = Field(default=None, description="PDFBox plain-text extraction.")
+ tables: list[str] | None = Field(default=None, description="Tabula CSV strings, one per table found on the page.")
+ ocr_text: str | None = Field(default=None, description="OCRmyPDF output text.")
+ ocr_confidence: float | None = Field(
+ default=None, ge=0.0, le=1.0, description="Mean character confidence from OCRmyPDF."
+ )
+
+ @property
+ def readable_text(self) -> str:
+ """Best available text for this folio — OCR wins over digital when present."""
+ return self.ocr_text or self.text or ""
+
+
+class Evidence(ApiModel):
+ """
+ Java's fulfilment package: the extracted content Python asked for.
+ Java may also set final_round=True on the last allowable round to signal
+ that the auditor must return a Verdict regardless of remaining questions.
+ """
+
+ session_id: str
+ folios: list[Folio]
+ round: int = Field(ge=1, le=3)
+ final_round: bool = Field(
+ default=False,
+ description="When True, Java will not honour further Requisitions. "
+ "The auditor must return a Verdict this round.",
+ )
+ unauditable_pages: list[int] = Field(
+ default_factory=list,
+ description=(
+ "Pages that were requested in the Requisition but could not be fulfilled — "
+ "e.g. OCR was asked for but is not wired. The Auditor echoes these into "
+ "Verdict.unauditable_pages so the client knows coverage is incomplete."
+ ),
+ )
+
+
+# ---------------------------------------------------------------------------
+# Findings — what the auditor discovers
+# ---------------------------------------------------------------------------
+
+
+class DiscrepancyKind(StrEnum):
+ """Java counterpart: DiscrepancyKind.java - values must stay in sync."""
+
+ TALLY = "tally" # a row/column sum is wrong
+ ARITHMETIC = "arithmetic" # an inline calculation is wrong
+ CONSISTENCY = "consistency" # the same figure is stated differently elsewhere
+ STATEMENT = "statement" # a prose claim contradicts the numbers
+
+
+class Severity(StrEnum):
+ """Java counterpart: AuditSeverity.java - values must stay in sync."""
+
+ ERROR = "error" # definite arithmetic mistake
+ WARNING = "warning" # possible rounding or ambiguity
+
+
+class Discrepancy(ApiModel):
+ """A single mathematical error found in the document."""
+
+ page: int = Field(ge=0)
+ kind: DiscrepancyKind
+ severity: Severity
+ description: str = Field(description="Human-readable explanation of the error.")
+ stated: str = Field(description="The value as it appears in the document.")
+ expected: str = Field(description="The value the auditor calculated.")
+ context: str = Field(
+ default="",
+ description="Surrounding text or table fragment for traceability.",
+ )
+
+
+# ---------------------------------------------------------------------------
+# Verdict — the final report
+# ---------------------------------------------------------------------------
+
+
+class Verdict(ApiModel):
+ """
+ The auditor's final opinion on the document's mathematical integrity.
+ Returned to Java as the terminal message in the negotiation.
+ """
+
+ type: Literal["verdict"] = "verdict"
+ session_id: str
+ discrepancies: list[Discrepancy] = Field(default_factory=list)
+ pages_examined: list[int] = Field(description="0-indexed page numbers the auditor actually inspected.")
+ rounds_taken: int = Field(ge=1, le=3)
+ summary: str = Field(description="One or two sentences summarising the audit outcome for the client.")
+ clean: bool = Field(description="True iff no errors were found (warnings are tolerated).")
+ unauditable_pages: list[int] = Field(
+ default_factory=list,
+ description=(
+ "0-indexed pages that could not be audited — typically because OCR was "
+ "requested but is not yet wired. Java populates this by omitting the folio "
+ "and the Auditor echoes the page number here so the client knows coverage "
+ "is incomplete."
+ ),
+ )
+
+ @property
+ def error_count(self) -> int:
+ return sum(1 for d in self.discrepancies if d.severity == Severity.ERROR)
+
+ @property
+ def warning_count(self) -> int:
+ return sum(1 for d in self.discrepancies if d.severity == Severity.WARNING)
diff --git a/engine/src/stirling/logging.py b/engine/src/stirling/logging.py
new file mode 100644
index 000000000..a0a6fec20
--- /dev/null
+++ b/engine/src/stirling/logging.py
@@ -0,0 +1,22 @@
+"""Shared logging utilities for the Stirling AI engine."""
+
+from __future__ import annotations
+
+import json
+
+
+class Pretty:
+ """Lazy JSON formatter — only serialises when ``str()`` is called.
+
+ Designed for use with ``logging``'s ``%s`` formatting so that the
+ JSON serialisation is skipped entirely when the log message is
+ never emitted.
+ """
+
+ __slots__ = ("_obj",)
+
+ def __init__(self, obj: object) -> None:
+ self._obj = obj
+
+ def __str__(self) -> str:
+ return json.dumps(self._obj, indent=2, default=str, ensure_ascii=True)
diff --git a/engine/src/stirling/models/agent_tool_models.py b/engine/src/stirling/models/agent_tool_models.py
new file mode 100644
index 000000000..fcef18ad4
--- /dev/null
+++ b/engine/src/stirling/models/agent_tool_models.py
@@ -0,0 +1,30 @@
+"""Agent tool IDs, parameter models, and registry.
+
+tool_models.py is auto-generated from the frontend. This file is its
+manually-maintained counterpart for tools backed by AI agent pipelines.
+"""
+
+from __future__ import annotations
+
+from enum import StrEnum
+
+from stirling.models.base import ApiModel
+from stirling.models.tool_models import OperationId, ParamToolModel
+
+
+class AgentToolId(StrEnum):
+ MATH_AUDITOR_AGENT = "mathAuditorAgent"
+
+
+class MathAuditorAgentParams(ApiModel):
+ tolerance: str = "0.01"
+
+
+type AgentParamModel = MathAuditorAgentParams
+
+type AnyToolId = OperationId | AgentToolId
+type AnyParamModel = ParamToolModel | AgentParamModel
+
+AGENT_OPERATIONS: dict[AgentToolId, type[AgentParamModel]] = {
+ AgentToolId.MATH_AUDITOR_AGENT: MathAuditorAgentParams,
+}
diff --git a/engine/tests/ledger/__init__.py b/engine/tests/ledger/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/engine/tests/ledger/test_arithmetic_scanner.py b/engine/tests/ledger/test_arithmetic_scanner.py
new file mode 100644
index 000000000..00fe4c68f
--- /dev/null
+++ b/engine/tests/ledger/test_arithmetic_scanner.py
@@ -0,0 +1,133 @@
+"""
+ArithmeticScanner — unit tests.
+
+Tests cover the two inline arithmetic patterns the scanner targets:
+ 1. Equals expressions: A + B = C
+ 2. Total-then-addends: Total: C (A + B)
+"""
+
+from decimal import Decimal
+
+import pytest
+
+from stirling.agents.ledger.validators.arithmetic import ArithmeticScanner
+
+
+@pytest.fixture
+def scanner() -> ArithmeticScanner:
+ return ArithmeticScanner(tolerance=Decimal("0.01"))
+
+
+# ---------------------------------------------------------------------------
+# Equals expressions: A + B + C = D
+# ---------------------------------------------------------------------------
+
+
+def test_correct_equals_expression(scanner: ArithmeticScanner) -> None:
+ """A correct sum should produce no findings."""
+ text = "The total cost is 100 + 200 + 150 = 450."
+ assert scanner.scan(page=0, text=text) == []
+
+
+def test_wrong_equals_expression(scanner: ArithmeticScanner) -> None:
+ """An incorrect sum should produce one error discrepancy."""
+ text = "Revenue: 500 + 300 = 900" # should be 800
+ discrepancies = scanner.scan(page=3, text=text)
+ assert len(discrepancies) == 1
+ d = discrepancies[0]
+ assert d.page == 3
+ assert d.kind == "arithmetic"
+ assert d.severity == "error"
+ assert d.stated == "900"
+ assert d.expected == "800"
+
+
+def test_subtraction_expression(scanner: ArithmeticScanner) -> None:
+ """Subtraction in expressions should be evaluated correctly."""
+ text = "Net: 1000 - 250 = 750"
+ assert scanner.scan(page=0, text=text) == []
+
+
+def test_wrong_subtraction(scanner: ArithmeticScanner) -> None:
+ text = "Net: 1000 - 250 = 800" # should be 750
+ discrepancies = scanner.scan(page=0, text=text)
+ assert len(discrepancies) == 1
+ assert discrepancies[0].expected == "750"
+
+
+def test_currency_symbols_stripped(scanner: ArithmeticScanner) -> None:
+ """Currency symbols and thousand separators must not break parsing."""
+ text = "Total: £1,000 + £500 = £1,500"
+ assert scanner.scan(page=0, text=text) == []
+
+
+def test_multiple_expressions_in_text(scanner: ArithmeticScanner) -> None:
+ """Multiple expressions in the same text should each be evaluated."""
+ text = (
+ "Q1 revenue: 100 + 200 = 300. "
+ "Q2 revenue: 150 + 100 = 350. " # wrong: should be 250
+ )
+ discrepancies = scanner.scan(page=0, text=text)
+ assert len(discrepancies) == 1
+ assert discrepancies[0].expected == "250"
+
+
+# ---------------------------------------------------------------------------
+# Total-then-addends: "Total: X (A + B + C)"
+# ---------------------------------------------------------------------------
+
+
+def test_correct_total_then_addends(scanner: ArithmeticScanner) -> None:
+ text = "Grand Total: 750 (300 + 250 + 200)"
+ assert scanner.scan(page=0, text=text) == []
+
+
+def test_wrong_total_then_addends(scanner: ArithmeticScanner) -> None:
+ text = "Grand Total: 900 (300 + 250 + 200)" # addends sum to 750
+ discrepancies = scanner.scan(page=0, text=text)
+ assert len(discrepancies) == 1
+ d = discrepancies[0]
+ assert d.stated == "900"
+ assert d.expected == "750"
+
+
+def test_total_keyword_variations(scanner: ArithmeticScanner) -> None:
+ """The pattern must work for 'Sum', 'Subtotal', 'Grand Total' etc."""
+ cases = [
+ ("Sum: 600 (200 + 200 + 200)", True),
+ ("Subtotal: 600 (200 + 200 + 200)", True),
+ ("Total: 999 (200 + 200 + 200)", False), # wrong
+ ]
+ for text, should_be_clean in cases:
+ result = scanner.scan(page=0, text=text)
+ if should_be_clean:
+ assert result == [], f"Expected clean for: {text!r}"
+ else:
+ assert len(result) == 1, f"Expected error for: {text!r}"
+
+
+# ---------------------------------------------------------------------------
+# Edge cases
+# ---------------------------------------------------------------------------
+
+
+def test_no_expressions_in_text(scanner: ArithmeticScanner) -> None:
+ text = "This paragraph discusses revenue trends but contains no arithmetic."
+ assert scanner.scan(page=0, text=text) == []
+
+
+def test_empty_text(scanner: ArithmeticScanner) -> None:
+ assert scanner.scan(page=0, text="") == []
+
+
+def test_leading_negative_expression(scanner: ArithmeticScanner) -> None:
+ """Expressions starting with a negative number should evaluate correctly."""
+ text = "Adjustment: -100 + 250 = 150"
+ assert scanner.scan(page=0, text=text) == []
+
+
+def test_leading_negative_wrong(scanner: ArithmeticScanner) -> None:
+ text = "Adjustment: -100 + 250 = 200" # should be 150
+ discrepancies = scanner.scan(page=0, text=text)
+ assert len(discrepancies) == 1
+ assert discrepancies[0].expected == "150"
diff --git a/engine/tests/ledger/test_figure_tracker.py b/engine/tests/ledger/test_figure_tracker.py
new file mode 100644
index 000000000..fec305062
--- /dev/null
+++ b/engine/tests/ledger/test_figure_tracker.py
@@ -0,0 +1,100 @@
+"""
+FigureTracker — unit tests.
+
+Tests that named figures are correctly accumulated and that conflicting
+sightings (same label, different value) are surfaced as consistency warnings.
+"""
+
+from decimal import Decimal
+
+import pytest
+
+from stirling.agents.ledger.validators.figures import FigureTracker
+
+
+@pytest.fixture
+def tracker() -> FigureTracker:
+ return FigureTracker(tolerance=Decimal("0.01"))
+
+
+# ---------------------------------------------------------------------------
+# No conflicts
+# ---------------------------------------------------------------------------
+
+
+def test_no_conflicts_single_figure(tracker: FigureTracker) -> None:
+ tracker.record("Net Profit", Decimal("1200.00"), page=3, raw="£1,200.00")
+ assert tracker.conflicts() == []
+
+
+def test_no_conflicts_consistent_figure(tracker: FigureTracker) -> None:
+ """The same figure cited identically on two pages must not raise a conflict."""
+ tracker.record("Total Revenue", Decimal("5000.00"), page=1, raw="£5,000")
+ tracker.record("Total Revenue", Decimal("5000.00"), page=8, raw="£5,000")
+ assert tracker.conflicts() == []
+
+
+def test_no_conflicts_within_tolerance(tracker: FigureTracker) -> None:
+ """A difference within tolerance must not be flagged."""
+ tracker.record("VAT", Decimal("100.00"), page=2, raw="£100.00")
+ tracker.record("VAT", Decimal("100.005"), page=5, raw="£100.005")
+ assert tracker.conflicts() == []
+
+
+# ---------------------------------------------------------------------------
+# Conflicts
+# ---------------------------------------------------------------------------
+
+
+def test_conflict_different_values(tracker: FigureTracker) -> None:
+ """Same label, different value on two pages → one consistency warning."""
+ tracker.record("Net Profit", Decimal("1200.00"), page=3, raw="£1,200")
+ tracker.record("Net Profit", Decimal("1250.00"), page=7, raw="£1,250")
+ conflicts = tracker.conflicts()
+ assert len(conflicts) == 1
+ d = conflicts[0]
+ assert d.kind == "consistency"
+ assert d.severity == "warning"
+ assert d.page == 7 # later occurrence is flagged
+
+
+def test_conflict_three_sightings_two_values(tracker: FigureTracker) -> None:
+ """Three sightings where one differs from canonical → 1 conflict."""
+ tracker.record("Revenue", Decimal("1000"), page=1, raw="£1,000")
+ tracker.record("Revenue", Decimal("1000"), page=3, raw="£1,000")
+ tracker.record("Revenue", Decimal("999"), page=5, raw="£999")
+ conflicts = tracker.conflicts()
+ # Canonical=p1 (1000). p3 matches, p5 differs → 1 conflict
+ assert len(conflicts) == 1
+ assert conflicts[0].page == 5
+
+
+# ---------------------------------------------------------------------------
+# Label normalisation
+# ---------------------------------------------------------------------------
+
+
+def test_label_normalisation_case_insensitive(tracker: FigureTracker) -> None:
+ """Labels must be compared case-insensitively."""
+ tracker.record("Net Profit", Decimal("1200"), page=2, raw="1200")
+ tracker.record("net profit", Decimal("1100"), page=4, raw="1100")
+ assert len(tracker.conflicts()) == 1
+
+
+def test_label_normalisation_punctuation(tracker: FigureTracker) -> None:
+ """Colons and dashes in labels must be normalised before comparison."""
+ tracker.record("Total Revenue:", Decimal("5000"), page=1, raw="5000")
+ tracker.record("Total Revenue —", Decimal("4000"), page=9, raw="4000")
+ assert len(tracker.conflicts()) == 1
+
+
+# ---------------------------------------------------------------------------
+# Entry count
+# ---------------------------------------------------------------------------
+
+
+def test_entry_count(tracker: FigureTracker) -> None:
+ tracker.record("A", Decimal("1"), page=0, raw="1")
+ tracker.record("A", Decimal("1"), page=1, raw="1")
+ tracker.record("B", Decimal("2"), page=2, raw="2")
+ assert tracker.entry_count == 3
diff --git a/engine/tests/ledger/test_formula_evaluator.py b/engine/tests/ledger/test_formula_evaluator.py
new file mode 100644
index 000000000..1cf5a254d
--- /dev/null
+++ b/engine/tests/ledger/test_formula_evaluator.py
@@ -0,0 +1,205 @@
+"""
+FormulaEvaluator — unit tests.
+
+Tests cover:
+ - Operator precedence (* / before + -)
+ - Column reference replacement (colN with word boundaries)
+ - Negative number handling
+ - each_row, column_total, and single_cell scopes
+"""
+
+from decimal import Decimal
+
+import pytest
+
+from stirling.agents.ledger.validators.formula import FormulaEvaluator
+
+
+@pytest.fixture
+def evaluator() -> FormulaEvaluator:
+ return FormulaEvaluator(tolerance=Decimal("0.01"))
+
+
+# ---------------------------------------------------------------------------
+# _safe_eval — operator precedence
+# ---------------------------------------------------------------------------
+
+
+def test_safe_eval_addition(evaluator: FormulaEvaluator) -> None:
+ assert evaluator._safe_eval("2 + 3") == Decimal("5")
+
+
+def test_safe_eval_multiplication_before_addition(evaluator: FormulaEvaluator) -> None:
+ """2 + 3 * 4 should be 14, not 20."""
+ assert evaluator._safe_eval("2 + 3 * 4") == Decimal("14")
+
+
+def test_safe_eval_division_before_subtraction(evaluator: FormulaEvaluator) -> None:
+ """10 - 6 / 2 should be 7, not 2."""
+ assert evaluator._safe_eval("10 - 6 / 2") == Decimal("7")
+
+
+def test_safe_eval_mixed_precedence(evaluator: FormulaEvaluator) -> None:
+ """1 + 2 * 3 - 4 / 2 should be 1 + 6 - 2 = 5."""
+ assert evaluator._safe_eval("1 + 2 * 3 - 4 / 2") == Decimal("5")
+
+
+def test_safe_eval_all_multiplication(evaluator: FormulaEvaluator) -> None:
+ assert evaluator._safe_eval("2 * 3 * 4") == Decimal("24")
+
+
+def test_safe_eval_division_by_zero(evaluator: FormulaEvaluator) -> None:
+ assert evaluator._safe_eval("10 / 0") is None
+
+
+def test_safe_eval_negative_result(evaluator: FormulaEvaluator) -> None:
+ assert evaluator._safe_eval("3 - 5") == Decimal("-2")
+
+
+def test_safe_eval_leading_negative(evaluator: FormulaEvaluator) -> None:
+ """Expressions starting with a negative number should work."""
+ result = evaluator._safe_eval("-100 + 200")
+ assert result == Decimal("100")
+
+
+def test_safe_eval_empty(evaluator: FormulaEvaluator) -> None:
+ assert evaluator._safe_eval("") is None
+
+
+def test_safe_eval_single_number(evaluator: FormulaEvaluator) -> None:
+ assert evaluator._safe_eval("42") == Decimal("42")
+
+
+def test_safe_eval_decimal_numbers(evaluator: FormulaEvaluator) -> None:
+ result = evaluator._safe_eval("1.5 * 2 + 0.5")
+ assert result == Decimal("3.5")
+
+
+# ---------------------------------------------------------------------------
+# colN replacement — word boundary safety
+# ---------------------------------------------------------------------------
+
+
+def test_col1_does_not_corrupt_col12(evaluator: FormulaEvaluator) -> None:
+ """col1 replacement must not alter col12."""
+ csv = "a,b,c,d,e,f,g,h,i,j,k,l,m\n0,10,0,0,0,0,0,0,0,0,0,0,120\n"
+ # col1=10, col12=120 → col12 - col1 should be 110
+ result = evaluator.evaluate(
+ page=0,
+ table_csv=csv,
+ formula="col0 = col12 - col1",
+ scope="each_row",
+ description="test",
+ )
+ # row 1: col0=0, expected=120-10=110 → discrepancy
+ assert len(result) == 1
+ assert result[0].expected == "110"
+
+
+def test_col_replacement_adjacent_columns(evaluator: FormulaEvaluator) -> None:
+ """col1 and col10 should both be replaced correctly."""
+ csv = "a,b,c,d,e,f,g,h,i,j,k\n55,5,0,0,0,0,0,0,0,0,50\n"
+ # col0=55, col1=5, col10=50 → col1 + col10 = 55
+ result = evaluator.evaluate(
+ page=0,
+ table_csv=csv,
+ formula="col0 = col1 + col10",
+ scope="each_row",
+ description="test",
+ )
+ assert result == [] # 5 + 50 = 55, matches col0
+
+
+# ---------------------------------------------------------------------------
+# each_row scope
+# ---------------------------------------------------------------------------
+
+
+def test_each_row_correct(evaluator: FormulaEvaluator) -> None:
+ csv = "Item,Qty,Price,Total\nWidget,10,5,50\nGadget,3,20,60\n"
+ result = evaluator.evaluate(
+ page=0,
+ table_csv=csv,
+ formula="col3 = col1 * col2",
+ scope="each_row",
+ description="unit price check",
+ )
+ assert result == []
+
+
+def test_each_row_error(evaluator: FormulaEvaluator) -> None:
+ csv = "Item,Qty,Price,Total\nWidget,10,5,50\nGadget,3,20,99\n"
+ result = evaluator.evaluate(
+ page=0,
+ table_csv=csv,
+ formula="col3 = col1 * col2",
+ scope="each_row",
+ description="unit price check",
+ )
+ assert len(result) == 1
+ assert result[0].expected == "60"
+ assert result[0].stated == "99"
+
+
+# ---------------------------------------------------------------------------
+# column_total scope
+# ---------------------------------------------------------------------------
+
+
+def test_column_total_correct(evaluator: FormulaEvaluator) -> None:
+ csv = "Name,Amount\nA,100\nB,200\nTotal,300\n"
+ result = evaluator.evaluate(
+ page=0,
+ table_csv=csv,
+ formula="sum",
+ scope="column_total",
+ description="total check",
+ target_row=3,
+ target_col=1,
+ )
+ assert result == []
+
+
+def test_column_total_error(evaluator: FormulaEvaluator) -> None:
+ csv = "Name,Amount\nA,100\nB,200\nTotal,400\n"
+ result = evaluator.evaluate(
+ page=0,
+ table_csv=csv,
+ formula="sum",
+ scope="column_total",
+ description="total check",
+ target_row=3,
+ target_col=1,
+ )
+ assert len(result) == 1
+ assert result[0].expected == "300"
+
+
+# ---------------------------------------------------------------------------
+# single_cell scope
+# ---------------------------------------------------------------------------
+
+
+def test_single_cell_correct(evaluator: FormulaEvaluator) -> None:
+ csv = "A,B,C\n10,20,30\n5,15,20\n15,35,50\n"
+ result = evaluator.evaluate(
+ page=0,
+ table_csv=csv,
+ formula="cell(3,2) = cell(1,2) + cell(2,2)",
+ scope="single_cell",
+ description="grand total",
+ )
+ assert result == []
+
+
+def test_single_cell_error(evaluator: FormulaEvaluator) -> None:
+ csv = "A,B,C\n10,20,30\n5,15,20\n15,35,99\n"
+ result = evaluator.evaluate(
+ page=0,
+ table_csv=csv,
+ formula="cell(3,2) = cell(1,2) + cell(2,2)",
+ scope="single_cell",
+ description="grand total",
+ )
+ assert len(result) == 1
+ assert result[0].expected == "50"
diff --git a/engine/tests/ledger/test_models.py b/engine/tests/ledger/test_models.py
new file mode 100644
index 000000000..98b0ef9c8
--- /dev/null
+++ b/engine/tests/ledger/test_models.py
@@ -0,0 +1,144 @@
+"""
+Ledger models — unit tests for serialisation and business logic.
+
+These tests confirm the wire contract: models round-trip through JSON
+correctly and their helper properties behave as documented.
+"""
+
+import pytest
+from pydantic import ValidationError
+
+from stirling.contracts.ledger import (
+ Discrepancy,
+ DiscrepancyKind,
+ Evidence,
+ Folio,
+ FolioManifest,
+ FolioType,
+ Requisition,
+ Severity,
+ Verdict,
+)
+
+# ---------------------------------------------------------------------------
+# FolioManifest
+# ---------------------------------------------------------------------------
+
+
+def test_folio_manifest_round_trip() -> None:
+ manifest = FolioManifest(
+ session_id="abc-123",
+ page_count=3,
+ folio_types=[FolioType.TEXT, FolioType.IMAGE, FolioType.MIXED],
+ )
+ reloaded = FolioManifest.model_validate_json(manifest.model_dump_json())
+ assert reloaded == manifest
+
+
+def test_folio_manifest_round_bounds() -> None:
+ with pytest.raises(ValidationError):
+ FolioManifest(session_id="x", page_count=1, folio_types=[FolioType.TEXT], round=0)
+ with pytest.raises(ValidationError):
+ FolioManifest(session_id="x", page_count=1, folio_types=[FolioType.TEXT], round=4)
+
+
+# ---------------------------------------------------------------------------
+# Requisition
+# ---------------------------------------------------------------------------
+
+
+def test_requisition_empty() -> None:
+ req = Requisition(rationale="nothing needed")
+ assert req.need_text == []
+ assert req.need_tables == []
+ assert req.need_ocr == []
+
+
+def test_requisition_type_discriminator() -> None:
+ req = Requisition(need_text=[0, 1], rationale="needs text")
+ assert req.type == "requisition"
+
+
+# ---------------------------------------------------------------------------
+# Folio.readable_text
+# ---------------------------------------------------------------------------
+
+
+def test_folio_readable_text_prefers_ocr() -> None:
+ folio = Folio(page=0, text="digital text", ocr_text="ocr text")
+ assert folio.readable_text == "ocr text"
+
+
+def test_folio_readable_text_falls_back_to_text() -> None:
+ folio = Folio(page=0, text="digital text")
+ assert folio.readable_text == "digital text"
+
+
+def test_folio_readable_text_empty_when_none() -> None:
+ folio = Folio(page=0)
+ assert folio.readable_text == ""
+
+
+# ---------------------------------------------------------------------------
+# Verdict
+# ---------------------------------------------------------------------------
+
+
+def test_verdict_clean_flag() -> None:
+ verdict = Verdict(
+ session_id="s1",
+ discrepancies=[],
+ pages_examined=[0, 1],
+ rounds_taken=2,
+ summary="All figures balance.",
+ clean=True,
+ )
+ assert verdict.error_count == 0
+ assert verdict.warning_count == 0
+ assert verdict.clean is True
+
+
+def test_verdict_error_and_warning_counts() -> None:
+ discrepancies = [
+ Discrepancy(
+ page=0,
+ kind=DiscrepancyKind.TALLY,
+ severity=Severity.ERROR,
+ description="bad sum",
+ stated="100",
+ expected="110",
+ ),
+ Discrepancy(
+ page=1,
+ kind=DiscrepancyKind.CONSISTENCY,
+ severity=Severity.WARNING,
+ description="mismatched figure",
+ stated="500",
+ expected="550",
+ ),
+ ]
+ verdict = Verdict(
+ session_id="s1",
+ discrepancies=discrepancies,
+ pages_examined=[0, 1],
+ rounds_taken=1,
+ summary="Issues found.",
+ clean=False,
+ )
+ assert verdict.error_count == 1
+ assert verdict.warning_count == 1
+
+
+# ---------------------------------------------------------------------------
+# Evidence.final_round
+# ---------------------------------------------------------------------------
+
+
+def test_evidence_final_round() -> None:
+ evidence = Evidence(
+ session_id="s",
+ folios=[Folio(page=0, text="hello")],
+ round=3,
+ final_round=True,
+ )
+ assert evidence.final_round is True
diff --git a/engine/tests/ledger/test_routes.py b/engine/tests/ledger/test_routes.py
new file mode 100644
index 000000000..6e36dd471
--- /dev/null
+++ b/engine/tests/ledger/test_routes.py
@@ -0,0 +1,249 @@
+"""
+Ledger Auditor — FastAPI route tests.
+
+Uses FastAPI's TestClient with dependency overrides. All LLM calls are
+mocked out; these tests exercise HTTP parsing, serialisation, and response
+enveloping only — not the agent's reasoning.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Iterator
+from decimal import Decimal
+
+import pytest
+from fastapi.testclient import TestClient
+
+from stirling.api import app
+from stirling.api.dependencies import get_math_auditor_agent
+from stirling.config import AppSettings, load_settings
+from stirling.contracts.ledger import (
+ Discrepancy,
+ DiscrepancyKind,
+ Evidence,
+ FolioManifest,
+ Requisition,
+ Severity,
+ Verdict,
+)
+
+# ---------------------------------------------------------------------------
+# Stubs
+# ---------------------------------------------------------------------------
+
+
+class StubSettingsProvider:
+ def __call__(self) -> AppSettings:
+ return AppSettings(
+ smart_model_name="test",
+ fast_model_name="test",
+ smart_model_max_tokens=8192,
+ fast_model_max_tokens=2048,
+ posthog_enabled=False,
+ posthog_api_key="",
+ posthog_host="https://eu.i.posthog.com",
+ )
+
+
+class StubLedgerAgent:
+ """Stub that returns canned responses without touching any model."""
+
+ def __init__(
+ self,
+ requisition: Requisition | None = None,
+ verdict: Verdict | None = None,
+ ) -> None:
+ self._requisition = requisition or _stub_requisition()
+ self._verdict = verdict or _stub_verdict()
+ self.examine_calls: list[FolioManifest] = []
+ self.audit_calls: list[tuple[Evidence, Decimal]] = []
+
+ async def examine(self, manifest: FolioManifest) -> Requisition:
+ self.examine_calls.append(manifest)
+ return self._requisition
+
+ async def audit(self, evidence: Evidence, tolerance: Decimal = Decimal("0.01")) -> Verdict:
+ self.audit_calls.append((evidence, tolerance))
+ return self._verdict
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+
+def _stub_requisition() -> Requisition:
+ return Requisition(
+ need_text=[0, 2],
+ need_tables=[0],
+ need_ocr=[1],
+ rationale="Page 1 is image-only; pages 0 and 2 have financial text.",
+ )
+
+
+def _stub_verdict(
+ clean: bool = True,
+ discrepancies: list[Discrepancy] | None = None,
+) -> Verdict:
+ return Verdict(
+ session_id="test-session",
+ discrepancies=discrepancies or [],
+ pages_examined=[0, 2],
+ rounds_taken=2,
+ summary="No errors found." if clean else "1 tally error found.",
+ clean=clean,
+ )
+
+
+def _manifest_body(**overrides: object) -> dict[str, object]:
+ base: dict[str, object] = {
+ "sessionId": "test-session",
+ "pageCount": 3,
+ "folioTypes": ["text", "image", "mixed"],
+ "round": 1,
+ }
+ return {**base, **overrides}
+
+
+def _evidence_body(**overrides: object) -> dict[str, object]:
+ base: dict[str, object] = {
+ "sessionId": "test-session",
+ "folios": [
+ {"page": 0, "text": "Fee: £100\nTax: £20\nTotal: £120"},
+ {"page": 2, "text": "Summary: all tallies correct"},
+ ],
+ "round": 2,
+ "finalRound": False,
+ }
+ return {**base, **overrides}
+
+
+# ---------------------------------------------------------------------------
+# Fixtures
+# ---------------------------------------------------------------------------
+
+
+@pytest.fixture
+def stub_agent() -> StubLedgerAgent:
+ return StubLedgerAgent()
+
+
+@pytest.fixture
+def client(stub_agent: StubLedgerAgent) -> Iterator[TestClient]:
+ app.dependency_overrides[load_settings] = StubSettingsProvider()
+ app.dependency_overrides[get_math_auditor_agent] = lambda: stub_agent
+ yield TestClient(app, raise_server_exceptions=False)
+ app.dependency_overrides.pop(load_settings, None)
+ app.dependency_overrides.pop(get_math_auditor_agent, None)
+
+
+# ---------------------------------------------------------------------------
+# POST /api/v1/ai/math-auditor-agent/examine
+# ---------------------------------------------------------------------------
+
+
+class TestExamineEndpoint:
+ """Tests for POST /api/v1/ai/math-auditor-agent/examine."""
+
+ def test_returns_200(self, client: TestClient) -> None:
+ resp = client.post("/api/v1/ai/math-auditor-agent/examine", json=_manifest_body())
+ assert resp.status_code == 200
+
+ def test_response_is_requisition(self, client: TestClient) -> None:
+ resp = client.post("/api/v1/ai/math-auditor-agent/examine", json=_manifest_body())
+ body = resp.json()
+ assert body["type"] == "requisition"
+ assert body["needText"] == [0, 2]
+ assert body["needTables"] == [0]
+ assert body["needOcr"] == [1]
+ assert "rationale" in body
+
+ def test_examine_called_with_parsed_manifest(
+ self,
+ client: TestClient,
+ stub_agent: StubLedgerAgent,
+ ) -> None:
+ client.post("/api/v1/ai/math-auditor-agent/examine", json=_manifest_body(sessionId="my-session", pageCount=3))
+ assert len(stub_agent.examine_calls) == 1
+ manifest = stub_agent.examine_calls[0]
+ assert manifest.session_id == "my-session"
+ assert manifest.page_count == 3
+
+ def test_content_type_is_json(self, client: TestClient) -> None:
+ resp = client.post("/api/v1/ai/math-auditor-agent/examine", json=_manifest_body())
+ assert "application/json" in resp.headers["content-type"]
+
+
+# ---------------------------------------------------------------------------
+# POST /api/v1/ai/math-auditor-agent/deliberate
+# ---------------------------------------------------------------------------
+
+
+class TestDeliberateEndpoint:
+ """Tests for POST /api/v1/ai/math-auditor-agent/deliberate."""
+
+ def test_returns_200_clean(self, client: TestClient) -> None:
+ resp = client.post("/api/v1/ai/math-auditor-agent/deliberate", json=_evidence_body())
+ assert resp.status_code == 200
+
+ def test_response_is_verdict(self, client: TestClient) -> None:
+ resp = client.post("/api/v1/ai/math-auditor-agent/deliberate", json=_evidence_body())
+ body = resp.json()
+ assert body["type"] == "verdict"
+ assert body["clean"] is True
+
+ def test_discrepancies_serialised(self, client: TestClient) -> None:
+ d = Discrepancy(
+ page=0,
+ kind=DiscrepancyKind.TALLY,
+ severity=Severity.ERROR,
+ description="Column total wrong",
+ stated="250",
+ expected="300",
+ )
+ stub = StubLedgerAgent(verdict=_stub_verdict(clean=False, discrepancies=[d]))
+ app.dependency_overrides[get_math_auditor_agent] = lambda: stub
+ resp = client.post("/api/v1/ai/math-auditor-agent/deliberate", json=_evidence_body())
+ body = resp.json()
+ discrepancies = body["discrepancies"]
+ assert len(discrepancies) == 1
+ assert discrepancies[0]["kind"] == "tally"
+ assert discrepancies[0]["severity"] == "error"
+ assert discrepancies[0]["stated"] == "250"
+ assert discrepancies[0]["expected"] == "300"
+
+ def test_tolerance_query_param_forwarded(
+ self,
+ client: TestClient,
+ stub_agent: StubLedgerAgent,
+ ) -> None:
+ client.post("/api/v1/ai/math-auditor-agent/deliberate?tolerance=0.05", json=_evidence_body())
+ assert len(stub_agent.audit_calls) == 1
+ _, tolerance = stub_agent.audit_calls[0]
+ assert tolerance == Decimal("0.05")
+
+ def test_default_tolerance_when_omitted(
+ self,
+ client: TestClient,
+ stub_agent: StubLedgerAgent,
+ ) -> None:
+ client.post("/api/v1/ai/math-auditor-agent/deliberate", json=_evidence_body())
+ _, tolerance = stub_agent.audit_calls[0]
+ assert tolerance == Decimal("0.01")
+
+ def test_invalid_tolerance_returns_400(
+ self,
+ client: TestClient,
+ stub_agent: StubLedgerAgent,
+ ) -> None:
+ resp = client.post("/api/v1/ai/math-auditor-agent/deliberate?tolerance=notanumber", json=_evidence_body())
+ assert resp.status_code == 400
+
+ def test_final_round_flag_parsed(
+ self,
+ client: TestClient,
+ stub_agent: StubLedgerAgent,
+ ) -> None:
+ client.post("/api/v1/ai/math-auditor-agent/deliberate", json=_evidence_body(finalRound=True))
+ evidence, _ = stub_agent.audit_calls[0]
+ assert evidence.final_round is True
diff --git a/testing/ledger/arithmetic_error.pdf b/testing/ledger/arithmetic_error.pdf
new file mode 100644
index 000000000..36d73ebca
Binary files /dev/null and b/testing/ledger/arithmetic_error.pdf differ
diff --git a/testing/ledger/clean_invoice.pdf b/testing/ledger/clean_invoice.pdf
new file mode 100644
index 000000000..c54d7d45b
--- /dev/null
+++ b/testing/ledger/clean_invoice.pdf
@@ -0,0 +1,86 @@
+%PDF-1.3
+%
+1 0 obj
+<<
+/Count 1
+/Kids [3 0 R]
+/MediaBox [0 0 595.28 841.89]
+/Type /Pages
+>>
+endobj
+2 0 obj
+<<
+/OpenAction [3 0 R /FitH null]
+/PageLayout /OneColumn
+/Pages 1 0 R
+/Type /Catalog
+>>
+endobj
+3 0 obj
+<<
+/Contents 4 0 R
+/Parent 1 0 R
+/Resources 7 0 R
+/Type /Page
+>>
+endobj
+4 0 obj
+<<
+/Filter /FlateDecode
+/Length 562
+>>
+stream
+xMo@#J
+o
+%-Um/;2&ccd˞ٙwf%Rl,8#̀h|OgQ`Dw+8-}\&c@jDɖ|ՙt(t0UYP