Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ jobs:
python -m pip install --upgrade pip
pip install -e ".[dev]"
- name: Lint
run: ruff check src tests
run: |
ruff check src tests
black --check src tests
- name: Test (with coverage + corpus precision/recall gate)
run: pytest --cov=attune_verify --cov-report=term-missing
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- **rag adapter no longer crashes on every call.** It read
`result.is_faithful`, a field attune-rag's `FaithfulnessResult` has never
had (its verdict is score/claims-based), so the semantic layer always
degraded to a warning. `faithful` is now derived from
`unsupported_claims`; the adapter also raises a clear error when called
from inside a running event loop instead of asyncio.run's generic one.
- **Years near a count keyword no longer false-positive.** "Released 2026
versions of the widgets" flagged 2026 against the `widgets` source. Values
in 1900–2099 are now compared only when the keyword directly follows the
number ("2026 widgets" is still checked).
- **Count-source keywords match on a word boundary.** "test" no longer
matches inside "latest" (plural drift still matches: "widget" ~ "widgets").
- **Link targets can no longer escape `project_root`.** `../`-traversal that
happens to hit a real file outside the root previously passed silently; it
now yields a warning (unverifiable as a project link). Site-absolute
targets (`/docs/page.md`) are resolved under `project_root` instead of the
filesystem root.
- `check_counts` docstring claimed unmatched claims yield warnings; they are
(and were) silently skipped — the docstring now says so.
- **Fences with an info string are now extracted.** ` ```python title="ex.py" `
previously matched nothing, so everything inside the fence went unchecked —
a silent false negative for any MkDocs/Docusaurus-style generated doc.
Expand All @@ -23,6 +42,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
checker-level warning; same for a bad `env_python` in the import checker.
Both now degrade per-flag / per-import (warning) and keep checking the rest.

### Added

- **`FindingKind.CHECKER_ERROR`** — checker infrastructure failures carry
their own kind instead of repurposing `UNRESOLVED_IMPORT`.
- Import resolution is cached per `verify()` call — repeated imports of the
same module across fences no longer re-launch the interpreter.
- `VerifyContext.judge` is typed `Optional[Judge]` (was `object`).
- CI now enforces `black --check` alongside ruff; the codebase is formatted.

## [0.2.1] - 2026-06-22

Verifier-accuracy fixes — `verify()` now catches three hallucination
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,10 @@ verify checks *"does this named thing exist?"*

## Status

Pre-alpha — spec complete, implementation in progress.
Alpha — the deterministic core (imports, flags, links, counts) is shipped
and guarded by a labeled precision/recall corpus (gated ≥ 0.95 each) and
mutation testing (gated ≥ 0.75). The LLM semantic layer is optional via
the `[rag]` extra.

## License

Expand Down
64 changes: 37 additions & 27 deletions src/attune_verify/_verify.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Core verify() orchestration — runs all checkers and the semantic layer."""

from __future__ import annotations

import logging
Expand Down Expand Up @@ -54,6 +55,7 @@ def verify(content: str, context: VerifyContext) -> VerifyResult:
# Per-checker wrappers — each catches exceptions and surfaces as a warning
# ---------------------------------------------------------------------------


def _check_imports(content: str, context: VerifyContext) -> List[Finding]:
fences = extract_code_fences(content)
return check_imports(fences, env_python=context.env_python)
Expand Down Expand Up @@ -92,28 +94,32 @@ def _run_checker(
except Exception as exc: # noqa: BLE001
# INTENTIONAL: individual checker failures must not abort the run.
logger.exception("checker '%s' raised: %s", name, exc)
result.findings.append(Finding(
kind=FindingKind.UNRESOLVED_IMPORT, # closest kind for infra error
detail=f"Checker '{name}' failed: {exc}",
evidence="",
severity="warning",
))
result.findings.append(
Finding(
kind=FindingKind.CHECKER_ERROR,
detail=f"Checker '{name}' failed: {exc}",
evidence="",
severity="warning",
)
)


def _run_semantic(result: VerifyResult, content: str, context: VerifyContext) -> None:
"""Run the semantic layer if a judge is available."""
from attune_verify.semantic.protocol import Judge # noqa: PLC0415

if context.judge is None or not isinstance(context.judge, Judge):
result.findings.append(Finding(
kind=FindingKind.SEMANTIC,
detail=(
"Semantic layer requested (context.semantic=True) "
"but no judge was provided in VerifyContext.judge"
),
evidence="",
severity="warning",
))
result.findings.append(
Finding(
kind=FindingKind.SEMANTIC,
detail=(
"Semantic layer requested (context.semantic=True) "
"but no judge was provided in VerifyContext.judge"
),
evidence="",
severity="warning",
)
)
return

try:
Expand All @@ -125,18 +131,22 @@ def _run_semantic(result: VerifyResult, content: str, context: VerifyContext) ->
result.semantic_ran = True
if not verdict.faithful:
for issue in verdict.issues:
result.findings.append(Finding(
kind=FindingKind.SEMANTIC,
detail=issue,
evidence="",
severity="error",
))
result.findings.append(
Finding(
kind=FindingKind.SEMANTIC,
detail=issue,
evidence="",
severity="error",
)
)
except Exception as exc: # noqa: BLE001
# INTENTIONAL: semantic layer is opt-in; failures degrade gracefully.
logger.exception("semantic judge raised: %s", exc)
result.findings.append(Finding(
kind=FindingKind.SEMANTIC,
detail=f"Semantic judge failed: {exc}",
evidence="",
severity="warning",
))
result.findings.append(
Finding(
kind=FindingKind.SEMANTIC,
detail=f"Semantic judge failed: {exc}",
evidence="",
severity="warning",
)
)
51 changes: 43 additions & 8 deletions src/attune_verify/checkers/counts.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,30 +2,38 @@

from __future__ import annotations

import re
from typing import Callable, Dict, List, Union

from attune_verify._extract import NumericClaim
from attune_verify.result import Finding, FindingKind

# Values in this range near a source keyword are far more likely to be years
# than counts ("Released 2026 versions of the widgets" is not a widget count).
_YEAR_MIN, _YEAR_MAX = 1900, 2099


def check_counts(
claims: List[NumericClaim],
count_sources: Dict[str, Union[int, Callable[[], int]]],
) -> List[Finding]:
"""Verify numeric claims match count_sources values.

Counts cannot be inferred — the caller must supply them. Any numeric
claim in the content is matched against count_sources by value. Claims
with no matching source entry are flagged as warnings (unverifiable),
not errors.
Counts cannot be inferred — the caller must supply them. Each numeric
claim is matched to the source its surrounding text names and compared
against that source's value. Claims whose context names no source are
silently skipped (unverifiable without a source, and flagging every
stray number would be noise). Year-like values (1900–2099) are compared
only when the source keyword directly follows the number ("2026 widgets"),
so dates near a keyword don't false-positive.

Args:
claims: Numeric claims extracted from generated content.
count_sources: Expected values keyed by label/description.
Values may be plain ints or zero-argument callables.

Returns:
List of findings for mismatched or unverifiable counts.
List of findings for mismatched counts.
"""
if not count_sources:
return []
Expand All @@ -43,7 +51,11 @@ def check_counts(
# unrelated source (e.g. "12 tests" passing because some other source
# also equals 12) — cross-contamination.
close_label = _find_close_label(claim.context, resolved_sources)
if close_label is not None and claim.value != resolved_sources[close_label]:
if close_label is None:
continue
if _year_like(claim.value) and not _label_follows_number(claim, close_label):
continue
if claim.value != resolved_sources[close_label]:
expected = resolved_sources[close_label]
findings.append(
Finding(
Expand All @@ -64,10 +76,33 @@ def _find_close_label(
context: str,
sources: Dict[str, int],
) -> str | None:
"""Find a source label whose keywords appear in the claim's context."""
"""Find a source label whose keywords appear in the claim's context.

Keywords match on a leading word boundary — "test" matches "tests" but
not "latest" — a bare substring test false-matched inside longer words.
"""
context_lower = context.lower()
for label in sources:
words = label.lower().split()
if any(w in context_lower for w in words if len(w) > 3):
if any(re.search(rf"\b{re.escape(w)}", context_lower) for w in words if len(w) > 3):
return label
return None


def _year_like(value: int) -> bool:
return _YEAR_MIN <= value <= _YEAR_MAX


def _label_follows_number(claim: NumericClaim, label: str) -> bool:
"""True when a label keyword is one of the two tokens after the number.

"2026 widgets" reads as a widget count; "2026 versions of the widgets"
reads as a year that merely has the keyword nearby.
"""
match = re.search(rf"\b{claim.value}\b((?:\s+\S+){{1,2}})", claim.context.lower())
if match is None:
return False
following = match.group(1)
return any(
re.search(rf"\b{re.escape(w)}", following) for w in label.lower().split() if len(w) > 3
)
9 changes: 8 additions & 1 deletion src/attune_verify/checkers/imports.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ def check_imports(
List of findings for unresolvable imports.
"""
findings: List[Finding] = []
# Each resolution is a subprocess; repeated imports of the same module
# across fences are common, so resolve each module once per call.
resolution_cache: dict[str, bool] = {}
for fence in fences:
# "" is a bare fence: LLM output routinely omits the language tag, so
# parse speculatively — non-Python content fails ast.parse and is skipped.
Expand All @@ -42,7 +45,11 @@ def check_imports(
for module in _modules_from_node(node):
location = f"line {fence.line}" if fence.line else None
try:
resolved = _resolves(module, env_python)
if module in resolution_cache:
resolved = resolution_cache[module]
else:
resolved = _resolves(module, env_python)
resolution_cache[module] = resolved
except (OSError, subprocess.TimeoutExpired) as exc:
# Resolution infrastructure failed (bad env_python, timeout).
# Degrade per-import — the remaining imports must still run.
Expand Down
60 changes: 42 additions & 18 deletions src/attune_verify/checkers/links.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Link checker — verifies markdown link targets resolve to real files."""

from __future__ import annotations

from pathlib import Path
Expand Down Expand Up @@ -35,24 +36,47 @@ def check_links(
if not path_part:
continue
if project_root is None:
findings.append(Finding(
kind=FindingKind.DEAD_LINK,
detail=(
f"Link '{target}' cannot be verified "
"(no project_root in VerifyContext)"
),
evidence=f"[{link.text}]({link.target})",
location=f"line {link.line}" if link.line else None,
severity="warning",
))
findings.append(
Finding(
kind=FindingKind.DEAD_LINK,
detail=(
f"Link '{target}' cannot be verified " "(no project_root in VerifyContext)"
),
evidence=f"[{link.text}]({link.target})",
location=f"line {link.line}" if link.line else None,
severity="warning",
)
)
continue
root = project_root.resolve()
# Site-absolute targets (/docs/page.md) mean root-relative in generated
# docs; joining them raw would make Path use the filesystem root.
rel = path_part.lstrip("/") if path_part.startswith("/") else path_part
resolved = (root / rel).resolve()
if not resolved.is_relative_to(root):
# ../-traversal out of the declared truth boundary: the file may
# exist on disk, but it cannot be verified AS a project link.
# Warning, not error — same "never a silent pass" rule as flags.
findings.append(
Finding(
kind=FindingKind.DEAD_LINK,
detail=(
f"Link '{target}' resolves outside project_root " "and cannot be verified"
),
evidence=f"[{link.text}]({link.target})",
location=f"line {link.line}" if link.line else None,
severity="warning",
)
)
continue
resolved = (project_root / path_part).resolve()
if not resolved.exists():
findings.append(Finding(
kind=FindingKind.DEAD_LINK,
detail=f"Link target '{path_part}' does not exist",
evidence=f"[{link.text}]({link.target})",
location=f"line {link.line}" if link.line else None,
severity="error",
))
findings.append(
Finding(
kind=FindingKind.DEAD_LINK,
detail=f"Link target '{path_part}' does not exist",
evidence=f"[{link.text}]({link.target})",
location=f"line {link.line}" if link.line else None,
severity="error",
)
)
return findings
12 changes: 7 additions & 5 deletions src/attune_verify/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,16 @@

The caller declares WHERE truth comes from; verify performs the lookups.
"""

from __future__ import annotations

import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable, Dict, FrozenSet, Optional, Union
from typing import TYPE_CHECKING, Callable, Dict, FrozenSet, Optional, Union

if TYPE_CHECKING:
from attune_verify.semantic.protocol import Judge


@dataclass
Expand Down Expand Up @@ -39,8 +43,6 @@ class VerifyContext:
env_python: str = field(default_factory=lambda: sys.executable)
help_commands: Dict[str, str] = field(default_factory=dict)
allowed_help_cmds: FrozenSet[str] = field(default_factory=frozenset)
count_sources: Dict[str, Union[int, Callable[[], int]]] = field(
default_factory=dict
)
judge: object = None # Judge protocol instance
count_sources: Dict[str, Union[int, Callable[[], int]]] = field(default_factory=dict)
judge: Optional["Judge"] = None
semantic: bool = False
Loading
Loading