diff --git a/.github/workflows/formal-maintenance.yml b/.github/workflows/formal-maintenance.yml
new file mode 100644
index 00000000..8641d6e3
--- /dev/null
+++ b/.github/workflows/formal-maintenance.yml
@@ -0,0 +1,72 @@
+name: Formal maintenance
+
+on:
+ workflow_dispatch:
+ schedule:
+ - cron: "17 8 * * 1"
+ push:
+ branches: [main]
+ paths:
+ - ".github/workflows/formal-maintenance.yml"
+ - "lean-toolchain"
+ - "lakefile.toml"
+ - "lake-manifest.json"
+ - "formal/**"
+ - "tools/check_lean_specs.py"
+ - "tools/check_logic_fixtures.py"
+ - "baseline/**/*.py"
+ - "datasets/**/*.py"
+ - "decomposer/**/*.py"
+ - "search/**/*.py"
+ - "training/**/*.py"
+ - "utils/**/*.py"
+ - "verifier/**/*.py"
+ pull_request:
+ paths:
+ - ".github/workflows/formal-maintenance.yml"
+ - "lean-toolchain"
+ - "lakefile.toml"
+ - "lake-manifest.json"
+ - "formal/**"
+ - "tools/check_lean_specs.py"
+ - "tools/check_logic_fixtures.py"
+ - "baseline/**/*.py"
+ - "datasets/**/*.py"
+ - "decomposer/**/*.py"
+ - "search/**/*.py"
+ - "training/**/*.py"
+ - "utils/**/*.py"
+ - "verifier/**/*.py"
+
+permissions:
+ contents: read
+
+concurrency:
+ group: formal-maintenance-${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ maintain:
+ name: Lean 4.32 proof and conformance gate
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v6
+
+ - name: Install pinned Lean toolchain
+ uses: leanprover/lean-action@v1
+ with:
+ auto-config: false
+ build: false
+ test: false
+ lint: false
+
+ - name: Build without warnings
+ run: lake build --wfail
+
+ - name: Check theorem links, tests, dependencies, and axiom footprints
+ run: python3 tools/check_lean_specs.py
+
+ - name: Replay shared logical evaluation fixtures
+ run: python3 tools/check_logic_fixtures.py
diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml
index 88c3199c..660d238c 100644
--- a/.github/workflows/pytest.yml
+++ b/.github/workflows/pytest.yml
@@ -17,6 +17,12 @@ on:
- "verifier/**"
- "baseline/**"
- "training/**"
+ - "formal/**"
+ - "tools/check_lean_specs.py"
+ - "tools/check_logic_fixtures.py"
+ - "lakefile.toml"
+ - "lake-manifest.json"
+ - "lean-toolchain"
pull_request:
paths:
- ".github/workflows/pytest.yml"
@@ -30,6 +36,12 @@ on:
- "verifier/**"
- "baseline/**"
- "training/**"
+ - "formal/**"
+ - "tools/check_lean_specs.py"
+ - "tools/check_logic_fixtures.py"
+ - "lakefile.toml"
+ - "lake-manifest.json"
+ - "lean-toolchain"
jobs:
test:
diff --git a/.gitignore b/.gitignore
index 83972fad..0ec5c62d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -205,6 +205,7 @@ tempCodeRunnerFile.py
# Ruff stuff:
.ruff_cache/
+.lake/
# PyPI configuration file
.pypirc
diff --git a/FORMAL_LOGIC.md b/FORMAL_LOGIC.md
new file mode 100644
index 00000000..8f71926d
--- /dev/null
+++ b/FORMAL_LOGIC.md
@@ -0,0 +1,305 @@
+# Maintaining AMFV's formal logic
+
+AMFV uses Lean to specify and test structural rules at boundaries where an
+ordinary program can return plausible-looking but invalid data. The current
+models cover source URL admission, scraper accounting, verification receipts,
+cache admission, and evaluation success.
+
+This is an engineering layer, not a claim that AMFV is wholly verified. Lean
+proves properties of the finite models in `formal/`. Python tests and shared
+fixtures connect those models to runtime behavior.
+
+## Why this layer exists
+
+Medical fact verification depends on more than a model's final score. The
+system must also preserve where evidence came from, which evidence a receipt
+uses, whether cached work belongs to the same scope, and whether an evaluation
+actually agrees with its reference.
+
+These rules are:
+
+- small enough to state precisely;
+- important enough that silent failure is expensive;
+- stable enough to serve as interfaces between components; and
+- easy to under-test if only happy paths are exercised.
+
+Lean gives these rules an executable, reviewable definition. It is a
+development and CI dependency only; AMFV's Python runtime does not invoke Lean.
+
+## What is and is not proved
+
+The Lean library proves the named theorems about the types and functions
+declared under `formal/`. The repository then checks that selected Python
+symbols point to real theorems and have paired runtime tests.
+
+This establishes a maintained correspondence. It does not prove:
+
+- arbitrary Python bytecode correct;
+- arbitrary HTML or JSON correctly parsed;
+- a medical claim true or false;
+- evidence clinically sufficient;
+- a source corpus complete; or
+- the network, clock, or upstream source trustworthy.
+
+When adding a theorem, state its assumptions in its types or surrounding
+documentation. Do not describe a model property as a property of the whole
+application unless the runtime bridge actually enforces it.
+
+## Repository layout
+
+| Path | Responsibility |
+| --- | --- |
+| `lean-toolchain` | Pins the exact Lean release used locally and in CI. |
+| `lakefile.toml` | Declares the `AMFV` library and `amfv_logic` executable. |
+| `lake-manifest.json` | Records the dependency-free Lake project state. |
+| `formal/AMFV.lean` | Imports the public formal library. |
+| `formal/AMFV/Logic/` | General adversarial logical gates. |
+| `formal/AMFV/Scraping/` | URL policy and scraper-accounting models. |
+| `formal/AMFV/Verification/` | Receipt, cache, verdict, and evaluation models. |
+| `formal/AMFV/LogicalEval.lean` | JSONL logical-evaluation executable. |
+| `formal/AMFV/ProofBoundary.lean` | Explicit statements about the proof boundary. |
+| `formal/fixtures/logical-eval.jsonl` | Shared Lean/Python conformance cases. |
+| `tools/check_lean_specs.py` | Proof hygiene, dependency, tag, test, and axiom checks. |
+| `tools/check_logic_fixtures.py` | Replays shared fixtures through Lean. |
+| `.github/workflows/formal-maintenance.yml` | Pull-request, push, scheduled, and manual gate. |
+
+The formal project intentionally uses only Lean's standard library. Keeping the
+model small and dependency-free makes it easier for maintainers to audit and
+keeps CI failures attributable to this repository.
+
+## Local setup
+
+Install Lean through `elan`, Lean's toolchain manager. From the repository root,
+confirm that the pinned version is selected:
+
+```sh
+lean --version
+lake --version
+cat lean-toolchain
+```
+
+`elan` reads `lean-toolchain` automatically. The expected pin is
+`leanprover/lean4:v4.32.0`.
+
+Install the Python development environment separately:
+
+```sh
+uv sync --dev
+```
+
+No Lean package installation is required. A successful first build downloads
+or selects the pinned toolchain and builds the local project:
+
+```sh
+lake build --wfail
+```
+
+## The normal maintenance loop
+
+Use the same counterexample from discovery through proof and runtime repair:
+
+1. Identify a structural state that AMFV must reject or account for.
+2. Reduce it to the smallest finite model that preserves the failure.
+3. Add the counterexample to Lean and show why the old gate is insufficient.
+4. State and prove a narrow invariant for the repaired gate.
+5. Patch the Python boundary that enforces the rule.
+6. Add a focused Python regression test using the same counterexample.
+7. Link the runtime symbol and test to the theorem.
+8. Add or update shared JSONL fixtures when the rule is part of the logical
+ evaluator protocol.
+9. Run the full maintenance gate before requesting review.
+
+Prefer a theorem about one admission or accounting decision over a theorem that
+recreates an entire Python subsystem. Small models are easier to review, reuse,
+and keep aligned.
+
+## Linking Python to a theorem
+
+Put a `lean-spec` comment immediately above the supported Python function or
+class:
+
+```python
+# lean-spec: AMFV.Scraping.UrlPolicy.accepted_chapter_has_allowed_authority
+def is_allowed_chapter(...):
+ ...
+```
+
+Put the matching `lean-spec-test` comment immediately above a pytest test:
+
+```python
+# lean-spec-test: AMFV.Scraping.UrlPolicy.accepted_chapter_has_allowed_authority
+def test_rejects_off_domain_chapter(...):
+ ...
+```
+
+The fully qualified name must resolve to a Lean `theorem`, not merely a
+definition. Every runtime tag must have at least one paired test tag.
+
+`tools/check_lean_specs.py` enforces that:
+
+- both annotations are attached directly to Python declarations;
+- each runtime annotation has a paired runtime test;
+- every named declaration exists and is a theorem;
+- tagged theorems have an empty axiom footprint;
+- Lean sources contain no `sorry`, `admit`, `axiom`, `constant`, or `opaque`
+ proof escape; and
+- `lake-manifest.json` contains no external packages.
+
+If a Python symbol implements several independent rules, use a small validating
+function for each rule instead of making one annotation stand for an ambiguous
+bundle of behavior.
+
+## Shared logical-evaluation fixtures
+
+`lake exe amfv_logic` accepts one `amfv.logic.v1` JSON object per line and emits
+one result per line. Supported input kinds are:
+
+- `verifier_receipt`;
+- `cache_admission`; and
+- `evaluation_case`.
+
+Each fixture in `formal/fixtures/logical-eval.jsonl` includes
+`expected_valid` and `expected_violations`. Violation codes are part of the
+developer-facing protocol: keep them stable unless a deliberate protocol change
+is documented and applied to both implementations.
+
+When extending the protocol:
+
+1. define the rule and violation code in the Lean model;
+2. update `formal/AMFV/LogicalEval.lean`;
+3. update the Python mirror;
+4. add valid, invalid, boundary, and malformed fixtures;
+5. make both implementations return the same ordered result; and
+6. run both the Lean fixture replay and Python conformance tests.
+
+Fixtures should include the smallest counterexample, not just realistic large
+objects. For time rules, cover exact boundaries, unreasonable values, UTC
+normalization, and daylight-saving folds where applicable. For collections,
+cover missing fields, wrong shapes, unknown identifiers, duplicates, overlaps,
+and empty directional evidence.
+
+## Required checks
+
+Run these commands from the repository root:
+
+```sh
+lake build --wfail
+python3 tools/check_lean_specs.py
+python3 tools/check_logic_fixtures.py
+uv run pytest
+uv run ruff check .
+uv run ruff format --check .
+```
+
+When changing a package's public or packaging surface, also build that package,
+for example:
+
+```sh
+uv build --package amfv-verifier
+```
+
+`--wfail` is intentional: new Lean warnings are maintenance failures rather
+than deferred cleanup.
+
+## Continuous maintenance
+
+`.github/workflows/formal-maintenance.yml` runs when formal files, proof-link
+tools, the workflow itself, or Python component files change. It also runs every
+Monday and can be started manually with `workflow_dispatch`.
+
+The workflow:
+
+1. checks out the repository;
+2. installs the version from `lean-toolchain`;
+3. builds Lean with warnings treated as errors;
+4. verifies theorem links, paired tests, dependencies, and axiom footprints;
+5. replays the shared logical-evaluation fixtures.
+
+The scheduled run is a drift detector. Do not solve a scheduled failure by
+loosening a theorem, deleting a counterexample, or weakening the checker. First
+identify whether the toolchain, model, runtime link, or fixture protocol drifted.
+
+## Updating Lean
+
+Treat a Lean upgrade as its own pull request:
+
+1. change `lean-toolchain` to the intended exact release;
+2. run `lake update` to refresh `lake-manifest.json`;
+3. run every required check above;
+4. inspect warnings and proof changes rather than applying broad mechanical
+ rewrites;
+5. confirm `packages` remains empty in `lake-manifest.json`;
+6. record meaningful language or behavior changes in the pull-request body.
+
+Do not use an unpinned channel such as `stable` or `nightly`. Do not add Mathlib
+or another Lean dependency merely to shorten a small proof; propose that change
+explicitly with its maintenance and supply-chain cost.
+
+## Diagnosing failures
+
+### A `lean-spec` target does not resolve
+
+Import its module from `formal/AMFV.lean`, check the fully qualified namespace,
+and run:
+
+```sh
+lake env lean formal/AMFV.lean
+python3 tools/check_lean_specs.py
+```
+
+### A theorem has an axiom dependency
+
+Inspect it directly:
+
+```lean
+#print axioms AMFV.Namespace.theorem_name
+```
+
+Remove the assumption or proof escape. Do not suppress the checker.
+
+### Lean and Python disagree on a fixture
+
+Reduce the failing record to the smallest JSON object that still disagrees.
+Then compare:
+
+```sh
+lake exe amfv_logic < formal/fixtures/logical-eval.jsonl
+python3 tools/check_logic_fixtures.py
+uv run pytest
+```
+
+Determine whether the model, Python mirror, or expected protocol result is
+wrong. The formal implementation is not automatically authoritative about
+medical meaning; the intended rule must be reviewed.
+
+### CI passes locally but fails on the schedule
+
+Confirm the checked-in toolchain and manifest match the local environment, then
+rerun with a clean Lake build directory if necessary. If the failure is caused
+by an upstream action change, pin or repair the workflow in a focused pull
+request rather than bypassing the formal gate.
+
+## Review checklist
+
+Before merging a formal-logic change, verify:
+
+- [ ] The motivating bad state is concrete and reproducible.
+- [ ] The model is smaller than the runtime behavior it constrains.
+- [ ] Assumptions and proof boundaries are explicit.
+- [ ] The theorem has no proof placeholders or axiom dependencies.
+- [ ] The Python enforcement point has a `lean-spec` tag.
+- [ ] A focused regression test has the matching `lean-spec-test` tag.
+- [ ] Shared fixtures cover both acceptance and rejection when applicable.
+- [ ] Violation codes and protocol changes are deliberate and documented.
+- [ ] Lean remains a development/CI dependency, not a runtime dependency.
+- [ ] All required local and CI checks pass.
+
+## Choosing the next proof target
+
+Good targets are deterministic admission, accounting, freshness, scope, and
+agreement rules whose failure can silently contaminate later stages. Avoid
+using Lean to restate broad product intent or to make claims about clinical
+truth that require empirical evidence.
+
+The practical question is: **what invalid state could still look valid to the
+next component?** Start there.
diff --git a/README.md b/README.md
index 8721bae3..9767ef11 100644
--- a/README.md
+++ b/README.md
@@ -26,3 +26,24 @@ Following Baichuan-M3, the task is split into three models across four steps:
| [`training`](training/README.md) | Training experiments and recipes for the above | Independent |
The **Workspace** column marks membership in the root `uv` workspace. Independent packages (`baseline`, `training`) are excluded so they can evolve on their own.
+
+## Formal logical evaluation
+
+AMFV uses a dependency-free Lean 4.32.0 library to model structural invariants
+at high-risk boundaries such as source URL admission, scraper accounting,
+verification receipts, cache freshness, and evaluation success.
+
+```sh
+lake build --wfail
+python3 tools/check_lean_specs.py
+python3 tools/check_logic_fixtures.py
+```
+
+Python functions annotated with `# lean-spec:` are backed by a named theorem
+and an adversarial runtime test. This establishes conformance to the modeled
+invariant; it does not claim that Lean verifies Python bytecode, arbitrary HTML,
+medical truth, or corpus completeness.
+
+See [`FORMAL_LOGIC.md`](FORMAL_LOGIC.md) for the complete developer and
+maintenance guide. The shorter [`formal/README.md`](formal/README.md) is a
+directory-level quick reference.
diff --git a/datasets/amfv_datasets/scraping/__init__.py b/datasets/amfv_datasets/scraping/__init__.py
index cd7cdb06..f3631f9d 100644
--- a/datasets/amfv_datasets/scraping/__init__.py
+++ b/datasets/amfv_datasets/scraping/__init__.py
@@ -20,7 +20,9 @@
from amfv_datasets.scraping.nice import (
GuidanceListingPage,
GuidanceRef,
+ GuidelineExtractionReceipt,
NiceFetchError,
+ OmittedSection,
build_guideline_text,
guidance_ref_from_url,
list_published_guidance,
@@ -30,9 +32,11 @@
__all__ = [
"GuidanceRef",
+ "GuidelineExtractionReceipt",
"GuidanceListingPage",
"LinkMode",
"NiceFetchError",
+ "OmittedSection",
"OutputFormat",
"ScrapeError",
"ScrapeRun",
diff --git a/datasets/amfv_datasets/scraping/html.py b/datasets/amfv_datasets/scraping/html.py
index 2bd1c968..272c8948 100644
--- a/datasets/amfv_datasets/scraping/html.py
+++ b/datasets/amfv_datasets/scraping/html.py
@@ -3,9 +3,9 @@
from __future__ import annotations
import re
-from collections.abc import Iterable
+from collections.abc import Collection, Iterable
from enum import StrEnum
-from urllib.parse import urljoin
+from urllib.parse import urljoin, urlsplit, urlunsplit
from lxml import html as lxml_html
from markdownify import MarkdownConverter
@@ -40,17 +40,46 @@ def clean_text(value: str, *, drop_numeric_citations: bool = True) -> str:
return _WHITESPACE_RE.sub(" ", text).strip()
-def absolute_unique_urls(urls: Iterable[str], *, base_url: str) -> list[str]:
+def absolute_unique_urls(
+ urls: Iterable[str],
+ *,
+ base_url: str,
+ allowed_schemes: Collection[str] = ("http", "https"),
+ allowed_hosts: Collection[str] | None = None,
+) -> list[str]:
"""Normalize URLs against a base URL and remove duplicates.
Args:
urls: Raw URL values to normalize.
base_url: Base URL used for relative links.
+ allowed_schemes: URL schemes that may be returned.
+ allowed_hosts: Optional exact hostname allowlist. When set, URLs with
+ credentials or non-default ports are rejected.
"""
+ normalized_schemes = {scheme.lower() for scheme in allowed_schemes}
+ normalized_hosts = {host.lower() for host in allowed_hosts} if allowed_hosts is not None else None
seen: set[str] = set()
normalized_urls: list[str] = []
for raw_url in urls:
- url = urljoin(base_url, raw_url).split("#")[0].split("?")[0]
+ parsed = urlsplit(urljoin(base_url, raw_url))
+ scheme = parsed.scheme.lower()
+ if scheme not in normalized_schemes:
+ continue
+ try:
+ port = parsed.port
+ except ValueError:
+ continue
+ if normalized_hosts is not None:
+ default_port = 80 if scheme == "http" else 443 if scheme == "https" else None
+ if (
+ parsed.hostname is None
+ or parsed.hostname.lower() not in normalized_hosts
+ or parsed.username is not None
+ or parsed.password is not None
+ or port not in {None, default_port}
+ ):
+ continue
+ url = urlunsplit(parsed._replace(query="", fragment=""))
if url in seen:
continue
seen.add(url)
@@ -58,19 +87,35 @@ def absolute_unique_urls(urls: Iterable[str], *, base_url: str) -> list[str]:
return normalized_urls
-def first_matching_urls(html_text: str, *, xpaths: Iterable[str], base_url: str) -> list[str]:
+def first_matching_urls(
+ html_text: str,
+ *,
+ xpaths: Iterable[str],
+ base_url: str,
+ allowed_schemes: Collection[str] = ("http", "https"),
+ allowed_hosts: Collection[str] | None = None,
+) -> list[str]:
"""Return normalized URLs from the first XPath with matches.
Args:
html_text: HTML page text to parse.
xpaths: XPath expressions that return URL strings.
base_url: Base URL used for relative links.
+ allowed_schemes: URL schemes that may be returned.
+ allowed_hosts: Optional exact hostname allowlist.
"""
doc = lxml_html.fromstring(html_text)
for xpath in xpaths:
urls = doc.xpath(xpath)
if urls:
- return absolute_unique_urls(urls, base_url=base_url)
+ normalized_urls = absolute_unique_urls(
+ urls,
+ base_url=base_url,
+ allowed_schemes=allowed_schemes,
+ allowed_hosts=allowed_hosts,
+ )
+ if normalized_urls:
+ return normalized_urls
return []
diff --git a/datasets/amfv_datasets/scraping/nice.py b/datasets/amfv_datasets/scraping/nice.py
index 79d5bb69..b9c92851 100644
--- a/datasets/amfv_datasets/scraping/nice.py
+++ b/datasets/amfv_datasets/scraping/nice.py
@@ -38,7 +38,7 @@
default_client,
scrape_listing_documents,
)
-from amfv_datasets.scraping.html import LinkMode, document_title, first_matching_urls, html_to_markdown
+from amfv_datasets.scraping.html import LinkMode, absolute_unique_urls, document_title, html_to_markdown
from amfv_datasets.scraping.nextjs import script_json_by_id
BASE_URL = "https://www.nice.org.uk"
@@ -75,6 +75,7 @@
)
_NUMBERED_HEADING_RE = re.compile(r"^\s*\d+(?:\.\d+)*\s+")
_SKIP_CHAPTER_SUFFIXES = ("finding-more-information-and-committee-details",)
+_NICE_HOSTS = ("nice.org.uk", "www.nice.org.uk")
class _NiceScrapeStrategy(StrEnum):
@@ -99,12 +100,48 @@ class GuidanceRef:
@dataclass(frozen=True)
class GuidanceListingPage:
- """A NICE published-guidance listing page."""
+ """A NICE published-guidance listing page.
+
+ `total` is NICE's source-reported count before AMFV filters unsupported
+ guidance types. It is not an exact count of eligible or emitted documents.
+ """
refs: list[GuidanceRef]
total: int | None
+@dataclass(frozen=True)
+class OmittedSection:
+ """A discovered source section intentionally omitted from the document."""
+
+ url: str
+ reason: str
+
+
+@dataclass(frozen=True)
+class GuidelineExtractionReceipt:
+ """Auditable result of extracting overview/chapter source sections."""
+
+ content: str
+ section_count: int
+ title: str
+ discovered_count: int
+ retained_urls: tuple[str, ...]
+ omitted_sections: tuple[OmittedSection, ...]
+ transformations: tuple[str, ...]
+
+ # lean-spec: AMFV.Scraping.ExtractionReceipt.accounted_receipt_matches_disposition_count
+ def is_accounted(self) -> bool:
+ """Return whether disposition cardinalities match the discovered count."""
+ return self.discovered_count == len(self.retained_urls) + len(self.omitted_sections)
+
+
+@dataclass(frozen=True)
+class _ChapterLinkDiscovery:
+ accepted: tuple[str, ...]
+ rejected: tuple[OmittedSection, ...]
+
+
def _listing_url(*, page: int) -> str:
return f"{BASE_URL}/guidance/published?sp=on&pa={page}"
@@ -149,20 +186,12 @@ def _parse_listing(html_text: str) -> GuidanceListingPage:
logger.debug("Skipping unsupported NICE guidance type %s for %s", prefix, ref)
continue
slug = ref.lower()
- path = doc.get("pathAndQuery") or _page_path(ref=ref, slug=slug)
- page_url = (
- _page_url(ref=ref, slug=slug)
- if prefix in _ADVICE_PREFIXES
- else f"{BASE_URL}{path}"
- if path.startswith("/")
- else path
- )
refs.append(
GuidanceRef(
ref=ref,
slug=slug,
title=(doc.get("title") or ref).strip(),
- page_url=page_url,
+ page_url=_page_url(ref=ref, slug=slug),
)
)
total = results.get("resultCount")
@@ -181,14 +210,38 @@ def list_published_guidance(client: httpx.Client, page: int = 1) -> GuidanceList
return _parse_listing(response.text)
-def _chapter_links(html_text: str, slug: str) -> list[str]:
- """Return absolute chapter URLs from a guidance overview table of contents."""
+# lean-spec: AMFV.Scraping.UrlPolicy.accepted_chapter_respects_source_boundary
+def _chapter_links(html_text: str, slug: str) -> _ChapterLinkDiscovery:
+ """Return accepted and rejected chapter URLs from a table of contents."""
chapter_path_match = f"contains(@href, '/guidance/{slug}/chapter/') or contains(@href, '/advice/{slug}/chapter/')"
nav_xpaths = (
f"//*[contains(concat(' ', normalize-space(@class), ' '), ' stacked-nav ')]//a[{chapter_path_match}]/@href",
f"//ul[contains(concat(' ', normalize-space(@class), ' '), ' nav-list ')]//li//a[{chapter_path_match}]/@href",
)
- return first_matching_urls(html_text, xpaths=nav_xpaths, base_url=BASE_URL)
+ doc = lxml_html.fromstring(html_text)
+ rejected: list[OmittedSection] = []
+ for xpath in nav_xpaths:
+ raw_urls = doc.xpath(xpath)
+ if not raw_urls:
+ continue
+ authority_accepted = absolute_unique_urls(raw_urls, base_url=BASE_URL, allowed_hosts=_NICE_HOSTS)
+ accepted = [url for url in authority_accepted if _is_scoped_chapter_url(url, slug)]
+ for raw_url in raw_urls:
+ normalized = absolute_unique_urls([raw_url], base_url=BASE_URL, allowed_hosts=_NICE_HOSTS)
+ if normalized and _is_scoped_chapter_url(normalized[0], slug):
+ continue
+ normalized_rejection = absolute_unique_urls([raw_url], base_url=BASE_URL)
+ rejected_url = normalized_rejection[0] if normalized_rejection else raw_url
+ reason = "out_of_scope_url" if normalized else "unsafe_url"
+ rejected.append(OmittedSection(url=rejected_url, reason=reason))
+ if accepted:
+ return _ChapterLinkDiscovery(accepted=tuple(accepted), rejected=tuple(dict.fromkeys(rejected)))
+ return _ChapterLinkDiscovery(accepted=(), rejected=tuple(dict.fromkeys(rejected)))
+
+
+def _is_scoped_chapter_url(url: str, slug: str) -> bool:
+ path = urlparse(url).path.lower()
+ return path.startswith(f"/guidance/{slug}/chapter/") or path.startswith(f"/advice/{slug}/chapter/")
def _overview_markdown(html_text: str, *, ref: GuidanceRef, link_mode: LinkMode) -> str:
@@ -283,7 +336,19 @@ def build_guideline_text(
link_mode: Whether links are kept as markdown links or stripped to their
visible text (default: LinkMode.KEEP).
"""
- overview = client.get(ref.page_url)
+ receipt = _build_guideline_receipt(client, ref, link_mode=link_mode)
+ return receipt.content, receipt.section_count, receipt.title
+
+
+def _build_guideline_receipt(
+ client: httpx.Client,
+ ref: GuidanceRef,
+ *,
+ link_mode: LinkMode = LinkMode.KEEP,
+) -> GuidelineExtractionReceipt:
+ """Scrape a guideline and account for retained and omitted sections."""
+ page_url = _validated_page_url(ref)
+ overview = client.get(page_url)
overview.raise_for_status()
title = (
ref.title
@@ -294,28 +359,61 @@ def build_guideline_text(
suffixes=(" | Guidance | NICE", " | Advice | NICE"),
)
)
- chapter_urls = _chapter_links(overview.text, ref.slug)
- if not chapter_urls:
+ chapter_links = _chapter_links(overview.text, ref.slug)
+ if not chapter_links.accepted:
raise NiceFetchError(f"No chapters found for guidance '{ref.ref}'")
sections: list[str] = []
+ retained_urls: list[str] = []
+ omitted_sections = list(chapter_links.rejected)
overview_markdown = _overview_markdown(overview.text, ref=ref, link_mode=link_mode)
if overview_markdown:
sections.append(overview_markdown)
+ retained_urls.append(page_url)
+ else:
+ overview_reason = (
+ "overview_omitted_by_source_policy" if _ref_prefix(ref.ref) in {"ES", "MIB"} else "empty_overview"
+ )
+ omitted_sections.append(OmittedSection(url=page_url, reason=overview_reason))
- for url in chapter_urls:
+ for url in chapter_links.accepted:
if _is_skipped_chapter(url):
+ omitted_sections.append(OmittedSection(url=url, reason="non_content_chapter"))
continue
chapter = client.get(url)
chapter.raise_for_status()
markdown = _chapter_markdown(chapter.text, link_mode=link_mode)
if markdown:
sections.append(markdown)
+ retained_urls.append(url)
+ else:
+ omitted_sections.append(OmittedSection(url=url, reason="empty_chapter"))
content = "\n\n".join(sections).strip()
if not content:
raise NiceFetchError(f"No readable content for guidance '{ref.ref}'")
- return content, len(sections), title
+ transformations = [
+ "canonicalize_chapter_urls",
+ "convert_html_to_markdown",
+ "drop_numeric_citation_markers",
+ "filter_overview_boilerplate",
+ "normalize_numbered_headings",
+ "normalize_whitespace",
+ ]
+ if link_mode is LinkMode.STRIP:
+ transformations.append("strip_links")
+ receipt = GuidelineExtractionReceipt(
+ content=content,
+ section_count=len(sections),
+ title=title,
+ discovered_count=1 + len(chapter_links.accepted) + len(chapter_links.rejected),
+ retained_urls=tuple(retained_urls),
+ omitted_sections=tuple(omitted_sections),
+ transformations=tuple(transformations),
+ )
+ if not receipt.is_accounted():
+ raise AssertionError(f"Incomplete extraction receipt for guidance '{ref.ref}'")
+ return receipt
def scrape_guideline(
@@ -332,19 +430,25 @@ def scrape_guideline(
link_mode: Whether links are kept as markdown links or stripped to their
visible text (default: LinkMode.KEEP).
"""
- content, section_count, title = build_guideline_text(client, ref, link_mode=link_mode)
+ receipt = _build_guideline_receipt(client, ref, link_mode=link_mode)
return ScrapedDocument(
source="nice",
external_id=f"nice-{ref.slug}",
- title=title,
- url=ref.page_url,
- content=content,
- section_count=section_count,
+ title=receipt.title,
+ url=_validated_page_url(ref),
+ content=receipt.content,
+ section_count=receipt.section_count,
metadata={
"ref": ref.ref,
"slug": ref.slug,
"prefix": _ref_prefix(ref.ref),
"scrape_strategy": _scrape_strategy(ref),
+ "retained_urls": list(receipt.retained_urls),
+ "omitted_sections": [
+ {"url": omitted.url, "reason": omitted.reason} for omitted in receipt.omitted_sections
+ ],
+ "transformations": list(receipt.transformations),
+ "discovered_section_count": receipt.discovered_count,
},
)
@@ -362,6 +466,13 @@ def _page_url(*, ref: str, slug: str) -> str:
return f"{BASE_URL}{_page_path(ref=ref, slug=slug)}"
+def _validated_page_url(ref: GuidanceRef) -> str:
+ validated = guidance_ref_from_url(ref.page_url)
+ if validated.ref != ref.ref.upper() or validated.slug != ref.slug.lower():
+ raise NiceFetchError(f"Guidance reference {ref.ref!r} does not match its NICE page URL {ref.page_url!r}")
+ return validated.page_url
+
+
def _scrape_strategy(ref: GuidanceRef) -> str:
prefix = _ref_prefix(ref.ref)
if prefix in _ADVICE_PREFIXES:
@@ -371,6 +482,7 @@ def _scrape_strategy(ref: GuidanceRef) -> str:
return _NiceScrapeStrategy.CHAPTER.value
+# lean-spec: AMFV.Scraping.ListingAccounting.source_total_is_not_exact_after_filtering
def scrape_nice(
*,
documents: int | None,
@@ -386,6 +498,9 @@ def scrape_nice(
link_mode: Whether links are kept as markdown links or stripped to their
visible text (default: LinkMode.KEEP).
url: NICE source URL to scrape as a single document (default: None).
+
+ Listing runs report an indeterminate total because NICE's source count
+ includes guidance types filtered out by AMFV. Single-URL runs report one.
"""
if url is not None:
@@ -397,9 +512,8 @@ def scrape_url() -> Iterable[ScrapedDocument]:
with default_client() as client:
first_page = list_published_guidance(client, page=1)
- total = first_page.total if documents is None or first_page.total is None else min(documents, first_page.total)
return ScrapeRun(
- total=total,
+ total=None,
documents=scrape_listing_documents(
documents=documents,
client_factory=default_client,
@@ -414,11 +528,13 @@ def scrape_url() -> Iterable[ScrapedDocument]:
__all__ = [
"BASE_URL",
"DOCUMENT_DELAY_SECONDS",
+ "GuidelineExtractionReceipt",
"GuidanceListingPage",
"NICE_DATASET_DISPLAY_NAME",
"NICE_DATASET_NAME",
"GuidanceRef",
"NiceFetchError",
+ "OmittedSection",
"build_guideline_text",
"guidance_ref_from_url",
"list_published_guidance",
diff --git a/datasets/test/test_scraping_html.py b/datasets/test/test_scraping_html.py
index d34a8fa3..3a7f3428 100644
--- a/datasets/test/test_scraping_html.py
+++ b/datasets/test/test_scraping_html.py
@@ -23,6 +23,32 @@ def test_absolute_unique_urls_normalizes_relative_urls() -> None:
) == ["https://example.org/guidance/ng1", "https://example.org/guidance/ng2"]
+# lean-spec-test: AMFV.Scraping.UrlPolicy.accepted_chapter_has_allowed_authority
+def test_absolute_unique_urls_enforces_authority_policy() -> None:
+ """Host policies reject lookalike paths, credentials, and unusual ports."""
+ assert absolute_unique_urls(
+ [
+ "/guidance/ng1/chapter/recommendations?tab=contents",
+ "https://evil.example/guidance/ng1/chapter/recommendations",
+ "https://user@www.nice.org.uk/guidance/ng1/chapter/credentials",
+ "https://www.nice.org.uk:444/guidance/ng1/chapter/port",
+ "javascript:/guidance/ng1/chapter/script",
+ "https://www.nice.org.uk/guidance/ng1/chapter/recommendations#duplicate",
+ ],
+ base_url="https://www.nice.org.uk",
+ allowed_hosts=("nice.org.uk", "www.nice.org.uk"),
+ ) == ["https://www.nice.org.uk/guidance/ng1/chapter/recommendations"]
+
+
+def test_absolute_unique_urls_canonicalization_is_idempotent() -> None:
+ """Canonical URLs remain unchanged when normalized again."""
+ first_pass = absolute_unique_urls(
+ ["/guidance/ng1?tab=contents#heading"],
+ base_url="https://example.org",
+ )
+ assert absolute_unique_urls(first_pass, base_url="https://example.org") == first_pass
+
+
def test_first_matching_urls_uses_first_xpath_with_matches() -> None:
"""URL extraction falls back across XPath selectors."""
html_text = """
@@ -39,6 +65,23 @@ def test_first_matching_urls_uses_first_xpath_with_matches() -> None:
) == ["https://example.org/first"]
+def test_first_matching_urls_falls_back_when_policy_rejects_first_xpath() -> None:
+ """Rejected candidates do not prevent an accepted fallback selector."""
+ html_text = """
+
+
+ Good
+
+ """
+
+ assert first_matching_urls(
+ html_text,
+ xpaths=("//nav/a/@href", "//main/a/@href"),
+ base_url="https://www.nice.org.uk",
+ allowed_hosts=("nice.org.uk", "www.nice.org.uk"),
+ ) == ["https://www.nice.org.uk/guidance/ng1/chapter/good"]
+
+
def test_document_title_uses_heading_and_strips_suffix() -> None:
"""Document titles are read from common title locations."""
html_text = "