diff --git a/Makefile b/Makefile index 9b21b1f..8ac1253 100644 --- a/Makefile +++ b/Makefile @@ -30,7 +30,8 @@ SEARCH_Q ?= computer ingest-xml-attached ingest-abstract \ ingest-grok ingest-grok-media \ ingest-self ingest-self-providence ingest-git ingest-hg \ - verify search stats test test-live docs chain-check chain-check-shards \ + verify search stats test test-live docs docs-api docs-api-clean \ + chain-check chain-check-shards \ falsify burn burn-kindergarten inspect bootstrap-crawler test-crawler crawl-ingest \ recrawl-check bench-qa clean clean-db clean-data help @@ -499,6 +500,12 @@ docs/diagrams/%.svg: docs/diagrams/%.dot docs: $(DOT_PNGS) $(DOT_SVGS) ## render docs/diagrams/*.dot -> .png + .svg via graphviz +docs-api: ## generate Sphinx API reference from docstrings (output: docs/_source/_build/html/) + $(PY) -m sphinx.cmd.build -b html docs/_source docs/_source/_build/html + +docs-api-clean: ## remove Sphinx build artifacts + rm -rf docs/_source/_build/ + # Reproducible micro-benchmark over a fixed slice of cur. Lets you compare # ETL throughput across configs and catches regressions on optimization # work. Override BENCH_DOCS=N (default 5000). diff --git a/docs/_source/Makefile b/docs/_source/Makefile new file mode 100644 index 0000000..4d877a5 --- /dev/null +++ b/docs/_source/Makefile @@ -0,0 +1,18 @@ +# Sphinx documentation build Makefile + +.PHONY: help clean html text + +help: + @echo "Sphinx documentation build targets:" + @echo " make html - build HTML documentation" + @echo " make text - build text documentation" + @echo " make clean - remove build artifacts" + +html: + sphinx-build -b html . _build/html + +text: + sphinx-build -b text . _build/text + +clean: + rm -rf _build/ diff --git a/docs/_source/README.md b/docs/_source/README.md new file mode 100644 index 0000000..dda2711 --- /dev/null +++ b/docs/_source/README.md @@ -0,0 +1,60 @@ +# Aborist API Reference (Sphinx) + +This directory contains Sphinx configuration to generate API documentation from docstrings. + +## Build + +```bash +cd docs/_source +make html # Generate HTML (output: _build/html/) +make text # Generate text (output: _build/text/) +make clean # Remove build artifacts +``` + +Or directly: + +```bash +sphinx-build -b html . _build/html +``` + +## View + +After building, open `_build/html/index.html` in a browser. + +## Structure + +- `conf.py` — Sphinx configuration +- `index.rst` — Main table of contents +- `api/` — Module documentation (one .rst per module category) + - `substrate.rst` — Core data structures (merkle, document, wikitext) + - `storage.rst` — SQLite schema (store, ingest, evict) + - `retrieval.rst` — FTS5 search (search, sources, concepts) + - `qa.rst` — Q&A pipeline (runner, query, verify, evidence, etc.) + - `distill.rst` — Distillation (surface→core) + - `mesh.rst` — Federation (gossip-based sync) + - `cli.rst` — Command-line interface + +## What it replaces + +This generated documentation **replaces `docs/modules.md`** (1200+ lines of static API reference). The docstrings in code are the source of truth; Sphinx extracts them automatically. + +## Adding new modules + +1. Add a docstring to the module (module-level docstring at the top of `module.py`) +2. Add an `.rst` file in `api/` that includes the module with `automodule` directive +3. Reference it in `index.rst` +4. Rebuild with `make html` + +## Theme + +Uses **furo** theme (modern, responsive, search-enabled). + +## Autodoc directives + +The `.rst` files use Sphinx `automodule` to extract: +- Module docstrings +- Class docstrings + members +- Function signatures + docstrings +- Source code links (`:viewcode:` extension) + +See [Sphinx autodoc docs](https://www.sphinx-doc.org/en/master/usage/extensions/autodoc.html). diff --git a/docs/_source/_build/html/.buildinfo b/docs/_source/_build/html/.buildinfo new file mode 100644 index 0000000..9377929 --- /dev/null +++ b/docs/_source/_build/html/.buildinfo @@ -0,0 +1,4 @@ +# Sphinx build info version 1 +# This file records the configuration used when building these files. When it is not found, a full rebuild will be done. +config: 72d8748569a4fd9a0c87ddba8f0e6a2d +tags: 645f666f9bcd5a90fca523b33c5a78b7 diff --git a/docs/_source/_build/html/.doctrees/__intersphinx_cache__/python_objects.inv b/docs/_source/_build/html/.doctrees/__intersphinx_cache__/python_objects.inv new file mode 100644 index 0000000..2d66073 Binary files /dev/null and b/docs/_source/_build/html/.doctrees/__intersphinx_cache__/python_objects.inv differ diff --git a/docs/_source/_build/html/.doctrees/api/cli.doctree b/docs/_source/_build/html/.doctrees/api/cli.doctree new file mode 100644 index 0000000..6dee2a0 Binary files /dev/null and b/docs/_source/_build/html/.doctrees/api/cli.doctree differ diff --git a/docs/_source/_build/html/.doctrees/api/distill.doctree b/docs/_source/_build/html/.doctrees/api/distill.doctree new file mode 100644 index 0000000..965dee2 Binary files /dev/null and b/docs/_source/_build/html/.doctrees/api/distill.doctree differ diff --git a/docs/_source/_build/html/.doctrees/api/mesh.doctree b/docs/_source/_build/html/.doctrees/api/mesh.doctree new file mode 100644 index 0000000..5abdba3 Binary files /dev/null and b/docs/_source/_build/html/.doctrees/api/mesh.doctree differ diff --git a/docs/_source/_build/html/.doctrees/api/qa.doctree b/docs/_source/_build/html/.doctrees/api/qa.doctree new file mode 100644 index 0000000..d03fd84 Binary files /dev/null and b/docs/_source/_build/html/.doctrees/api/qa.doctree differ diff --git a/docs/_source/_build/html/.doctrees/api/retrieval.doctree b/docs/_source/_build/html/.doctrees/api/retrieval.doctree new file mode 100644 index 0000000..08bb7c9 Binary files /dev/null and b/docs/_source/_build/html/.doctrees/api/retrieval.doctree differ diff --git a/docs/_source/_build/html/.doctrees/api/storage.doctree b/docs/_source/_build/html/.doctrees/api/storage.doctree new file mode 100644 index 0000000..cbcbac1 Binary files /dev/null and b/docs/_source/_build/html/.doctrees/api/storage.doctree differ diff --git a/docs/_source/_build/html/.doctrees/api/substrate.doctree b/docs/_source/_build/html/.doctrees/api/substrate.doctree new file mode 100644 index 0000000..c06778f Binary files /dev/null and b/docs/_source/_build/html/.doctrees/api/substrate.doctree differ diff --git a/docs/_source/_build/html/.doctrees/environment.pickle b/docs/_source/_build/html/.doctrees/environment.pickle new file mode 100644 index 0000000..75f2555 Binary files /dev/null and b/docs/_source/_build/html/.doctrees/environment.pickle differ diff --git a/docs/_source/_build/html/.doctrees/index.doctree b/docs/_source/_build/html/.doctrees/index.doctree new file mode 100644 index 0000000..bedb7c0 Binary files /dev/null and b/docs/_source/_build/html/.doctrees/index.doctree differ diff --git a/docs/_source/_build/html/_modules/aborist/cli.html b/docs/_source/_build/html/_modules/aborist/cli.html new file mode 100644 index 0000000..cad3123 --- /dev/null +++ b/docs/_source/_build/html/_modules/aborist/cli.html @@ -0,0 +1,4456 @@ + + + + + + + + aborist.cli - Aborist API Reference + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + + + +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for aborist.cli

+"""Aborist CLI: ingest / search / verify / stats."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import sys
+from pathlib import Path
+
+from aborist import __version__
+from aborist.ingest import ingest_source, verify_random_sample
+from aborist.progress import Progress
+from aborist.search import FTS5Backend
+from aborist.sources import WikipediaCurDump
+from aborist.store import (
+    DEFAULT_DB_PATH,
+    append_audit,
+    connect,
+    connect_query,
+    stats,
+    transaction,
+)
+
+
+def _cmd_ingest(args: argparse.Namespace) -> int:
+    """Ingest a corpus (Wikipedia, HTML, git, etc.) into a shard."""
+    if args.source in ("wikipedia_cur", "wikipedia_old"):
+        if not args.path:
+            print(f"--path is required for {args.source}", file=sys.stderr)
+            return 2
+        from aborist.sources import WikipediaSqlDump
+
+        table = "cur" if args.source == "wikipedia_cur" else "old"
+        shard = None
+        if args.shard:
+            rank_str, total_str = args.shard.split("/", 1)
+            shard = (int(rank_str), int(total_str))
+        src = WikipediaSqlDump(path=args.path, table=table, shard=shard)
+    elif args.source == "html":  # noqa: SIM114 — keep branch shape
+        try:
+            from aborist.sources import HtmlPageSource
+        except ImportError:
+            print(
+                "html source requires extras: pip install 'aborist[html]'",
+                file=sys.stderr,
+            )
+            return 2
+        urls: list[str] = list(args.url or [])
+        if args.urls_from:
+            urls.extend(
+                line.strip()
+                for line in Path(args.urls_from).read_text(encoding="utf-8").splitlines()
+                if line.strip() and not line.lstrip().startswith("#")
+            )
+        if not urls:
+            print("html source needs --url or --urls-from", file=sys.stderr)
+            return 2
+        src = HtmlPageSource(urls, respect_robots=not args.no_robots)
+    elif args.source in ("grok_export", "grok_media"):
+        if not args.path:
+            print(f"--path is required for {args.source}", file=sys.stderr)
+            return 2
+        from aborist.sources import GrokExportSource, GrokMediaPostsSource
+
+        cls = GrokExportSource if args.source == "grok_export" else GrokMediaPostsSource
+        src = cls(path=args.path)
+    elif args.source in ("wikipedia_xml", "wikipedia_xml_history", "wikipedia_abstract"):
+        if not args.path:
+            print(f"--path is required for {args.source}", file=sys.stderr)
+            return 2
+        from aborist.sources import WikipediaAbstractDump, WikipediaXmlDump
+
+        if args.source == "wikipedia_abstract":
+            src = WikipediaAbstractDump(path=args.path)
+        else:
+            shard = None
+            if args.shard:
+                rank_str, total_str = args.shard.split("/", 1)
+                shard = (int(rank_str), int(total_str))
+            src = WikipediaXmlDump(
+                path=args.path,
+                shard=shard,
+                multi_revision=(args.source == "wikipedia_xml_history"),
+            )
+    elif args.source in ("git_repo", "hg_repo"):
+        if not args.path:
+            print(f"--path is required for {args.source}", file=sys.stderr)
+            return 2
+        from aborist.sources import GitRepoSource, MercurialRepoSource
+
+        cls = GitRepoSource if args.source == "git_repo" else MercurialRepoSource
+        src = cls(repo_path=args.path)
+    elif args.source == "providence":
+        # Self-reference: promote STRICT live providence_cache records
+        # past the kindergarten window into the document corpus.
+        # See docs/self-reference-design.md.
+        from aborist.sources.providence import (
+            DEFAULT_KINDERGARTEN_SECONDS,
+            ProvidenceSource,
+        )
+        from aborist.store import connect
+
+        # The source reads from the SAME shard it's writing into —
+        # promote each shard's own STRICT records to its own
+        # documents table. Cross-shard promotion runs as a separate
+        # invocation per shard.
+        target_db_for_read = args.db
+        if args.shards_dir and args.shard:
+            rank_str, total_str = args.shard.split("/", 1)
+            rank = int(rank_str)
+            total = int(total_str)
+            digits = max(3, len(str(total - 1)))
+            target_db_for_read = Path(args.shards_dir) / f"{rank:0{digits}d}.db"
+        if not target_db_for_read:
+            print("--db or --shards-dir + --shard required for providence source", file=sys.stderr)
+            return 2
+        kg_seconds = int(getattr(args, "kindergarten_seconds", None) or DEFAULT_KINDERGARTEN_SECONDS)
+        # Open a separate connection for reading; ingest opens its own
+        # write connection downstream.
+        read_conn = connect(target_db_for_read)
+        src = ProvidenceSource(read_conn, kindergarten_seconds=kg_seconds)
+    else:
+        print(f"unknown source: {args.source}", file=sys.stderr)
+        return 2
+
+    # Resolve target DB: if --shards-dir is set with --shard, write to a
+    # per-shard file. Each shard owns its own SQLite file, so N parallel
+    # ingests have ZERO writer-lock contention.
+    target_db = args.db
+    if args.shards_dir:
+        if not args.shard:
+            print(
+                "--shards-dir requires --shard rank/total",
+                file=sys.stderr,
+            )
+            return 2
+        rank_str, total_str = args.shard.split("/", 1)
+        rank = int(rank_str)
+        total = int(total_str)
+        shards_dir = Path(args.shards_dir)
+        shards_dir.mkdir(parents=True, exist_ok=True)
+        digits = max(3, len(str(total - 1)))
+        target_db = shards_dir / f"{rank:0{digits}d}.db"
+
+    progress: Progress | None = None
+    if not args.quiet:
+        prefix = ""
+        if args.shard:
+            prefix = f"[shard {args.shard}] "
+        progress = Progress(
+            interval=args.progress_interval,
+            total_estimate=args.total_estimate,
+            prefix=prefix,
+        )
+
+    conn = connect(target_db)
+    try:
+        result = ingest_source(
+            conn,
+            src,
+            chunker_name=args.chunker,
+            limit=args.limit,
+            batch_size=args.batch_size,
+            resume=args.resume,
+            progress=progress,
+        )
+    finally:
+        conn.close()
+    print(json.dumps(result.__dict__, indent=2, ensure_ascii=False))
+    return 0
+
+
+def _cmd_search(args: argparse.Namespace) -> int:
+    """Search the corpus with FTS5."""
+    conn = (
+        connect_query(args.db, shards_dir=args.global_shards_dir)
+        if args.global_shards_dir
+        else connect(args.db)
+    )
+    try:
+        backend = FTS5Backend(conn)
+        hits = backend.search(args.query, limit=args.limit)
+    finally:
+        conn.close()
+    if args.json:
+        print(
+            json.dumps(
+                [
+                    {
+                        "document_root": h.document_root,
+                        "document_uri": h.document_uri,
+                        "chunk_idx": h.chunk_idx,
+                        "snippet": h.snippet,
+                        "score": h.score,
+                        "audit_mode": h.audit_mode.value,
+                        "title": h.title,
+                    }
+                    for h in hits
+                ],
+                indent=2, ensure_ascii=False
+            )
+        )
+    else:
+        for h in hits:
+            print(f"[{h.audit_mode.value}] {h.score:7.3f}  {h.title or h.document_uri}")
+            print(f"    chunk {h.chunk_idx}  root={h.document_root[:16]}…")
+            print(f"    {h.snippet}")
+            print()
+    return 0
+
+
+def _cmd_verify(args: argparse.Namespace) -> int:
+    """Verify Q&A cache records and audit chains."""
+    conn = (
+        connect_query(args.db, shards_dir=args.global_shards_dir)
+        if args.global_shards_dir
+        else connect(args.db)
+    )
+    try:
+        result = verify_random_sample(conn, n=args.n)
+    finally:
+        conn.close()
+    print(json.dumps(result, indent=2, ensure_ascii=False))
+    return 0 if result["failed"] == 0 else 1
+
+
+def _cmd_distill(args: argparse.Namespace) -> int:
+    """Distill surface documents into compressed cores."""
+    from aborist.distill import get_distiller
+    from aborist.distill.runner import distill_existing
+    from aborist.store import discover_shards
+
+    try:
+        distiller = get_distiller(args.process)
+    except ValueError as e:
+        print(str(e), file=sys.stderr)
+        return 2
+
+    # Sharded mode: iterate over each shard's DB and distill in place.
+    # Cores stay in their source shard so the per-shard audit/derivation
+    # chains remain self-contained.
+    if args.global_shards_dir:
+        shard_paths = discover_shards(args.global_shards_dir)
+        if not shard_paths:
+            print(f"no shards in {args.global_shards_dir}", file=sys.stderr)
+            return 2
+
+        per_shard: list[dict] = []
+        totals = {
+            "scanned": 0,
+            "distilled": 0,
+            "skipped_existing": 0,
+            "skipped_cold": 0,
+            "skipped_empty": 0,
+        }
+        for sp in shard_paths:
+            conn = connect(sp)
+            try:
+                r = distill_existing(
+                    conn,
+                    distiller,
+                    kind=args.kind,
+                    source_type=args.source_type,
+                    limit=args.limit,
+                    chunker_name=args.chunker,
+                    batch_size=args.batch_size,
+                )
+            finally:
+                conn.close()
+            per_shard.append({"shard": sp.name, **r})
+            for k in totals:
+                totals[k] += r[k]
+        print(json.dumps({**totals, "shards": per_shard}, indent=2, ensure_ascii=False))
+        return 0
+
+    conn = connect(args.db)
+    try:
+        result = distill_existing(
+            conn,
+            distiller,
+            kind=args.kind,
+            source_type=args.source_type,
+            limit=args.limit,
+            chunker_name=args.chunker,
+            batch_size=args.batch_size,
+        )
+    finally:
+        conn.close()
+    print(json.dumps(result, indent=2, ensure_ascii=False))
+    return 0
+
+
+def _cmd_ask(args: argparse.Namespace) -> int:
+    """Ask a question against one document and get a grounded answer."""
+    import os
+
+    from aborist.qa import ask
+    from aborist.qa.client import OpenAICompatibleClient, StubClient
+
+    base_url = args.endpoint or os.environ.get(
+        "ABORIST_LLM_ENDPOINT", "https://hermes.ai.unturf.com/v1"
+    )
+    model = args.model or os.environ.get(
+        "ABORIST_LLM_MODEL",
+        "adamo1139/Hermes-3-Llama-3.1-8B-FP8-Dynamic",
+    )
+    revision = os.environ.get("ABORIST_LLM_REVISION", "")
+    quantization = os.environ.get("ABORIST_LLM_QUANTIZATION", "fp8-dynamic")
+    api_key = os.environ.get("ABORIST_LLM_API_KEY")
+
+    client: object
+    if args.dry_run:
+        client = StubClient(
+            answer=f"[STUB] would have answered '{args.question}' against root {args.document_root[:16]}…"
+        )
+    else:
+        client = OpenAICompatibleClient(base_url=base_url, api_key=api_key)
+
+    conn = (
+        connect_query(args.db, shards_dir=args.global_shards_dir)
+        if args.global_shards_dir
+        else connect(args.db)
+    )
+    # Per-call policy override for --answer-mode. Other knobs flow from
+    # DEFAULT_POLICY.
+    from aborist.qa.runner import DEFAULT_POLICY as _DEFAULT_ASK_POLICY
+    call_policy = dict(_DEFAULT_ASK_POLICY)
+    if getattr(args, "answer_mode", None):
+        call_policy["answer_mode"] = args.answer_mode
+    try:
+        result = ask(
+            conn,
+            document_root=args.document_root,
+            question=args.question,
+            client=client,
+            model_id=model,
+            revision=revision,
+            quantization=quantization,
+            policy=call_policy,
+        )
+    finally:
+        conn.close()
+    print(json.dumps(result, indent=2, ensure_ascii=False))
+    return 0 if result.get("status") in ("cache_hit", "cache_miss_then_written") else 1
+
+
+def _cmd_query(args: argparse.Namespace) -> int:
+    """Multi-source RAG: question -> top-K corpus docs -> Hermes -> cache."""
+    import os
+
+    from aborist.qa.client import OpenAICompatibleClient, StubClient
+    from aborist.qa.query import query
+
+    base_url = args.endpoint or os.environ.get(
+        "ABORIST_LLM_ENDPOINT", "https://hermes.ai.unturf.com/v1"
+    )
+    model = args.model or os.environ.get(
+        "ABORIST_LLM_MODEL",
+        "adamo1139/Hermes-3-Llama-3.1-8B-FP8-Dynamic",
+    )
+    revision = os.environ.get("ABORIST_LLM_REVISION", "")
+    quantization = os.environ.get("ABORIST_LLM_QUANTIZATION", "fp8-dynamic")
+    api_key = os.environ.get("ABORIST_LLM_API_KEY")
+
+    client: object
+    if args.dry_run:
+        client = StubClient(
+            answer="[STUB] dry-run: would have asked Hermes-3 with the assembled context."
+        )
+    else:
+        client = OpenAICompatibleClient(base_url=base_url, api_key=api_key)
+
+    qa_db = args.qa_db
+    if qa_db is None:
+        if args.global_shards_dir:
+            qa_db = Path(args.global_shards_dir) / "qa.db"
+        else:
+            qa_db = Path.home() / ".aborist" / "qa.db"
+    qa_db = Path(qa_db)
+
+    shards_dir = (
+        Path(args.global_shards_dir) if args.global_shards_dir else None
+    )
+    single_db = None if shards_dir else args.db
+
+    # Apply per-call policy overrides (question_dedup, repair, answer_mode)
+    # on top of the default. fidelity is a function-level kwarg, not in
+    # the policy.
+    from aborist.qa.query import DEFAULT_QUERY_POLICY
+    call_policy = dict(DEFAULT_QUERY_POLICY)
+    if getattr(args, "question_dedup", None):
+        call_policy["question_dedup"] = args.question_dedup
+    if getattr(args, "answer_mode", None):
+        call_policy["answer_mode"] = args.answer_mode
+    if getattr(args, "repair", False):
+        # Mechanical-only repair when --repair is set; --repair-reprompts
+        # adds the optional re-prompt tier on top. Both default off so
+        # `make query` stays single-shot unless a knob is flipped.
+        call_policy["repair_enabled"] = True
+        call_policy["repair_max_reprompts"] = max(
+            0, int(getattr(args, "repair_reprompts", 0))
+        )
+    # Ticket #000008 Phase 4 — quantifier-guard CLI overrides.
+    # Six-level disable hierarchy at Levels 2 (per-call CLI flag)
+    # via these flags; Level 3 policy fields are reachable via the
+    # underlying policy dict.
+    if getattr(args, "no_quantifier_guard", False):
+        call_policy["quantifier_guard_enabled"] = False
+    if getattr(args, "allow_broad", False):
+        # Keeps the classifier on (telemetry stays useful) but
+        # zeroes out the apply_caps gate so broad shapes don't
+        # get clipped during emergent search.
+        call_policy["quantifier_guard_apply_caps"] = False
+    if getattr(args, "reject_broad", False):
+        # Phase 4 reject-broad: the actual rejection happens inside
+        # query() via the policy field; this CLI flag just sets the
+        # field. See aborist/qa/query.py for the early-return path.
+        call_policy["quantifier_reject_broad"] = True
+    if getattr(args, "apply_quantifier_caps", False):
+        # Operator opts in to flipping the dry-run gate per-call.
+        # Bench-first per §10.11.3 — this flag is the path from
+        # dry-run to live-cap.
+        call_policy["quantifier_guard_apply_caps"] = True
+    # Ticket #000010 — meta-cognition CLI overrides.
+    if getattr(args, "no_preflight", False):
+        call_policy["metacognition_enabled"] = False
+    if getattr(args, "block_on_contradiction", False):
+        # Strict mode: hard-block on lexical contradictions instead
+        # of label-only.
+        call_policy["metacognition_block_on_contradiction"] = True
+    if getattr(args, "soft_preflight", False):
+        # Ticket #000011 — opt-in to model-assisted soft preflight
+        # sidecar. Adds one short LLM round-trip; NEVER gates
+        # admissibility (D1 preserved).
+        call_policy["soft_preflight_enabled"] = True
+
+    result = query(
+        question=args.question,
+        qa_db=qa_db,
+        chat_client=client,
+        model_id=model,
+        revision=revision,
+        quantization=quantization,
+        shards_dir=shards_dir,
+        single_db=single_db,
+        top_k=args.top_k,
+        over_fetch=args.over_fetch,
+        max_context_chars=args.max_context_chars,
+        policy=call_policy,
+        fidelity=getattr(args, "fidelity", None),
+        burn_existing=bool(getattr(args, "burn", False)),
+        retrieval_keywords=getattr(args, "retrieval_keywords", None),
+    )
+
+    # Emit unfirehose-compatible session journal. One JSONL file per
+    # `make query` invocation, written to ~/.aborist/unfirehose/{slug}/
+    # {session_uuid}.jsonl. Unfirehose's native-harness watcher picks
+    # this up automatically (no registration). Failures here must NEVER
+    # break the query path — wrap in a broad except & swallow.
+    try:
+        _emit_query_journal(args.question, result, model)
+    except Exception:  # pragma: no cover — best-effort journaling
+        pass
+
+    if args.json:
+        print(json.dumps(result, indent=2, ensure_ascii=False))
+    else:
+        print(_render_query_human(result, args.question))
+    return (
+        0
+        if result.get("status") in ("cache_hit", "cache_miss_then_written")
+        else 1
+    )
+
+
+def _emit_query_journal(question: str, result: dict, model: str) -> None:
+    """Write one unfirehose/1.0 session for this query invocation."""
+    from aborist.journal import SessionWriter
+    timings = result.get("timings") or {}
+    answer = result.get("answer_text") or ""
+    aborist_meta = {
+        "audit_mode": result.get("audit_mode"),
+        "verifier_method": result.get("verifier_method"),
+        "n_quotes": result.get("n_quotes"),
+        "n_verified": result.get("n_verified"),
+        "cache_key": result.get("cache_key"),
+        "cache_status": result.get("status"),
+        "lookup_path": result.get("lookup_path"),
+        "violations": [
+            {"kind": v.get("kind"), "rationale": (v.get("rationale") or "")[:160]}
+            for v in (result.get("violations") or [])
+        ],
+        "sources": [
+            {"title": s.get("title"), "uri": s.get("document_uri"), "used": s.get("used"), "role": s.get("source_role")}
+            for s in (result.get("sources") or [])
+        ],
+        "timings_ms": timings,
+        "answer_mode": (result.get("policy") or {}).get("answer_mode"),
+    }
+    with SessionWriter(first_prompt=question) as s:
+        s.user_message(question)
+        s.assistant_message(
+            answer,
+            model=model,
+            provider="hermes",
+            stop_reason="end_turn",
+            duration_ms=int(timings.get("total_ms") or 0) or None,
+            aborist_meta=aborist_meta,
+        )
+
+
+# Soft-demote violation kinds that demote STRICT to HYBRID without
+# rejecting the pointer outright. WARRANT_MISSING / TITLE_MISMATCH /
+# DEFLECTION_DETECTED handled separately as hard demotes (their
+# presence determines POINTER-LINKED vs ANCHOR-WARRANTED).
+_SOFT_DEMOTE_VIOLATION_KINDS = frozenset({
+    "LAZY_ANCHOR_DEMOTED",
+    "POINTER_OVERFLOW_TRIMMED",
+    "TOO_MANY_CLAIMS",
+    "BARE_NAME_CLAIM",
+    # FORMAT_COLLAPSED — model abandoned the claim_lattice_pointer
+    # protocol (multi-line prose, zero [E\d+] tags). Soft-demotes
+    # STRICT → HYBRID; pairs with the bottom UNGROUNDED rung when the
+    # parser found nothing groundable, but at least surfaces the
+    # collapse cause to the operator at audit-line glance.
+    "FORMAT_COLLAPSED",
+    # Ticket #000008 Phase 4 — broad-quantifier soft-demotes (§10.3).
+    # Per §10.3 these stay as soft demotes (cap at ANCHOR-WARRANTED)
+    # rather than minting a new audit_mode token. The audit-line tail
+    # (rendered by _render_warrant_tail) names which one fired so an
+    # operator can tell at a glance.
+    "BROAD_QUANTIFIER_RUNAWAY",      # raw_line_count >> pointer_count
+    "BROAD_QUANTIFIER_CAP_APPLIED",  # preflight cap fired below default
+    "BROAD_QUANTIFIER_SCOPE_UNBOUND", # unbounded universal reached the LLM
+    # BROAD_QUANTIFIER_REJECTED is a HARD demote (UNGROUNDED via
+    # early-return) — listed here for completeness but doesn't
+    # belong in the soft-demote set.
+})
+
+
+def _ladder_rung_for_lattice(audit_mode: str, violations: list[dict] | None) -> str:
+    """Map (audit_mode, violations) → four-rung ladder for claim-lattice
+    methods. Renderer-only transformation; schema column unchanged.
+
+    The ladder names a strictly stronger property at each rung:
+
+        POINTER-LINKED       pointer/source/chunk verified;
+                             warrant either didn't apply or failed
+        ANCHOR-WARRANTED     pointer-linked AND cited evidence contains
+                             required anchors (warrant ran & passed);
+                             other soft-demote violations may be present
+        EVIDENCE-WARRANTED   anchor-warranted AND no soft demotes
+        UNGROUNDED           n_verified == 0 (existing audit_mode)
+
+    HYBRID adds `-PARTIAL` suffix to whichever rung applies.
+
+    The rung logic uses the violations list as the signal — no new
+    verifier output field needed. Three discriminators:
+      - WARRANT_MISSING in violations → warrant ran and failed at
+        least one claim → POINTER-LINKED (pointer ok but warrant
+        didn't anchor the claim)
+      - any soft-demote kind in violations → ANCHOR-WARRANTED (the
+        rung is reached but other demotes pulled it back from STRICT)
+      - no warrant miss AND no soft demotes → EVIDENCE-WARRANTED
+    """
+    if audit_mode == "UNGROUNDED":
+        return "UNGROUNDED"
+    kinds = {v.get("kind") for v in (violations or [])}
+    # Hard demotes: WARRANT_MISSING, TITLE_MISMATCH, DEFLECTION_DETECTED
+    # all indicate the citation is structurally misaligned with the
+    # claim or the answer is structurally off-topic — pointer resolved
+    # but the cited evidence (span, source, or whole answer) doesn't
+    # actually support the user's question. All three drop to
+    # POINTER-LINKED. (DEFLECTION_DETECTED was previously a soft demote
+    # to ANCHOR-WARRANTED; the comeliness/fetish/investitures emergent
+    # case showed that "the model totally shifted topic but anchored
+    # the new topic" earned ANCHOR-WARRANTED unfairly. Off-topic
+    # belongs at the lower rung.)
+    if (
+        "WARRANT_MISSING" in kinds
+        or "TITLE_MISMATCH" in kinds
+        or "DEFLECTION_DETECTED" in kinds
+    ):
+        rung = "POINTER-LINKED"
+    elif kinds & _SOFT_DEMOTE_VIOLATION_KINDS:
+        rung = "ANCHOR-WARRANTED"
+    else:
+        rung = "EVIDENCE-WARRANTED"
+    if audit_mode == "HYBRID":
+        rung = rung + "-PARTIAL"
+    return rung
+
+
+def _render_audit_label(
+    audit_mode: str,
+    verifier_method: str,
+    violations: list[dict] | None = None,
+) -> str:
+    """Map (audit_mode, verifier_method, violations) → human-readable
+    display label.
+
+    Schema-level audit_mode names what the lexical verifier could
+    confirm; the display label names what THAT means in honesty
+    terms. STRICT in claim_lattice mode = "every pointer resolves
+    to a valid evidence object whose source_role is allowed AND
+    every cited span passes the citation-overlap coverage check."
+    That is NOT full semantic entailment. The display label spells
+    out the actual property so users don't read STRICT as "the
+    answer is correct."
+
+    Four-rung ladder for claim-lattice methods (per ticket #000005):
+
+        POINTER-LINKED       pointer verified; warrant either didn't
+                             apply or failed for some claim
+        ANCHOR-WARRANTED     pointer-linked + warrant passed where
+                             it ran; other soft demotes may apply
+        EVIDENCE-WARRANTED   anchor-warranted + no soft demotes
+        UNGROUNDED           no verified pairs
+
+    HYBRID audits get a `-PARTIAL` suffix on whichever rung applies.
+
+    Plus the verifier-method tail (`· via claim_lattice`,
+    `· via claim_lattice_pointer`) so the user can see WHICH
+    verifier path produced the verdict.
+
+    For quote / span / entity / paraphrase mode the audit_mode
+    labels carry less risk of overclaiming (they verify against
+    pinned spans, not synthesis) — keep them as-is.
+
+    `violations` defaults to None for backward compatibility with
+    callers that don't have access to the violation list. With
+    None, the ladder falls back to EVIDENCE-WARRANTED (the most
+    optimistic rung) — operators get the same surface as before
+    until callers thread violations through.
+    """
+    is_claim_lattice = verifier_method.startswith("claim_lattice")
+    if is_claim_lattice:
+        rung = _ladder_rung_for_lattice(audit_mode, violations)
+        return f"{rung} · via {verifier_method}"
+    # Quote / span / entity / paraphrase: keep audit_mode as the
+    # primary token; append method for clarity.
+    return f"{audit_mode} · via {verifier_method}"
+
+
+def _render_warrant_tail(result: dict) -> str:
+    """Append a tail to the audit-line label that names the specific
+    warrant failure mode when one fired. Surfaces the cap reason at
+    the user-facing layer without overloading audit_mode.
+
+    Two failure modes today:
+      - WARRANT_MISSING: relation/date/etc. anchor extracted from
+        claim doesn't appear in any cited span (per-span check).
+      - TITLE_MISMATCH: cited evidence's source title shares no
+        content tokens with the claim (per-source check). 2026-05-02
+        spin-glass case: claim about spin glass cited to *Quantum
+        chromodynamics*.
+
+    When both fire on different claims of the same answer, surface
+    both tails so the operator sees the full picture."""
+    violations = result.get("violations") or []
+    kinds = {v.get("kind") for v in violations}
+    parts: list[str] = []
+    if "WARRANT_MISSING" in kinds:
+        parts.append("warrant missing")
+    if "TITLE_MISMATCH" in kinds:
+        parts.append("title mismatch")
+    if "FORMAT_COLLAPSED" in kinds:
+        parts.append("format collapsed")
+    # Ticket #000008 Phase 4 — broad-quantifier tails (§10.3 / §10.7).
+    # Each names what the preflight detected so operators don't have to
+    # parse violation lists by hand. Cap value comes from
+    # `claim_cap_applied` on the result when present.
+    if "BROAD_QUANTIFIER_REJECTED" in kinds:
+        parts.append("broad rejected")
+    elif "BROAD_QUANTIFIER_CAP_APPLIED" in kinds:
+        cap = result.get("claim_cap_applied")
+        parts.append(f"broad cap {cap}" if cap else "broad cap")
+    elif "BROAD_QUANTIFIER_SCOPE_UNBOUND" in kinds:
+        parts.append("broad unbounded")
+    elif "BROAD_QUANTIFIER_RUNAWAY" in kinds:
+        parts.append("broad runaway")
+    # Ticket #000010 — meta-cognition logical-status tails. Pulled
+    # from result["question_state"]["logical_statuses"] when present.
+    # Doesn't double up with the broad-quantifier tails above (those
+    # come from the verifier violation list, not the preflight).
+    qs = result.get("question_state") or {}
+    statuses = set(qs.get("logical_statuses") or [])
+    if "false_premise_suspected" in statuses:
+        parts.append("false premise")
+    if "contradictory_question" in statuses:
+        parts.append("contradictory")
+    if "stale_risk" in statuses:
+        parts.append("stale risk")
+    if "out_of_corpus_risk" in statuses:
+        parts.append("out of corpus")
+    if "reference_frame_ambiguous" in statuses:
+        parts.append("frame ambiguous")
+    # Ticket #000011 — soft preflight sidecar hint. Renders distinctly
+    # from the hard tails above so an operator can tell at a glance
+    # that the signal is advisory. Skips SOFT_DISABLED / SOFT_PARSE_FAIL
+    # / SOFT_WELL_FORMED (no actionable signal).
+    soft = result.get("soft_preflight_hint") or {}
+    soft_label = soft.get("classifier_label") or ""
+    if soft_label and soft_label not in (
+        "SOFT_DISABLED", "SOFT_PARSE_FAIL", "SOFT_WELL_FORMED",
+    ):
+        # Strip SOFT_ prefix + lowercase for tail readability
+        # (e.g. SOFT_FALSE_PREMISE_SUSPECTED → "false premise suspected").
+        readable = soft_label.removeprefix("SOFT_").lower().replace("_", " ")
+        parts.append(f"soft: {readable}")
+    if not parts:
+        return ""
+    return " · " + " · ".join(parts)
+
+
+def _maybe_render_json_envelope_as_bullets(answer: str) -> str:
+    """If `answer` is a claim_lattice JSON envelope, render bullets.
+
+    JSON-mode runs that land UNGROUNDED have no verified claims so the
+    runtime's bullet renderer produces empty text and `answer_text`
+    falls back to the raw model output — a `{"claims":[...]}` envelope.
+    The user then sees raw JSON for failed runs and bullets for
+    successful ones, which reads as inconsistent. Detect the JSON
+    shape, parse it (lenient), and render each claim's `text` as a
+    bullet line tagged with its evidence_ids so the surface stays
+    consistent across grounded / ungrounded outcomes.
+
+    Falls back to the raw input unchanged if:
+      - input doesn't look like JSON (no leading `{`)
+      - parse fails (lenient parser exception)
+      - parse succeeds but the shape isn't `{"claims": [...]}`
+    """
+    stripped = (answer or "").lstrip()
+    if not stripped.startswith("{") and not stripped.startswith("```"):
+        return answer
+    if "claims" not in stripped:
+        return answer
+    try:
+        from aborist.qa.verify import _lenient_json_parse
+        parsed, _fixups = _lenient_json_parse(answer)
+    except Exception:
+        return answer
+    if not isinstance(parsed, dict):
+        return answer
+    raw_claims = parsed.get("claims")
+    if not isinstance(raw_claims, list) or not raw_claims:
+        return answer
+    out_lines: list[str] = []
+    for c in raw_claims:
+        if not isinstance(c, dict):
+            continue
+        text = c.get("text") or ""
+        if not isinstance(text, str) or not text.strip():
+            continue
+        eids = c.get("evidence_ids") or []
+        if isinstance(eids, list) and eids:
+            ids = ",".join(str(x) for x in eids if isinstance(x, str))
+            out_lines.append(f"- {text.strip()}  [{ids}: unverified]")
+        else:
+            out_lines.append(f"- {text.strip()}")
+    return "\n".join(out_lines) if out_lines else answer
+
+
+def _render_query_human(result: dict, question: str) -> str:
+    """Pretty-print a query result for terminal reading.
+
+    Layout:
+        question
+        AUDIT_MODE  N/M verified  via verifier_method  Xs  (cached|fresh)
+
+        answer text...
+
+        sources (K):
+          [1] Title — host/path  (shard.db)
+          [2] ...
+
+        unverified (J):
+          - "..."
+
+        cache_key: 35ab7d33…   <run with --json for full record>
+
+    Errors / no-source paths fall back to a short status line.
+    """
+    status = result.get("status")
+    if status == "broad_quantifier_rejected":
+        # Phase 4 reject-broad early-return path. The result carries
+        # an answer_text with the rejection rationale + a violations
+        # list; render both so the operator sees WHY without --json.
+        answer_text = result.get("answer_text") or ""
+        violations = result.get("violations") or []
+        kind = next(
+            (v.get("kind") for v in violations
+             if v.get("kind") == "BROAD_QUANTIFIER_REJECTED"),
+            "BROAD_QUANTIFIER_REJECTED",
+        )
+        intensity = result.get("quantifier_intensity") or "?"
+        token = result.get("quantifier_matched_token") or "?"
+        cap = result.get("claim_cap_applied")
+        cap_str = f" · cap was {cap}" if cap else ""
+        return (
+            f"{question}\n"
+            f"  UNGROUNDED · via {kind} · {intensity} (\"{token}\")"
+            f"{cap_str}  0/0  0.0s  (preflight)\n\n"
+            f"{answer_text}"
+        )
+    if status not in ("cache_hit", "cache_miss_then_written"):
+        msg = result.get("msg") or status or "unknown error"
+        return f"  {status or 'error'}: {msg}"
+
+    audit = result.get("audit_mode", "?")
+    n_quotes = result.get("n_quotes", 0) or 0
+    n_verified = result.get("n_verified", 0) or 0
+    method = result.get("verifier_method", "?")
+    timings = result.get("timings") or {}
+    total_ms = timings.get("total_ms")
+    elapsed = f"{total_ms / 1000:.1f}s" if isinstance(total_ms, (int, float)) else "?"
+    cache_status = "cached" if status == "cache_hit" else "fresh"
+    lookup_path = result.get("lookup_path")
+    # Annotate cache_hits when they came from a fallback ckey, not the
+    # primary one — useful when an agent ran with fidelity=equivalence_class
+    # and reused another agent's record.
+    if lookup_path and lookup_path.endswith("_fallback"):
+        cache_status = f"cached via {lookup_path}"
+
+    # Render-layer label honesty: schema audit_mode (STRICT/HYBRID/
+    # UNGROUNDED) describes what the lexical verifier checked, not
+    # full semantic entailment. The display label combines audit_mode
+    # with verifier_method so the user sees what was actually
+    # verified. Four-rung ladder for claim-lattice methods (#000005):
+    #   POINTER-LINKED      pointer verified; warrant didn't apply
+    #                       or failed for some claim
+    #   ANCHOR-WARRANTED    pointer-linked + warrant passed where
+    #                       it ran; other soft demotes may apply
+    #   EVIDENCE-WARRANTED  anchor-warranted + no soft demotes
+    #   UNGROUNDED          no verified pairs
+    # Schema column stays unchanged; pure display.
+    display_label = _render_audit_label(audit, method, result.get("violations"))
+    warrant_tail = _render_warrant_tail(result)
+
+    lines: list[str] = []
+    lines.append(question)
+    lines.append(
+        f"  {display_label}{warrant_tail}  {n_verified}/{n_quotes}  "
+        f"{elapsed}  ({cache_status})"
+    )
+    lines.append("")
+
+    answer = result.get("answer_text") or ""
+    # When JSON-mode runs land UNGROUNDED, rendered_text is empty and
+    # answer_text falls back to the raw model output — a JSON envelope.
+    # Parse it and render each claim as a bullet so the user gets the
+    # same shape whether the run grounded or not. Falls back to raw
+    # display if parse fails or output isn't JSON-shaped.
+    answer = _maybe_render_json_envelope_as_bullets(answer)
+    lines.append(answer)
+    lines.append("")
+
+    sources = result.get("sources") or []
+    if sources:
+        lines.append(f"sources ({len(sources)}):")
+        for i, s in enumerate(sources, start=1):
+            uri = s.get("document_uri", "")
+            title = (s.get("title") or "").strip() or _short_path(uri)
+            shard = s.get("shard")
+            shard_part = f"  ({shard})" if shard else ""
+            # Render-layer source-role display + used/unused annotation
+            # (claim_lattice modes only — quote-mode results don't carry
+            # the per-source `used` flag). Honestly surfaces "the system
+            # retrieved noise but did not rely on it" so the user can
+            # see the model ignoring distractors instead of having to
+            # infer it. Pre-2026-05-01 the source list showed every
+            # retrieved doc indistinguishably.
+            role = s.get("source_role")
+            used = s.get("used")
+            pointer_ids = s.get("used_pointer_ids") or []
+            annotations: list[str] = []
+            if role:
+                annotations.append(role)
+            if used is True:
+                if pointer_ids:
+                    annotations.append(f"used ({','.join(pointer_ids)})")
+                else:
+                    annotations.append("used")
+            elif used is False:
+                annotations.append("unused")
+            if annotations:
+                annotation_part = " — " + " — ".join(annotations)
+            else:
+                annotation_part = ""
+            lines.append(
+                f"  [{i}] {title}{annotation_part}{_strip_scheme(uri)}{shard_part}"
+            )
+        lines.append("")
+
+        # Retrieval-purity one-line summary (claim_lattice modes only).
+        # "primary at #R · used N/M sources · M-N noise unused"
+        # Surfaces noise-resistance at a glance without making the
+        # user count rows themselves.
+        purity = result.get("retrieval_purity")
+        if purity:
+            primary_rank = purity.get("primary_rank") or 0
+            used = purity.get("used_sources", 0)
+            total = purity.get("total_sources", 0)
+            noise_unused = (
+                purity.get("noise_sources_count", 0)
+                - purity.get("noise_sources_used", 0)
+            )
+            primary_part = (
+                f"primary at #{primary_rank}"
+                if primary_rank > 0 else "no primary in top-K"
+            )
+            noise_part = (
+                f" · {noise_unused} noise unused" if noise_unused else ""
+            )
+            lines.append(
+                f"  retrieval purity: {primary_part} · used "
+                f"{used}/{total} sources{noise_part}"
+            )
+            lines.append("")
+
+    partially = result.get("partially_verified_quotes") or []
+    if partially:
+        lines.append(f"partially grounded ({len(partially)}):")
+        for q in partially:
+            qtxt = q if len(q) <= 100 else q[:97] + "..."
+            lines.append(f'  - "{qtxt}"')
+        lines.append("")
+
+    unverified = result.get("unverified_quotes") or []
+    if unverified:
+        lines.append(f"unverified ({len(unverified)}):")
+        for q in unverified:
+            qtxt = q if len(q) <= 100 else q[:97] + "..."
+            lines.append(f'  - "{qtxt}"')
+        lines.append("")
+
+    # Anchor-smell sidecar (claim_lattice mode only). Soft signal —
+    # never persisted, never in cache_key. Surfaces when ≥50% of
+    # verified claim-pointer pairs share one pointer_id AND there
+    # are at least 3 verified pairs to compare; below 3 the ratio is
+    # vacuous (1/1 always = 1.00 even when nothing is wrong).
+    ratio = result.get("lazy_anchor_ratio")
+    distribution = result.get("pointer_id_distribution") or {}
+    total_pairs = sum(distribution.values()) if distribution else 0
+    if (
+        method == "claim_lattice"
+        and isinstance(ratio, (int, float))
+        and ratio >= 0.5
+        and total_pairs >= 3
+    ):
+        top_pid, top_count = max(distribution.items(), key=lambda kv: kv[1])
+        lines.append(
+            f"lazy-anchor smell: {top_count} of {total_pairs} verified "
+            f"pairs cite [{top_pid}] (ratio {ratio:.2f}); "
+            f"distinct pointers cited: {len(distribution)}"
+        )
+        lines.append("")
+
+    pc = result.get("prompt_chars") or {}
+    if pc:
+        # Compact one-liner — operator at a glance: did STRICT come
+        # from a tight prompt or a context-stuffed one?
+        lines.append(
+            f"capacity: prompt {pc.get('messages_total', 0):,} chars "
+            f"(sys {pc.get('system_prompt', 0):,} + "
+            f"reminder {pc.get('grounding_reminder', 0):,} + "
+            f"evidence {pc.get('evidence_or_context', 0):,} + "
+            f"question {pc.get('user_question', 0):,}) → "
+            f"answer {result.get('answer_chars', 0):,} chars"
+        )
+
+    # Per-phase timings — surfaces where the per-call cost lands.
+    # Hermes-bound queries should show `llm_ms` dominating; if
+    # search_ms or persist_ms creeps up that's a retrieval / WAL
+    # signal the operator wants visible. Cache-hit rows skip llm
+    # entirely so the breakdown also tells you which path you're
+    # paying for.
+    timings = result.get("timings") or {}
+    if timings:
+        parts: list[str] = []
+        order = (
+            ("cache_lookup_ms", "cache"),
+            ("search_ms", "search"),
+            ("context_ms", "context"),
+            ("llm_ms", "llm"),
+            ("persist_ms", "persist"),
+        )
+        for key, label in order:
+            v = timings.get(key)
+            if isinstance(v, (int, float)) and v > 0:
+                parts.append(f"{label} {v / 1000:.2f}s")
+        total = timings.get("total_ms")
+        if isinstance(total, (int, float)):
+            parts.append(f"**total {total / 1000:.2f}s**")
+        if parts:
+            lines.append("timings: " + " · ".join(parts))
+
+    # Reference-frame notes (Ticket #000002). When detect_frame
+    # classified the query as `reference`, surface the named work
+    # so an operator knows the substrate routed to a fictional
+    # source. Skipped for literal / no-phrase-route / ambiguous
+    # rows — the line only appears when there's something to say.
+    fd = result.get("frame_detection") or {}
+    if fd.get("kind") == "reference" and fd.get("reference_title"):
+        lines.append("")
+        lines.append(f"reference frame: {fd['reference_title']}")
+        if fd.get("reference_uri"):
+            lines.append(f"  cited as the named work in the answer")
+
+    cache_key = (result.get("cache_key") or "")[:8]
+    lines.append(f"cache_key: {cache_key}…   <run with --json for full record>")
+    return "\n".join(lines)
+
+
+def _strip_scheme(uri: str) -> str:
+    """`https://en.wikipedia.org/wiki/X` -> `en.wikipedia.org/wiki/X`."""
+    for prefix in ("https://", "http://"):
+        if uri.startswith(prefix):
+            return uri[len(prefix):]
+    return uri
+
+
+def _short_path(uri: str) -> str:
+    """Last URL segment as a fallback display name."""
+    s = _strip_scheme(uri).rstrip("/")
+    if "/" in s:
+        return s.rsplit("/", 1)[1]
+    return s
+
+
+def _cmd_inspect(args: argparse.Namespace) -> int:
+    """Sidecar diagnostic — pulls source chunks for a cache_key and
+    classifies each unverified span. Read-only; no audit events, no
+    providence_cache mutations.
+    """
+    from aborist.qa.inspect import inspect_cache_key
+
+    qa_db = args.qa_db
+    if qa_db is None:
+        qa_db = (
+            Path(args.global_shards_dir) / "qa.db"
+            if args.global_shards_dir
+            else Path.home() / ".aborist" / "qa.db"
+        )
+    shards_dir = (
+        Path(args.global_shards_dir) if args.global_shards_dir else None
+    )
+    single_db = None if shards_dir else Path(args.db) if args.db else None
+
+    result = inspect_cache_key(
+        args.cache_key,
+        qa_db=Path(qa_db),
+        shards_dir=shards_dir,
+        single_db=single_db,
+    )
+
+    if args.json:
+        print(json.dumps(result, indent=2, ensure_ascii=False))
+    else:
+        print(_render_inspect_human(result))
+    return 0 if result.get("status") == "ok" else 1
+
+
+def _render_inspect_human(result: dict) -> str:
+    """Pretty-print an inspect result so an operator can scan
+    paraphrase vs invention vs trailing-artifact at a glance."""
+    if result.get("status") != "ok":
+        return f"  {result.get('status', 'error')}: cache_key={result.get('cache_key', '?')}"
+
+    rec = result["record"]
+    ctx = result["context"]
+    sources = result["sources"]
+    diagnoses = result["unverified"]
+
+    lines: list[str] = []
+    lines.append(rec["question_text"])
+    lines.append(
+        f"  {rec['audit_mode']}  {rec['n_verified']}/{rec['n_quotes']} verified  "
+        f"via {rec['verifier_method']}  state={rec['falsification_state']}"
+    )
+    lines.append("")
+    lines.append(
+        f"context: {ctx['raw_chars']:,} raw -> {ctx['base_chars']:,} base "
+        f"(wikitext-strip {'on' if ctx['wikitext_strip_active'] else 'off'})"
+    )
+    if sources:
+        lines.append(f"sources ({len(sources)}):")
+        for i, s in enumerate(sources, start=1):
+            lines.append(
+                f"  [{i}] {s.get('title') or '(untitled)'} — "
+                f"{s.get('chunk_count', '?')} chunks, "
+                f"{s.get('raw_chars', 0):,} chars"
+            )
+    lines.append("")
+
+    if not diagnoses:
+        lines.append("(no unverified spans)")
+        return "\n".join(lines)
+
+    lines.append(f"unverified diagnoses ({len(diagnoses)}):")
+    for i, d in enumerate(diagnoses, start=1):
+        span = d.get("span", "")
+        diag = d.get("diagnosis", "?")
+        lines.append("")
+        lines.append(f"  [{i}] {diag}")
+        lines.append(f"      span: {_short(span, 140)}")
+        if diag == "trailing_artifact":
+            lines.append(f"      matched_prefix_chars: {d.get('matched_prefix_chars')}")
+            lines.append(f"      trailing_artifact: {_short(d.get('trailing_artifact', ''), 100)}")
+        elif diag == "synthetic_elision_inside_quote":
+            lines.append(
+                f"      [...] inserted by model — "
+                f"{d.get('prefix_chars', 0)} prefix chars "
+                f"({'in source' if d.get('prefix_in_source') else 'NOT in source'}), "
+                f"{d.get('suffix_chars', 0)} suffix chars "
+                f"({'in source' if d.get('suffix_in_source') else 'NOT in source'})"
+            )
+        elif diag == "interior_elision":
+            lines.append(
+                f"      matched: {d.get('matched_prefix_chars')} prefix + "
+                f"{d.get('matched_suffix_chars')} suffix chars (parenthetical aside dropped)"
+            )
+            lines.append(f"      dropped_aside: {_short(d.get('dropped_aside', ''), 120)}")
+        elif diag in ("paraphrase", "partial_paraphrase"):
+            lines.append(f"      token_coverage: {d.get('token_coverage')}")
+            counts = d.get("token_counts", {})
+            if counts:
+                top = ", ".join(f"{k}×{v}" for k, v in list(counts.items())[:6])
+                lines.append(f"      tokens_in_base: {top}")
+            missing = d.get("missing_tokens", [])
+            if missing:
+                lines.append(f"      missing_tokens: {missing[:8]}")
+        elif diag == "no_overlap":
+            lines.append(f"      tokens_checked: {d.get('tokens_checked', '?')}")
+            lines.append(f"      tokens_present: {d.get('tokens_present', '?')}")
+        repair = d.get("repair")
+        if repair:
+            action = repair.get("action", "?")
+            reason = repair.get("reason", "")
+            line = f"      repair: {action}"
+            if reason:
+                line += f"  ({reason})"
+            lines.append(line)
+    return "\n".join(lines)
+
+
+def _short(s: str, n: int) -> str:
+    """Truncate string to n chars with ellipsis."""
+    return s if len(s) <= n else s[: n - 3] + "..."
+
+
+def _falsify_cache_key(
+    cache_key_value: str,
+    *,
+    state: str,
+    reason: str,
+    by_actor: str,
+    shards_dir: Path | None,
+    db_path: Path | None,
+) -> dict:
+    """Mark a providence_cache record as failed/stale/quarantined across shards.
+
+    Searches every shard for the cache_key (it lives in exactly one).
+    Updates the row's falsification_state, appends a falsification log
+    entry, and writes a 'falsify' audit event so the chain records the act.
+    """
+    import time as _time
+
+    from aborist.store import append_audit, discover_shards, transaction
+
+    if state not in ("failed", "stale", "quarantined"):
+        return {"status": "invalid_state", "value": state}
+
+    paths: list[Path] = (
+        discover_shards(shards_dir) if shards_dir else [Path(db_path)]
+    )
+
+    for sp in paths:
+        c = connect(sp)
+        try:
+            row = c.execute(
+                "SELECT cache_key, falsification_state FROM providence_cache "
+                "WHERE cache_key = ?",
+                (cache_key_value,),
+            ).fetchone()
+            if row is None:
+                continue
+            now = int(_time.time())
+            with transaction(c):
+                event_hash = append_audit(
+                    c,
+                    event_type="falsify",
+                    subject_root=cache_key_value,
+                    body={
+                        "cache_key": cache_key_value,
+                        "from_state": row["falsification_state"],
+                        "to_state": state,
+                        "reason": reason,
+                        "by_actor": by_actor,
+                    },
+                    ts=now,
+                )
+                c.execute(
+                    "UPDATE providence_cache "
+                    "SET falsification_state = ?, audit_event_hash = ? "
+                    "WHERE cache_key = ?",
+                    (state, event_hash, cache_key_value),
+                )
+                c.execute(
+                    "INSERT INTO falsifications "
+                    "(cache_key, state, reason, by_actor, at, audit_event_hash) "
+                    "VALUES (?, ?, ?, ?, ?, ?)",
+                    (cache_key_value, state, reason, by_actor, now, event_hash),
+                )
+            return {
+                "status": "falsified",
+                "cache_key": cache_key_value,
+                "shard": sp.name,
+                "from_state": row["falsification_state"],
+                "to_state": state,
+                "reason": reason,
+                "by_actor": by_actor,
+                "audit_event_hash": event_hash,
+                "ts": now,
+            }
+        finally:
+            c.close()
+
+    return {"status": "not_found", "cache_key": cache_key_value}
+
+
+def _burn_cache_key(
+    cache_key_value: str,
+    *,
+    reason: str,
+    by_actor: str,
+    shards_dir: Path | None,
+    db_path: Path | None,
+    force: bool = False,
+) -> dict:
+    """Delete a providence_cache leaf, but only if it has no children.
+
+    "Kindergarten of a tree's genesis" — early/scratch use. Falsify keeps
+    history; burn removes the row. Children today = falsifications
+    referencing this cache_key. If any exist, refuse without ``--force``.
+
+    Always writes a 'providence_burn' audit event so the chain records
+    that a leaf was removed and why. Use ``aborist providence --falsify``
+    instead when downstream consumers may have built on this answer.
+    """
+    import time as _time
+
+    from aborist.store import append_audit, discover_shards, transaction
+
+    paths: list[Path] = (
+        discover_shards(shards_dir) if shards_dir else [Path(db_path)]
+    )
+
+    for sp in paths:
+        c = connect(sp)
+        try:
+            row = c.execute(
+                "SELECT cache_key, audit_mode, n_verified, falsification_state, "
+                " question_text FROM providence_cache WHERE cache_key = ?",
+                (cache_key_value,),
+            ).fetchone()
+            if row is None:
+                continue
+            child_falsifications = c.execute(
+                "SELECT COUNT(*) FROM falsifications WHERE cache_key = ?",
+                (cache_key_value,),
+            ).fetchone()[0]
+            if child_falsifications > 0 and not force:
+                return {
+                    "status": "refused_has_children",
+                    "cache_key": cache_key_value,
+                    "shard": sp.name,
+                    "child_falsifications": int(child_falsifications),
+                    "hint": "use --force to burn anyway, or 'providence --falsify' to keep history",
+                }
+            now = int(_time.time())
+            with transaction(c):
+                c.execute(
+                    "DELETE FROM providence_cache WHERE cache_key = ?",
+                    (cache_key_value,),
+                )
+            event_hash = append_audit(
+                c,
+                event_type="providence_burn",
+                subject_root=cache_key_value,
+                body={
+                    "cache_key": cache_key_value,
+                    "burned_audit_mode": row["audit_mode"],
+                    "burned_n_verified": int(row["n_verified"]),
+                    "burned_state": row["falsification_state"],
+                    "question_text": row["question_text"],
+                    "reason": reason,
+                    "by_actor": by_actor,
+                    "child_falsifications_at_burn": int(child_falsifications),
+                    "forced": bool(child_falsifications > 0 and force),
+                },
+                ts=now,
+            )
+            return {
+                "status": "burned",
+                "cache_key": cache_key_value,
+                "shard": sp.name,
+                "burned_audit_mode": row["audit_mode"],
+                "reason": reason,
+                "by_actor": by_actor,
+                "audit_event_hash": event_hash,
+                "ts": now,
+            }
+        finally:
+            c.close()
+
+    return {"status": "not_found", "cache_key": cache_key_value}
+
+
+def _count_document_children(c, document_root: str) -> dict:
+    """Count outbound child references that 'burn' must protect.
+
+    For a document/core leaf, "children" = anything downstream that built on
+    this row. Specifically:
+      - derivations rows where ``src_root = root`` (a core was distilled
+        from this — burning would orphan or silently cascade-truncate the
+        derivation, leaving the descendant core dangling).
+      - edges rows where ``dst_root = root`` (other documents link to this
+        one; burning leaves dangling references).
+      - providence_cache rows where ``source_root = root`` (Q&A grounded
+        in this document).
+
+    NOTE on schema: derivations has ON DELETE CASCADE on BOTH ``core_root``
+    AND ``src_root``. Without this gate, a bare DELETE FROM documents would
+    silently cascade-prune derivations and orphan downstream cores.
+    """
+    derivations_downstream = c.execute(
+        "SELECT COUNT(*) FROM derivations WHERE src_root = ?",
+        (document_root,),
+    ).fetchone()[0]
+    incoming_edges = c.execute(
+        "SELECT COUNT(*) FROM edges WHERE dst_root = ?",
+        (document_root,),
+    ).fetchone()[0]
+    providence_refs = c.execute(
+        "SELECT COUNT(*) FROM providence_cache WHERE source_root = ?",
+        (document_root,),
+    ).fetchone()[0]
+    return {
+        "derivations_downstream": int(derivations_downstream),
+        "incoming_edges": int(incoming_edges),
+        "providence_refs": int(providence_refs),
+    }
+
+
+def _burn_document_root(
+    document_root_value: str,
+    *,
+    reason: str,
+    by_actor: str,
+    shards_dir: Path | None,
+    db_path: Path | None,
+    force: bool = False,
+) -> dict:
+    """Delete a surface document leaf, but only if it has no children.
+
+    Children:
+      - derivations.src_root = root      (downstream cores derived from it)
+      - edges.dst_root       = root      (other docs link to it)
+      - providence_cache.source_root = root  (Q&A grounded in it)
+
+    On burn:
+      - DELETE FROM chunks_fts (FTS5 has no FK; clear before chunks vanish).
+      - DELETE FROM documents — cascades to chunks + merkle_nodes via FK.
+      - Append a 'document_burn' audit event recording counts + forced flag.
+
+    Refuses with status='refused_has_children' (and skips the audit event)
+    when any child count > 0 and ``--force`` is not set, so callers can fix
+    state and retry idempotently.
+    """
+    import time as _time
+
+    from aborist.store import append_audit, discover_shards, transaction
+
+    paths: list[Path] = (
+        discover_shards(shards_dir) if shards_dir else [Path(db_path)]
+    )
+
+    for sp in paths:
+        c = connect(sp)
+        try:
+            row = c.execute(
+                "SELECT document_root, document_uri, kind, title, source_type, "
+                "       chunking_version, canonicalization_version, schema_version "
+                "FROM documents WHERE document_root = ? AND kind = 'surface'",
+                (document_root_value,),
+            ).fetchone()
+            if row is None:
+                continue
+            counts = _count_document_children(c, document_root_value)
+            total_children = sum(counts.values())
+            if total_children > 0 and not force:
+                return {
+                    "status": "refused_has_children",
+                    "document_root": document_root_value,
+                    "kind": "surface",
+                    "shard": sp.name,
+                    **counts,
+                    "hint": "use --force to burn anyway (orphans descendants); "
+                            "prefer evict for cold-tier compression",
+                }
+            chunk_count = c.execute(
+                "SELECT COUNT(*) FROM chunks WHERE document_root = ?",
+                (document_root_value,),
+            ).fetchone()[0]
+            now = int(_time.time())
+            with transaction(c):
+                # FTS5 has no FK to chunks; clear by chunk_id before the
+                # CASCADE on documents wipes the rows that resolve them.
+                for cr in c.execute(
+                    "SELECT chunk_id FROM chunks WHERE document_root = ?",
+                    (document_root_value,),
+                ).fetchall():
+                    c.execute(
+                        "DELETE FROM chunks_fts WHERE rowid = ?",
+                        (cr["chunk_id"],),
+                    )
+                # Outbound edges (src_root = this) carry no FK; clean them
+                # explicitly so we don't leave half-edges pointing from a
+                # ghost. Incoming edges (dst_root = this) are already gated
+                # above by the children check.
+                c.execute(
+                    "DELETE FROM edges WHERE src_root = ?",
+                    (document_root_value,),
+                )
+                # documents -> chunks/merkle_nodes/derivations cascade via FK.
+                c.execute(
+                    "DELETE FROM documents WHERE document_root = ?",
+                    (document_root_value,),
+                )
+            event_hash = append_audit(
+                c,
+                event_type="document_burn",
+                subject_root=document_root_value,
+                body={
+                    "document_root": document_root_value,
+                    "document_uri": row["document_uri"],
+                    "kind": "surface",
+                    "title": row["title"],
+                    "source_type": row["source_type"],
+                    "burned_chunk_count": int(chunk_count),
+                    "child_counts_at_burn": counts,
+                    "reason": reason,
+                    "by_actor": by_actor,
+                    "forced": bool(total_children > 0 and force),
+                },
+                ts=now,
+            )
+            return {
+                "status": "burned",
+                "document_root": document_root_value,
+                "kind": "surface",
+                "shard": sp.name,
+                "burned_chunk_count": int(chunk_count),
+                "child_counts_at_burn": counts,
+                "reason": reason,
+                "by_actor": by_actor,
+                "audit_event_hash": event_hash,
+                "ts": now,
+            }
+        finally:
+            c.close()
+
+    return {"status": "not_found", "document_root": document_root_value, "kind": "surface"}
+
+
+def _burn_core_root(
+    document_root_value: str,
+    *,
+    reason: str,
+    by_actor: str,
+    shards_dir: Path | None,
+    db_path: Path | None,
+    force: bool = False,
+) -> dict:
+    """Delete a core document leaf, but only if it has no children.
+
+    Same children gates as ``_burn_document_root`` (derivations.src_root,
+    edges.dst_root, providence_cache.source_root). The "PLUS no further
+    derivations build cores from this core" rule from the spec is
+    structurally identical to derivations.src_root > 0 — a core acts as a
+    src_root only when something deeper distilled from it.
+
+    CLAUDE.md says "Cores never evict" — that's the eviction subsystem,
+    which only touches kind='surface'. Burn is operator-driven removal:
+    cores CAN be burned, but the children gate is enforced.
+
+    Audit event type is 'core_burn' so chain consumers can distinguish
+    surface vs core leaf removals at a glance.
+    """
+    import time as _time
+
+    from aborist.store import append_audit, discover_shards, transaction
+
+    paths: list[Path] = (
+        discover_shards(shards_dir) if shards_dir else [Path(db_path)]
+    )
+
+    for sp in paths:
+        c = connect(sp)
+        try:
+            row = c.execute(
+                "SELECT document_root, document_uri, kind, title, source_type, "
+                "       compression_depth, chunking_version, "
+                "       canonicalization_version, schema_version "
+                "FROM documents WHERE document_root = ? AND kind = 'core'",
+                (document_root_value,),
+            ).fetchone()
+            if row is None:
+                continue
+            counts = _count_document_children(c, document_root_value)
+            total_children = sum(counts.values())
+            if total_children > 0 and not force:
+                return {
+                    "status": "refused_has_children",
+                    "document_root": document_root_value,
+                    "kind": "core",
+                    "shard": sp.name,
+                    **counts,
+                    "hint": "use --force to burn anyway; cores carry "
+                            "downstream derivations that will be orphaned",
+                }
+            chunk_count = c.execute(
+                "SELECT COUNT(*) FROM chunks WHERE document_root = ?",
+                (document_root_value,),
+            ).fetchone()[0]
+            # Inbound derivations (where this core is core_root, i.e. its
+            # binding back to source surfaces). These are NOT children —
+            # they're the core's own provenance and cascade-delete with it.
+            inbound_derivations = c.execute(
+                "SELECT COUNT(*) FROM derivations WHERE core_root = ?",
+                (document_root_value,),
+            ).fetchone()[0]
+            now = int(_time.time())
+            with transaction(c):
+                for cr in c.execute(
+                    "SELECT chunk_id FROM chunks WHERE document_root = ?",
+                    (document_root_value,),
+                ).fetchall():
+                    c.execute(
+                        "DELETE FROM chunks_fts WHERE rowid = ?",
+                        (cr["chunk_id"],),
+                    )
+                c.execute(
+                    "DELETE FROM edges WHERE src_root = ?",
+                    (document_root_value,),
+                )
+                c.execute(
+                    "DELETE FROM documents WHERE document_root = ?",
+                    (document_root_value,),
+                )
+            event_hash = append_audit(
+                c,
+                event_type="core_burn",
+                subject_root=document_root_value,
+                body={
+                    "document_root": document_root_value,
+                    "document_uri": row["document_uri"],
+                    "kind": "core",
+                    "title": row["title"],
+                    "source_type": row["source_type"],
+                    "compression_depth": int(row["compression_depth"]),
+                    "burned_chunk_count": int(chunk_count),
+                    "burned_inbound_derivations": int(inbound_derivations),
+                    "child_counts_at_burn": counts,
+                    "reason": reason,
+                    "by_actor": by_actor,
+                    "forced": bool(total_children > 0 and force),
+                },
+                ts=now,
+            )
+            return {
+                "status": "burned",
+                "document_root": document_root_value,
+                "kind": "core",
+                "shard": sp.name,
+                "burned_chunk_count": int(chunk_count),
+                "burned_inbound_derivations": int(inbound_derivations),
+                "child_counts_at_burn": counts,
+                "reason": reason,
+                "by_actor": by_actor,
+                "audit_event_hash": event_hash,
+                "ts": now,
+            }
+        finally:
+            c.close()
+
+    return {"status": "not_found", "document_root": document_root_value, "kind": "core"}
+
+
+def _cmd_burn_kindergarten(args: argparse.Namespace) -> int:
+    """Burn all providence_cache rows younger than the kindergarten window.
+
+    Test-ergonomic mass burn: when iterating on retrieval/verifier knobs
+    you want to wipe recent test runs without finding each cache_key.
+    Mirrors the kindergarten window from `mesh sync` so what's still
+    "private" (un-broadcast) is also what's safe to bust without
+    confusing peers.
+
+    Each row goes through the standard `_burn_cache_key` so the
+    children gate is honored (use `--force` to override en masse).
+    Each successful burn writes one ``providence_burn`` audit event;
+    chain integrity is verifiable via `make chain-check-shards` after.
+    """
+    import time as _time
+    from aborist.store import discover_shards
+
+    now = int(_time.time())
+    # `kindergarten_seconds <= 0` means "no time gate — burn every live
+    # row" per the verb's docstring. The earlier `cutoff = now - 0`
+    # treated 0 as a same-second-only window, which made
+    # test_burn_kindergarten_zero_seconds_burns_everything timing-flaky:
+    # if the wall-clock second rolled over between seed and burn,
+    # cutoff > seed.created_at and nothing matched.
+    kindergarten_seconds = max(0, args.kindergarten_seconds)
+    no_time_gate = kindergarten_seconds == 0
+    cutoff = 0 if no_time_gate else now - kindergarten_seconds
+    shards_dir = Path(args.global_shards_dir) if args.global_shards_dir else None
+    single_db = Path(args.db) if args.db else None
+    paths: list[Path] = (
+        discover_shards(shards_dir) if shards_dir else [single_db]
+    )
+    actor = args.by_actor or os.environ.get("USER", "unknown")
+    reason = args.reason or f"burn-kindergarten window={args.kindergarten_seconds}s"
+
+    examined = 0
+    burned = 0
+    refused = 0
+    not_found = 0
+    items: list[dict] = []
+    for sp in paths:
+        c = connect(sp)
+        try:
+            if no_time_gate:
+                rows = c.execute(
+                    "SELECT cache_key, created_at, audit_mode "
+                    "FROM providence_cache "
+                    "WHERE falsification_state = 'live' "
+                    "ORDER BY created_at DESC"
+                ).fetchall()
+            else:
+                rows = c.execute(
+                    "SELECT cache_key, created_at, audit_mode "
+                    "FROM providence_cache "
+                    "WHERE created_at >= ? AND falsification_state = 'live' "
+                    "ORDER BY created_at DESC",
+                    (cutoff,),
+                ).fetchall()
+        finally:
+            c.close()
+        for r in rows:
+            examined += 1
+            if args.dry_run:
+                items.append({
+                    "cache_key": r["cache_key"],
+                    "audit_mode": r["audit_mode"],
+                    "created_at": r["created_at"],
+                    "would_burn": True,
+                })
+                continue
+            result = _burn_cache_key(
+                r["cache_key"],
+                reason=reason,
+                by_actor=actor,
+                shards_dir=shards_dir,
+                db_path=single_db,
+                force=bool(args.force),
+            )
+            status = result.get("status")
+            if status == "burned":
+                burned += 1
+            elif status == "refused_has_children":
+                refused += 1
+            else:
+                not_found += 1
+            items.append(result)
+
+    print(json.dumps({
+        "status": "dry_run" if args.dry_run else "burned",
+        "kindergarten_seconds": args.kindergarten_seconds,
+        "cutoff_at": cutoff,
+        "now": now,
+        "examined": examined,
+        "burned": burned,
+        "refused_has_children": refused,
+        "not_found": not_found,
+        "items": items[: args.verbose],
+    }, indent=2, ensure_ascii=False))
+    return 0
+
+
+def _cmd_burn(args: argparse.Namespace) -> int:
+    """CLI: burn a leaf with no children.
+
+    Dispatches on ``--kind`` to the matching helper. Default 'providence'
+    preserves the original surface (`--cache-key` only) so existing scripts
+    keep working. Document/core kinds use ``--root``.
+    """
+    kind = getattr(args, "kind", "providence") or "providence"
+    shards = Path(args.global_shards_dir) if args.global_shards_dir else None
+    db = Path(args.db) if args.db else None
+    actor = args.by_actor or os.environ.get("USER", "unknown")
+    reason = args.reason or ""
+    force = bool(args.force)
+
+    if kind == "providence":
+        if not getattr(args, "cache_key", None):
+            print("burn --kind providence requires --cache-key", file=sys.stderr)
+            return 2
+        result = _burn_cache_key(
+            args.cache_key,
+            reason=reason,
+            by_actor=actor,
+            shards_dir=shards,
+            db_path=db,
+            force=force,
+        )
+    elif kind in ("document", "core"):
+        if not getattr(args, "root", None):
+            print(f"burn --kind {kind} requires --root", file=sys.stderr)
+            return 2
+        helper = _burn_document_root if kind == "document" else _burn_core_root
+        result = helper(
+            args.root,
+            reason=reason,
+            by_actor=actor,
+            shards_dir=shards,
+            db_path=db,
+            force=force,
+        )
+    else:
+        print(f"unknown burn kind: {kind}", file=sys.stderr)
+        return 2
+
+    print(json.dumps(result, indent=2, ensure_ascii=False))
+    return 0 if result.get("status") == "burned" else 1
+
+
+def _cmd_providence(args: argparse.Namespace) -> int:
+    """List providence_cache records or falsify one by cache_key."""
+    if getattr(args, "falsify", None):
+        result = _falsify_cache_key(
+            args.falsify,
+            state=args.state,
+            reason=args.reason or "",
+            by_actor=args.by_actor or os.environ.get("USER", "unknown"),
+            shards_dir=Path(args.global_shards_dir) if args.global_shards_dir else None,
+            db_path=Path(args.db) if args.db else None,
+        )
+        print(json.dumps(result, indent=2, ensure_ascii=False))
+        return 0 if result.get("status") == "falsified" else 1
+    if getattr(args, "show_preflight", None):
+        # Ticket #000009 §7.2 — pull the preflight stage payload
+        # from a row's run_dag_blob. Operator tool for inspecting
+        # the policy state that governed the cached row.
+        return _cmd_providence_show_preflight(
+            cache_key_prefix=args.show_preflight,
+            shards_dir=args.global_shards_dir,
+            db=args.db,
+        )
+    conn = (
+        connect_query(args.db, shards_dir=args.global_shards_dir)
+        if args.global_shards_dir
+        else connect(args.db)
+    )
+    try:
+        if args.document_uri:
+            rows = conn.execute(
+                "SELECT cache_key, question_text, answer_text, falsification_state, "
+                " hit_count, created_at FROM providence_cache "
+                "WHERE document_uri = ? ORDER BY created_at DESC",
+                (args.document_uri,),
+            ).fetchall()
+        elif args.source_root:
+            rows = conn.execute(
+                "SELECT cache_key, question_text, answer_text, falsification_state, "
+                " hit_count, created_at FROM providence_cache "
+                "WHERE source_root = ? ORDER BY created_at DESC",
+                (args.source_root,),
+            ).fetchall()
+        else:
+            rows = conn.execute(
+                "SELECT cache_key, question_text, answer_text, falsification_state, "
+                " hit_count, created_at FROM providence_cache "
+                "ORDER BY created_at DESC LIMIT ?",
+                (args.limit,),
+            ).fetchall()
+    finally:
+        conn.close()
+    out = [
+        {
+            "cache_key": r["cache_key"],
+            "state": r["falsification_state"],
+            "hit_count": r["hit_count"],
+            "question": r["question_text"],
+            "answer": r["answer_text"],
+            "created_at": r["created_at"],
+        }
+        for r in rows
+    ]
+    print(json.dumps(out, indent=2, ensure_ascii=False))
+    return 0
+
+
+def _cmd_providence_show_preflight(
+    *,
+    cache_key_prefix: str,
+    shards_dir: str | None,
+    db: str | None,
+) -> int:
+    """Render the ``preflight`` stage payload for a cached row.
+
+    Ticket #000009 §7.2. Pulls ``run_dag_blob`` for the matching
+    cache row, parses the JSON, finds the ``preflight`` node, and
+    pretty-prints the five nested CTI clauses (classifier,
+    answer_contract, prompt_contract, evidence_contract,
+    policy_refs) plus the metacognition QuestionState.
+
+    Match is by 12-char prefix on ``cache_key`` (matches what
+    bench rows + `_render_query_human` already truncate to).
+    Returns 0 on success, 1 on miss / parse failure.
+    """
+    conn = (
+        connect_query(db, shards_dir=shards_dir)
+        if shards_dir
+        else connect(db)
+    )
+    try:
+        rows = conn.execute(
+            "SELECT cache_key, question_text, run_dag_blob "
+            "FROM providence_cache WHERE cache_key LIKE ? "
+            "ORDER BY created_at DESC LIMIT 5",
+            (cache_key_prefix + "%",),
+        ).fetchall()
+    finally:
+        conn.close()
+    if not rows:
+        print(
+            f"  no providence_cache row matching cache_key prefix "
+            f"'{cache_key_prefix}'", file=sys.stderr,
+        )
+        return 1
+    if len(rows) > 1:
+        print(
+            f"  {len(rows)} rows match prefix '{cache_key_prefix}'; "
+            "rendering most recent. Pass a longer prefix to disambiguate.",
+            file=sys.stderr,
+        )
+    row = rows[0]
+    blob = row["run_dag_blob"]
+    if not blob:
+        print(
+            f"  cache_key {row['cache_key'][:12]}: no run_dag_blob "
+            "(legacy row, predates #000009)",
+            file=sys.stderr,
+        )
+        return 1
+    try:
+        parsed = json.loads(blob)
+    except json.JSONDecodeError as exc:
+        print(f"  run_dag_blob parse error: {exc}", file=sys.stderr)
+        return 1
+    nodes = parsed.get("nodes") or []
+    preflight_node = next(
+        (n for n in nodes if isinstance(n, dict)
+         and n.get("stage") == "preflight"),
+        None,
+    )
+    if preflight_node is None:
+        print(
+            f"  cache_key {row['cache_key'][:12]}: run_dag has no "
+            "preflight stage (predates #000009 binding)",
+            file=sys.stderr,
+        )
+        return 1
+    # Pull the full preflight payload (Ticket #000009 §7.2 — payload
+    # now persisted alongside nodes via build_run_dag's
+    # preflight_payload kwarg). Fall back to hash-only render for
+    # legacy rows whose blob predates the payload-storage commit.
+    payload = parsed.get("preflight_payload")
+    out: dict = {
+        "cache_key": row["cache_key"][:12],
+        "question": row["question_text"],
+        "preflight_stage_hash": preflight_node.get("hash"),
+        "preflight_hash_12": (preflight_node.get("hash") or "")[:12],
+        "run_dag_root": parsed.get("root"),
+        "run_dag_stages": [n.get("stage") for n in nodes],
+    }
+    if payload is not None:
+        # Verify the persisted payload hashes to the persisted leaf.
+        # Mismatch would indicate post-write tampering or a serialization
+        # drift; surface it explicitly so an auditor can detect.
+        from aborist.qa.dag import _canonical_json, _sha256_hex
+        recomputed = _sha256_hex(_canonical_json(payload))
+        out["preflight_payload"] = payload
+        out["payload_hash_check"] = (
+            "ok" if recomputed == preflight_node.get("hash")
+            else f"MISMATCH (recomputed {recomputed[:12]} != stored {preflight_node.get('hash', '')[:12]})"
+        )
+    else:
+        out["preflight_payload"] = None
+        out["payload_hash_check"] = (
+            "unavailable: legacy row predates preflight_payload "
+            "persistence (Ticket #000009 §7.2)"
+        )
+    print(json.dumps(out, indent=2, ensure_ascii=False))
+    return 0
+
+
+def _load_record_context(row, shards_dir, qa_db):
+    """Reassemble context for a providence record. Returns text or None
+    if any source doc has no hot chunks (cold)."""
+    from aborist.qa.query import _load_doc_text
+
+    proof = json.loads(row["merkle_proof"])
+    sources = proof.get("sources", [])
+    if not sources:
+        return None
+    parts: list[str] = []
+    for src in sources:
+        shard_name = src.get("shard")
+        if not shard_name:
+            return None
+        if shards_dir:
+            shard_path = shards_dir / shard_name
+        else:
+            shard_path = qa_db.parent / shard_name
+        if not shard_path.exists():
+            return None
+        text = _load_doc_text(str(shard_path), src["document_root"])
+        if not text:
+            return None
+        parts.append(text)
+    return "\n\n".join(parts)
+
+
+def _cmd_reclassify(args: argparse.Namespace) -> int:
+    """Re-run the layered verifier against existing live providence records.
+
+    Reads each record's answer + reassembles its context from
+    merkle_proof.sources, runs verify_quotes(), and updates the row only
+    if the verdict differs from what's stored. No LLM calls — this just
+    relabels existing answers under the current verifier.
+
+    Cold-source records (where any source doc has no hot chunks) are
+    skipped: we can't faithfully reclassify without the original context.
+    Run `aborist rehydrate` first if you want those covered too.
+
+    `--compare` runs all four entity policies side-by-side without
+    writing — use it to see what each policy would produce on real data
+    before committing to one. `--entity-policy X` writes under a single
+    policy.
+
+    Each changed record gets one 'providence_reclassify' audit event
+    with old & new state for chain-of-custody.
+    """
+    import time
+    from collections import defaultdict
+
+    from aborist.qa.verify import (
+        DEFAULT_ENTITY_POLICY,
+        ENTITY_POLICIES,
+        verify_quotes,
+    )
+
+    qa_db = args.qa_db
+    if qa_db is None:
+        if args.global_shards_dir:
+            qa_db = Path(args.global_shards_dir) / "qa.db"
+        else:
+            qa_db = Path.home() / ".aborist" / "qa.db"
+    qa_db = Path(qa_db)
+
+    shards_dir = (
+        Path(args.global_shards_dir) if args.global_shards_dir else None
+    )
+
+    conn = connect(qa_db)
+    try:
+        sql = (
+            "SELECT cache_key, answer_text, merkle_proof, audit_mode, "
+            " verifier_method, n_quotes, n_verified, unverified_quotes, "
+            " question_text "
+            "FROM providence_cache "
+            "WHERE falsification_state = 'live' "
+            "ORDER BY created_at DESC"
+        )
+        if args.limit:
+            sql += f" LIMIT {int(args.limit)}"
+        rows = conn.execute(sql).fetchall()
+
+        if args.compare:
+            # Run all four policies side-by-side, no DB write. Output is a
+            # per-record grid + a per-policy distribution summary so fox can
+            # eyeball where the policies disagree.
+            grid = []
+            distribution: dict[str, dict[str, int]] = {
+                p: defaultdict(int) for p in ENTITY_POLICIES
+            }
+            skipped_cold = 0
+            for row in rows:
+                context = _load_record_context(row, shards_dir, qa_db)
+                if context is None:
+                    skipped_cold += 1
+                    continue
+                per_policy = {}
+                for p in ENTITY_POLICIES:
+                    v = verify_quotes(row["answer_text"], context, entity_policy=p)
+                    label = f"{v['audit_mode']}/{v['verifier_method']}"
+                    per_policy[p] = label
+                    distribution[p][label] += 1
+                grid.append({
+                    "cache_key": row["cache_key"][:16] + "…",
+                    "question": row["question_text"][:55],
+                    **per_policy,
+                })
+            print(json.dumps({
+                "examined": len(grid),
+                "skipped_cold": skipped_cold,
+                "distribution": {p: dict(d) for p, d in distribution.items()},
+                "records": grid,
+            }, indent=2, ensure_ascii=False))
+            return 0
+
+        # Single-policy reclassify. Default tracks DEFAULT_ENTITY_POLICY
+        # so the CLI always matches the verifier's current contract.
+        policy_name = args.entity_policy or DEFAULT_ENTITY_POLICY
+        if policy_name not in ENTITY_POLICIES:
+            print(
+                f"--entity-policy must be one of {ENTITY_POLICIES}, "
+                f"got {policy_name!r}",
+                file=sys.stderr,
+            )
+            return 2
+
+        summary = {
+            "examined": 0,
+            "changed": 0,
+            "skipped_cold": 0,
+            "unchanged": 0,
+            "entity_policy": policy_name,
+            "transitions": defaultdict(int),
+        }
+
+        for row in rows:
+            summary["examined"] += 1
+            context = _load_record_context(row, shards_dir, qa_db)
+            if context is None:
+                summary["skipped_cold"] += 1
+                continue
+
+            verdict = verify_quotes(
+                row["answer_text"], context, entity_policy=policy_name
+            )
+
+            old_unverified = row["unverified_quotes"] or "null"
+            new_unverified_blob = (
+                json.dumps(verdict["unverified_quotes"], separators=(",", ":"))
+                if verdict["unverified_quotes"]
+                else None
+            )
+            new_unverified_for_compare = new_unverified_blob or "null"
+
+            unchanged = (
+                verdict["audit_mode"] == row["audit_mode"]
+                and verdict["verifier_method"] == row["verifier_method"]
+                and verdict["n_quotes"] == row["n_quotes"]
+                and verdict["n_verified"] == row["n_verified"]
+                and old_unverified == new_unverified_for_compare
+            )
+            if unchanged:
+                summary["unchanged"] += 1
+                continue
+
+            summary["changed"] += 1
+            transition = (
+                f"{row['audit_mode']}/{row['verifier_method']} "
+                f"-> {verdict['audit_mode']}/{verdict['verifier_method']}"
+            )
+            summary["transitions"][transition] += 1
+
+            if args.dry_run:
+                continue
+
+            now = int(time.time())
+            with transaction(conn):
+                event_hash = append_audit(
+                    conn,
+                    event_type="providence_reclassify",
+                    subject_root=row["cache_key"],
+                    body={
+                        "old_audit_mode": row["audit_mode"],
+                        "new_audit_mode": verdict["audit_mode"],
+                        "old_method": row["verifier_method"],
+                        "new_method": verdict["verifier_method"],
+                        "old_n_verified": row["n_verified"],
+                        "new_n_verified": verdict["n_verified"],
+                        "entity_policy": policy_name,
+                    },
+                    ts=now,
+                )
+                conn.execute(
+                    "UPDATE providence_cache SET "
+                    " audit_mode = ?, n_quotes = ?, n_verified = ?, "
+                    " unverified_quotes = ?, verifier_method = ?, "
+                    " audit_event_hash = ? "
+                    "WHERE cache_key = ?",
+                    (
+                        verdict["audit_mode"],
+                        verdict["n_quotes"],
+                        verdict["n_verified"],
+                        new_unverified_blob,
+                        verdict["verifier_method"],
+                        event_hash,
+                        row["cache_key"],
+                    ),
+                )
+    finally:
+        conn.close()
+
+    summary["transitions"] = dict(summary["transitions"])
+    summary["dry_run"] = bool(args.dry_run)
+    print(json.dumps(summary, indent=2, ensure_ascii=False))
+    return 0
+
+
+def _cmd_emergent(args: argparse.Namespace) -> int:
+    """Surface emergent claims from UNGROUNDED/HYBRID providence records.
+
+    These are spans the model produced that don't appear verbatim in the
+    corpus — candidate ingest targets. Frequent unverified quotes signal
+    knowledge the model has from training that our corpus is missing.
+    """
+    conn = (
+        connect_query(args.db, shards_dir=args.global_shards_dir)
+        if args.global_shards_dir
+        else connect(args.db)
+    )
+    try:
+        if args.aggregate:
+            rows = conn.execute(
+                "SELECT unverified_quotes FROM providence_cache "
+                "WHERE audit_mode IN ('UNGROUNDED','HYBRID') "
+                " AND falsification_state = 'live' "
+                " AND unverified_quotes IS NOT NULL"
+            ).fetchall()
+            counts: dict[str, int] = {}
+            for r in rows:
+                for q in json.loads(r["unverified_quotes"]):
+                    counts[q] = counts.get(q, 0) + 1
+            ranked = sorted(counts.items(), key=lambda kv: -kv[1])[: args.limit]
+            print(json.dumps(
+                [{"quote": q, "count": c} for q, c in ranked],
+                indent=2, ensure_ascii=False
+            ))
+        else:
+            rows = conn.execute(
+                "SELECT cache_key, audit_mode, verifier_method, question_text, "
+                " n_quotes, n_verified, unverified_quotes, created_at "
+                "FROM providence_cache "
+                "WHERE audit_mode IN ('UNGROUNDED','HYBRID') "
+                " AND falsification_state = 'live' "
+                "ORDER BY created_at DESC LIMIT ?",
+                (args.limit,),
+            ).fetchall()
+            out = [
+                {
+                    "cache_key": r["cache_key"],
+                    "audit_mode": r["audit_mode"],
+                    "verifier_method": r["verifier_method"],
+                    "question": r["question_text"],
+                    "n_quotes": r["n_quotes"],
+                    "n_verified": r["n_verified"],
+                    "unverified_quotes": (
+                        json.loads(r["unverified_quotes"])
+                        if r["unverified_quotes"]
+                        else []
+                    ),
+                    "created_at": r["created_at"],
+                }
+                for r in rows
+            ]
+            print(json.dumps(out, indent=2, ensure_ascii=False))
+    finally:
+        conn.close()
+    return 0
+
+
+def _cmd_evict(args: argparse.Namespace) -> int:
+    """Move hot chunks to cold tier (archive unused content)."""
+    from aborist.evict import evict_to_cold
+
+    conn = (
+        connect_query(args.db, shards_dir=args.global_shards_dir)
+        if args.global_shards_dir
+        else connect(args.db)
+    )
+    try:
+        result = evict_to_cold(
+            conn,
+            source_type=args.source_type,
+            older_than_days=args.older_than_days,
+            document_roots=args.document_root or None,
+        )
+    finally:
+        conn.close()
+    print(json.dumps(result, indent=2, ensure_ascii=False))
+    return 0
+
+
+def _cmd_rehydrate(args: argparse.Namespace) -> int:
+    """Rehydrate cold chunks from source (inverse of evict)."""
+    from aborist.evict import rehydrate
+
+    conn = (
+        connect_query(args.db, shards_dir=args.global_shards_dir)
+        if args.global_shards_dir
+        else connect(args.db)
+    )
+    try:
+        if args.all_cold:
+            roots = [
+                r["document_root"]
+                for r in conn.execute(
+                    "SELECT DISTINCT document_root FROM chunks WHERE tier = 'cold'"
+                ).fetchall()
+            ]
+        else:
+            roots = list(args.document_root or [])
+        if not roots:
+            print(
+                "rehydrate needs --document-root R or --all-cold",
+                file=sys.stderr,
+            )
+            return 2
+        results = []
+        for r in roots:
+            res = rehydrate(conn, r)
+            res["document_root"] = r
+            results.append(res)
+    finally:
+        conn.close()
+    print(json.dumps(results, indent=2, ensure_ascii=False))
+    drift = sum(1 for r in results if r.get("status") == "drift_detected")
+    return 1 if drift else 0
+
+
+def _cmd_activity(args: argparse.Namespace) -> int:
+    """Recent activity: Q&A records + freshly cached docs across all shards.
+
+    Designed for an agent to read before deciding the next action — what was
+    just asked, what was just integrated, what's the corpus state.
+    """
+    import time as _time
+
+    from aborist.store import discover_shards
+
+    shard_paths: list[Path] = []
+    if args.global_shards_dir:
+        shard_paths = discover_shards(args.global_shards_dir)
+    else:
+        shard_paths = [Path(args.db)]
+
+    cutoff_ts = 0
+    if args.since_seconds:
+        cutoff_ts = int(_time.time()) - args.since_seconds
+
+    qa_records: list[dict] = []
+    ingest_events: list[dict] = []
+    derive_events: list[dict] = []
+    falsifications: list[dict] = []
+    corpus = {
+        "documents_total": 0,
+        "documents_surface": 0,
+        "documents_core": 0,
+        "providence_total": 0,
+        "providence_live": 0,
+        "providence_stale": 0,
+        "providence_failed": 0,
+        "audit_events_total": 0,
+    }
+
+    for sp in shard_paths:
+        c = connect(sp)
+        try:
+            corpus["documents_total"] += c.execute(
+                "SELECT COUNT(*) FROM documents"
+            ).fetchone()[0]
+            corpus["documents_surface"] += c.execute(
+                "SELECT COUNT(*) FROM documents WHERE kind='surface'"
+            ).fetchone()[0]
+            corpus["documents_core"] += c.execute(
+                "SELECT COUNT(*) FROM documents WHERE kind='core'"
+            ).fetchone()[0]
+            corpus["providence_total"] += c.execute(
+                "SELECT COUNT(*) FROM providence_cache"
+            ).fetchone()[0]
+            corpus["providence_live"] += c.execute(
+                "SELECT COUNT(*) FROM providence_cache WHERE falsification_state='live'"
+            ).fetchone()[0]
+            corpus["providence_stale"] += c.execute(
+                "SELECT COUNT(*) FROM providence_cache WHERE falsification_state='stale'"
+            ).fetchone()[0]
+            corpus["providence_failed"] += c.execute(
+                "SELECT COUNT(*) FROM providence_cache WHERE falsification_state='failed'"
+            ).fetchone()[0]
+            corpus["audit_events_total"] += c.execute(
+                "SELECT COUNT(*) FROM audit_events"
+            ).fetchone()[0]
+
+            # Q&A records
+            for r in c.execute(
+                "SELECT cache_key, question_text, answer_text, "
+                " falsification_state, hit_count, created_at, last_hit_at, "
+                " document_uri FROM providence_cache "
+                "WHERE created_at >= ? ORDER BY created_at DESC LIMIT ?",
+                (cutoff_ts, args.limit),
+            ).fetchall():
+                ans = r["answer_text"] or ""
+                qa_records.append(
+                    {
+                        "ts": r["created_at"],
+                        "shard": sp.name,
+                        "cache_key": r["cache_key"],
+                        "question": r["question_text"],
+                        "answer_preview": (
+                            ans if len(ans) <= args.preview_chars
+                            else ans[: args.preview_chars] + "…"
+                        ),
+                        "sources_uri": r["document_uri"],
+                        "state": r["falsification_state"],
+                        "hit_count": r["hit_count"],
+                        "last_hit_at": r["last_hit_at"],
+                    }
+                )
+
+            # Recent ingest events
+            for r in c.execute(
+                "SELECT subject_root, body, ts FROM audit_events "
+                "WHERE event_type = 'ingest' AND ts >= ? "
+                "ORDER BY ts DESC LIMIT ?",
+                (cutoff_ts, args.limit),
+            ).fetchall():
+                body = json.loads(r["body"]) if r["body"] else {}
+                ingest_events.append(
+                    {
+                        "ts": r["ts"],
+                        "shard": sp.name,
+                        "document_root": r["subject_root"],
+                        "document_uri": body.get("document_uri"),
+                        "source_type": body.get("source_type"),
+                        "chunks": body.get("chunks"),
+                        "supersedes": body.get("supersedes"),
+                    }
+                )
+
+            # Recent derive events (distillations)
+            for r in c.execute(
+                "SELECT subject_root, body, ts FROM audit_events "
+                "WHERE event_type = 'derive' AND ts >= ? "
+                "ORDER BY ts DESC LIMIT ?",
+                (cutoff_ts, args.limit),
+            ).fetchall():
+                body = json.loads(r["body"]) if r["body"] else {}
+                derive_events.append(
+                    {
+                        "ts": r["ts"],
+                        "shard": sp.name,
+                        "core_root": r["subject_root"],
+                        "src_root": body.get("src_root"),
+                        "process_id": body.get("process_id"),
+                        "compression_ratio": body.get("compression_ratio"),
+                        "compression_depth": body.get("compression_depth"),
+                    }
+                )
+
+            # Recent falsifications
+            for r in c.execute(
+                "SELECT cache_key, state, reason, by_actor, at FROM falsifications "
+                "WHERE at >= ? ORDER BY at DESC LIMIT ?",
+                (cutoff_ts, args.limit),
+            ).fetchall():
+                falsifications.append(
+                    {
+                        "ts": r["at"],
+                        "shard": sp.name,
+                        "cache_key": r["cache_key"],
+                        "state": r["state"],
+                        "reason": r["reason"],
+                        "by_actor": r["by_actor"],
+                    }
+                )
+        finally:
+            c.close()
+
+    qa_records.sort(key=lambda x: -x["ts"])
+    ingest_events.sort(key=lambda x: -x["ts"])
+    derive_events.sort(key=lambda x: -x["ts"])
+    falsifications.sort(key=lambda x: -x["ts"])
+
+    print(
+        json.dumps(
+            {
+                "as_of": int(_time.time()),
+                "shards": [str(p) for p in shard_paths],
+                "corpus": corpus,
+                "recent_qa": qa_records[: args.limit],
+                "recent_ingests": ingest_events[: args.limit],
+                "recent_derives": derive_events[: args.limit],
+                "recent_falsifications": falsifications[: args.limit],
+            },
+            indent=2, ensure_ascii=False
+        )
+    )
+    return 0
+
+
+def _cmd_stats(args: argparse.Namespace) -> int:
+    """Show corpus statistics (document count, chunk count, index size)."""
+    conn = (
+        connect_query(args.db, shards_dir=args.global_shards_dir)
+        if args.global_shards_dir
+        else connect(args.db)
+    )
+    try:
+        result = stats(conn)
+    finally:
+        conn.close()
+    print(json.dumps(result, indent=2, ensure_ascii=False))
+    return 0
+
+
+def _check_audit_chain(conn: sqlite3.Connection) -> tuple[int, int]:
+    """Return (events_checked, breaks) for one audit chain in `conn`."""
+    import hashlib
+
+    rows = conn.execute(
+        "SELECT seq, event_hash, prev_event_hash, body FROM audit_events ORDER BY seq"
+    ).fetchall()
+    prev = None
+    breaks = 0
+    for r in rows:
+        h = hashlib.sha256()
+        if r["prev_event_hash"]:
+            h.update(bytes.fromhex(r["prev_event_hash"]))
+        h.update(r["body"].encode("utf-8"))
+        if h.hexdigest() != r["event_hash"]:
+            breaks += 1
+        if r["prev_event_hash"] != prev:
+            breaks += 1
+        prev = r["event_hash"]
+    return len(rows), breaks
+
+
+def _cmd_analyze(args: argparse.Namespace) -> int:
+    """Compression spectrum, depth distribution, audit chain integrity."""
+    from aborist.store import discover_shards
+
+    # In sharded mode, audit chains live per-shard (each shard has its own
+    # genesis -> latest). Check each independently and aggregate.
+    audit_summary: dict | None = None
+    if args.global_shards_dir:
+        per_shard_chain = []
+        total_events = 0
+        total_breaks = 0
+        for sp in discover_shards(args.global_shards_dir):
+            sc = connect(sp)
+            try:
+                ev, br = _check_audit_chain(sc)
+            finally:
+                sc.close()
+            per_shard_chain.append({"shard": sp.name, "events": ev, "breaks": br})
+            total_events += ev
+            total_breaks += br
+        audit_summary = {
+            "events": total_events,
+            "breaks": total_breaks,
+            "shards": per_shard_chain,
+        }
+
+    conn = (
+        connect_query(args.db, shards_dir=args.global_shards_dir)
+        if args.global_shards_dir
+        else connect(args.db)
+    )
+    try:
+        # Depth distribution.
+        depth = conn.execute(
+            "SELECT compression_depth, COUNT(*) AS n "
+            "FROM documents GROUP BY compression_depth ORDER BY 1"
+        ).fetchall()
+
+        # Per-process compression ratios.
+        procs = conn.execute(
+            "SELECT json_extract(body, '$.process_id') AS process_id, "
+            "       json_extract(body, '$.src_kind') AS src_kind, "
+            "       AVG(CAST(json_extract(body, '$.compression_ratio') AS REAL)) AS mean_ratio, "
+            "       MIN(CAST(json_extract(body, '$.compression_ratio') AS REAL)) AS min_ratio, "
+            "       MAX(CAST(json_extract(body, '$.compression_ratio') AS REAL)) AS max_ratio, "
+            "       COUNT(*) AS n_events "
+            "FROM audit_events WHERE event_type='derive' "
+            "GROUP BY process_id, src_kind"
+        ).fetchall()
+
+        # Source/kind crosstab.
+        kinds = conn.execute(
+            "SELECT source_type, kind, COUNT(*) AS n "
+            "FROM documents GROUP BY source_type, kind ORDER BY 3 DESC"
+        ).fetchall()
+
+        # Tier distribution.
+        tiers = conn.execute(
+            "SELECT tier, COUNT(*) AS n FROM chunks GROUP BY tier"
+        ).fetchall()
+
+        # Top inbound link targets (the 'gravity wells' of the corpus).
+        gravity = conn.execute(
+            "SELECT dst_uri, COUNT(*) AS inbound FROM edges "
+            "WHERE edge_type='wikilink' AND dst_uri != '' "
+            "GROUP BY dst_uri ORDER BY inbound DESC LIMIT ?",
+            (args.gravity_top,),
+        ).fetchall()
+
+        # Audit chain integrity (per-shard if sharded; single chain otherwise).
+        if audit_summary is None:
+            ev, br = _check_audit_chain(conn)
+            audit_summary = {"events": ev, "breaks": br}
+
+        report = {
+            "compression_depth_histogram": [
+                {"depth": r["compression_depth"], "count": r["n"]} for r in depth
+            ],
+            "distillers": [
+                {
+                    "process_id": r["process_id"],
+                    "src_kind": r["src_kind"],
+                    "n_events": r["n_events"],
+                    "compression_ratio": {
+                        "mean": (
+                            round(r["mean_ratio"], 4)
+                            if r["mean_ratio"] is not None
+                            else None
+                        ),
+                        "min": (
+                            round(r["min_ratio"], 4)
+                            if r["min_ratio"] is not None
+                            else None
+                        ),
+                        "max": (
+                            round(r["max_ratio"], 4)
+                            if r["max_ratio"] is not None
+                            else None
+                        ),
+                    },
+                }
+                for r in procs
+            ],
+            "documents_by_source_kind": [
+                {"source_type": r["source_type"], "kind": r["kind"], "count": r["n"]}
+                for r in kinds
+            ],
+            "chunks_by_tier": {r["tier"]: r["n"] for r in tiers},
+            "audit_chain": audit_summary,
+            "gravity_top_inbound": [
+                {"uri": r["dst_uri"], "inbound": r["inbound"]} for r in gravity
+            ],
+        }
+    finally:
+        conn.close()
+    print(json.dumps(report, indent=2, ensure_ascii=False))
+    return 0
+
+
+def _cmd_snapshot_create(args: argparse.Namespace) -> int:
+    """Compute snapshot_root over the read scope, persist into args.db.
+
+    Single-DB mode (--db only): read + write are the same connection;
+    delegate to the snapshot module's create_snapshot().
+
+    Cross-shard mode (--shards-dir + --db): read against the in-memory
+    UNION view to get the cluster-level Merkle root, then persist into
+    args.db (a dedicated snapshots store, conventionally
+    `~/.aborist/shards/snapshots.db`). The writer's own documents table
+    is irrelevant to the snapshot value — only the union scope counts.
+    """
+    import time as _time
+
+    from aborist.snapshot import compute_snapshot_root, create_snapshot
+
+    if args.global_shards_dir is None:
+        conn = connect(args.db)
+        try:
+            result = create_snapshot(
+                conn, reason=args.reason, parent_snapshot=args.parent,
+            )
+        finally:
+            conn.close()
+        print(json.dumps(result, indent=2, ensure_ascii=False))
+        return 0
+
+    # Cross-shard: compute against UNION, write to args.db.
+    read_conn = connect_query(args.db, shards_dir=args.global_shards_dir)
+    try:
+        snapshot_root, doc_count = compute_snapshot_root(read_conn)
+    finally:
+        read_conn.close()
+
+    write_conn = connect(args.db)
+    try:
+        parent = args.parent
+        if parent is None:
+            row = write_conn.execute(
+                "SELECT snapshot_root FROM snapshots ORDER BY taken_at DESC LIMIT 1"
+            ).fetchone()
+            if row is not None:
+                parent = row["snapshot_root"]
+
+        now = int(_time.time())
+        body = {
+            "snapshot_root": snapshot_root,
+            "doc_count": doc_count,
+            "parent_snapshot": parent,
+            "reason": args.reason,
+            "scope": "shards-union",
+        }
+        audit_event_hash = append_audit(
+            write_conn,
+            event_type="snapshot_create",
+            body=body,
+            subject_root=snapshot_root,
+            ts=now,
+        )
+        with transaction(write_conn):
+            write_conn.execute(
+                "INSERT OR IGNORE INTO snapshots "
+                "(snapshot_root, taken_at, audit_event_hash, doc_count, "
+                " parent_snapshot, reason) VALUES (?, ?, ?, ?, ?, ?)",
+                (
+                    snapshot_root,
+                    now,
+                    audit_event_hash,
+                    doc_count,
+                    parent,
+                    args.reason,
+                ),
+            )
+    finally:
+        write_conn.close()
+
+    print(
+        json.dumps(
+            {
+                "snapshot_root": snapshot_root,
+                "doc_count": doc_count,
+                "parent_snapshot": parent,
+                "audit_event_hash": audit_event_hash,
+                "taken_at": now,
+                "reason": args.reason,
+                "scope": "shards-union",
+            },
+            indent=2, ensure_ascii=False
+        )
+    )
+    return 0
+
+
+def _cmd_snapshot_list(args: argparse.Namespace) -> int:
+    """List named corpus snapshots (named roots)."""
+    from aborist.snapshot import list_snapshots
+
+    conn = connect(args.db)
+    try:
+        rows = list_snapshots(conn, limit=args.limit)
+    finally:
+        conn.close()
+    print(json.dumps(rows, indent=2, ensure_ascii=False))
+    return 0
+
+
+def _cmd_snapshot_verify(args: argparse.Namespace) -> int:
+    """Verify snapshot integrity (round-trip Merkle proofs)."""
+    from aborist.snapshot import verify_snapshot
+
+    conn = (
+        connect_query(args.db, shards_dir=args.global_shards_dir)
+        if args.global_shards_dir
+        else connect(args.db)
+    )
+    try:
+        result = verify_snapshot(conn, args.snapshot_root)
+    finally:
+        conn.close()
+    print(json.dumps(result, indent=2, ensure_ascii=False))
+    return 0 if result["matches"] else 1
+
+
+def _cmd_snapshot_diff(args: argparse.Namespace) -> int:
+    from aborist.snapshot import diff_against_current
+
+    conn = (
+        connect_query(args.db, shards_dir=args.global_shards_dir)
+        if args.global_shards_dir
+        else connect(args.db)
+    )
+    try:
+        result = diff_against_current(conn, args.snapshot_root)
+    finally:
+        conn.close()
+    print(json.dumps(result, indent=2, ensure_ascii=False))
+    return 0
+
+
+def _cmd_mesh_status(args: argparse.Namespace) -> int:
+    """Show mesh status (members, epochs, identity)."""
+    from aborist.mesh import current_epoch, is_enabled, load_identity
+    from aborist.mesh.state import roster_at
+
+    conn = connect(args.db)
+    try:
+        ident = load_identity(conn)
+        epoch = current_epoch(conn)
+        roster = roster_at(conn, epoch) if epoch is not None else []
+        out = {
+            "enabled": is_enabled(conn),
+            "identity": (
+                {
+                    "member_id": ident.member_id,
+                    "group_name": ident.group_name,
+                    "sign_pub_hex": ident.sign_pub.hex(),
+                    "dh_pub_hex": ident.dh_pub.hex(),
+                    "created_at": ident.created_at,
+                }
+                if ident
+                else None
+            ),
+            "current_epoch": epoch,
+            "roster": [
+                {
+                    "member_id": m.member_id,
+                    "role": m.role,
+                    "sign_pub_hex": m.sign_pub.hex(),
+                    "dh_pub_hex": m.dh_pub.hex(),
+                }
+                for m in roster
+            ],
+        }
+    finally:
+        conn.close()
+    print(json.dumps(out, indent=2, ensure_ascii=False))
+    return 0
+
+
+def _cmd_mesh_init(args: argparse.Namespace) -> int:
+    from aborist.mesh import init_identity
+
+    conn = connect(args.db)
+    try:
+        ident = init_identity(conn, group_name=args.group, member_id=args.member_id)
+    except RuntimeError as e:
+        print(f"error: {e}", file=sys.stderr)
+        conn.close()
+        return 2
+    finally:
+        conn.close()
+    print(
+        json.dumps(
+            {
+                "member_id": ident.member_id,
+                "group_name": ident.group_name,
+                "sign_pub_hex": ident.sign_pub.hex(),
+                "dh_pub_hex": ident.dh_pub.hex(),
+                "note": "share sign_pub_hex + dh_pub_hex with the founder of any group "
+                "you want to join. Run 'mesh enable' to flip the gating flag on.",
+            },
+            indent=2, ensure_ascii=False
+        )
+    )
+    return 0
+
+
+def _cmd_mesh_enable(args: argparse.Namespace) -> int:
+    from aborist.mesh import set_enabled
+
+    conn = connect(args.db)
+    try:
+        set_enabled(conn, True)
+    finally:
+        conn.close()
+    print(json.dumps({"enabled": True}, indent=2, ensure_ascii=False))
+    return 0
+
+
+def _cmd_mesh_disable(args: argparse.Namespace) -> int:
+    from aborist.mesh import set_enabled
+
+    conn = connect(args.db)
+    try:
+        set_enabled(conn, False)
+    finally:
+        conn.close()
+    print(json.dumps({"enabled": False}, indent=2, ensure_ascii=False))
+    return 0
+
+
+def _cmd_mesh_members(args: argparse.Namespace) -> int:
+    from aborist.mesh import current_epoch
+    from aborist.mesh.state import roster_at
+
+    conn = connect(args.db)
+    try:
+        epoch = current_epoch(conn)
+        if epoch is None:
+            print(json.dumps({"error": "mesh not initialized"}, indent=2, ensure_ascii=False))
+            return 2
+        roster = roster_at(conn, epoch)
+    finally:
+        conn.close()
+    print(
+        json.dumps(
+            {
+                "epoch": epoch,
+                "members": [
+                    {
+                        "member_id": m.member_id,
+                        "role": m.role,
+                        "sign_pub_hex": m.sign_pub.hex(),
+                        "dh_pub_hex": m.dh_pub.hex(),
+                    }
+                    for m in roster
+                ],
+            },
+            indent=2, ensure_ascii=False
+        )
+    )
+    return 0
+
+
+def _cmd_mesh_add(args: argparse.Namespace) -> int:
+    from aborist.mesh.members import add_member
+
+    try:
+        sign_pub = bytes.fromhex(args.sign_pub)
+        dh_pub = bytes.fromhex(args.dh_pub)
+    except ValueError:
+        print("error: --sign-pub and --dh-pub must be hex-encoded 32-byte keys", file=sys.stderr)
+        return 2
+    if len(sign_pub) != 32 or len(dh_pub) != 32:
+        print("error: keys must decode to exactly 32 bytes", file=sys.stderr)
+        return 2
+
+    conn = connect(args.db)
+    try:
+        epoch = add_member(
+            conn,
+            member_id=args.member_id,
+            sign_pub=sign_pub,
+            dh_pub=dh_pub,
+            role=args.role,
+        )
+    except (PermissionError, RuntimeError, ValueError) as e:
+        print(f"error: {e}", file=sys.stderr)
+        return 2
+    finally:
+        conn.close()
+    print(json.dumps({"new_epoch": epoch, "added": args.member_id}, indent=2, ensure_ascii=False))
+    return 0
+
+
+def _cmd_mesh_kick(args: argparse.Namespace) -> int:
+    from aborist.mesh.members import kick_member
+
+    conn = connect(args.db)
+    try:
+        epoch = kick_member(conn, member_id=args.member_id, reason=args.reason)
+    except (PermissionError, RuntimeError, ValueError) as e:
+        print(f"error: {e}", file=sys.stderr)
+        return 2
+    finally:
+        conn.close()
+    print(
+        json.dumps(
+            {"new_epoch": epoch, "kicked": args.member_id, "reason": args.reason},
+            indent=2, ensure_ascii=False
+        )
+    )
+    return 0
+
+
+def _cmd_mesh_rotate(args: argparse.Namespace) -> int:
+    from aborist.mesh.members import scheduled_rotate
+
+    conn = connect(args.db)
+    try:
+        epoch = scheduled_rotate(conn, reason=args.reason)
+    except (PermissionError, RuntimeError) as e:
+        print(f"error: {e}", file=sys.stderr)
+        return 2
+    finally:
+        conn.close()
+    print(json.dumps({"new_epoch": epoch, "reason": args.reason}, indent=2, ensure_ascii=False))
+    return 0
+
+
+def _cmd_mesh_serve(args: argparse.Namespace) -> int:
+    """Run the HTTP gossip server until SIGINT."""
+    from aborist.mesh import is_enabled, load_identity
+    from aborist.mesh.wire import MeshWireServer
+
+    conn = connect(args.db)
+    try:
+        if load_identity(conn) is None:
+            print("error: mesh not initialized; run 'aborist mesh init' first", file=sys.stderr)
+            return 2
+        if not is_enabled(conn):
+            print("error: mesh.enabled is off; run 'aborist mesh enable' first", file=sys.stderr)
+            return 2
+    finally:
+        conn.close()
+
+    srv = MeshWireServer(args.db, host=args.host, port=args.port)
+    print(json.dumps({"status": "serving", "url": srv.url, "db": str(args.db)}, ensure_ascii=False))
+    sys.stdout.flush()
+    try:
+        srv.serve()
+    except KeyboardInterrupt:
+        print(json.dumps({"status": "stopped", "reason": "SIGINT"}, ensure_ascii=False))
+    finally:
+        srv.stop()
+    return 0
+
+
+def _cmd_mesh_sync(args: argparse.Namespace) -> int:
+    """Push local roots + falsifications to a peer.
+
+    Two pushes happen by default (unless ``--no-roots`` /
+    ``--no-falsifications`` opts one out):
+
+    1. **ANNOUNCE_ROOT** for the most-recent ``--limit`` documents
+       older than the kindergarten window. Receivers dedup by
+       ``documents.document_root``.
+    2. **ANNOUNCE_FALSIFICATION** for the most-recent ``--limit``
+       falsifications older than the kindergarten window. Burns
+       deliberately NOT propagated — local kindergarten cleanup.
+
+    **Kindergarten window.** Records younger than
+    ``--kindergarten-seconds`` (default 3600 = 1 hour) are NOT
+    broadcast. Gives the operator time to inspect a fresh ingest or
+    falsification & burn it before the network sees it. Override per
+    invocation; ``--kindergarten-seconds 0`` broadcasts everything
+    (cron-friendly opt-out for operators who prefer immediate
+    propagation). The window is sender-side discipline; receivers
+    don't enforce it because they have no view into when the sender
+    created the record.
+
+    Receivers verify the Ed25519 signature, run per-peer chain-of-
+    claims fork detection, then write one ``mesh_received`` audit
+    event per accepted envelope. Duplicate broadcasts produce
+    duplicate audit-log entries on the receiver but no state
+    corruption.
+    """
+    import time as _time
+
+    from aborist.mesh import is_enabled, load_identity
+    from aborist.mesh.wire import MeshWireClient
+
+    now_ts = int(_time.time())
+    cutoff_ts = now_ts - max(0, args.kindergarten_seconds)
+
+    conn = connect(args.db)
+    try:
+        if load_identity(conn) is None:
+            print("error: mesh not initialized", file=sys.stderr)
+            return 2
+        if not is_enabled(conn):
+            print("error: mesh.enabled is off", file=sys.stderr)
+            return 2
+        # Total counts inform skipped-by-kindergarten reporting.
+        total_roots = 0
+        total_falsifications = 0
+        root_rows: list = []
+        falsification_rows: list = []
+        if not args.no_roots:
+            total_roots = conn.execute(
+                "SELECT COUNT(*) FROM documents"
+            ).fetchone()[0]
+            root_rows = conn.execute(
+                "SELECT document_root, document_uri, chunking_version, "
+                " canonicalization_version, schema_version "
+                "FROM documents WHERE ingest_ts <= ? "
+                "ORDER BY rowid DESC LIMIT ?",
+                (cutoff_ts, args.limit),
+            ).fetchall()
+        if not args.no_falsifications:
+            total_falsifications = conn.execute(
+                "SELECT COUNT(*) FROM falsifications"
+            ).fetchone()[0]
+            falsification_rows = conn.execute(
+                "SELECT cache_key, reason FROM falsifications "
+                "WHERE at <= ? "
+                "ORDER BY at DESC LIMIT ?",
+                (cutoff_ts, args.limit),
+            ).fetchall()
+    finally:
+        conn.close()
+
+    # Skipped-by-kindergarten = (rows younger than cutoff that would have
+    # been in the most-recent --limit) — approximated by total minus what
+    # we pulled, capped at limit.
+    fresh_roots_held = max(
+        0,
+        min(total_roots, args.limit) - len(root_rows),
+    ) if not args.no_roots else 0
+    fresh_falsifications_held = max(
+        0,
+        min(total_falsifications, args.limit) - len(falsification_rows),
+    ) if not args.no_falsifications else 0
+
+    sent_roots: list[dict] = []
+    sent_falsifications: list[dict] = []
+    errors: list[dict] = []
+    with MeshWireClient(args.db, args.peer) as client:
+        try:
+            peer_info = client.info()
+        except Exception as e:
+            print(json.dumps({"status": "peer_unreachable", "peer": args.peer, "error": str(e)}, indent=2, ensure_ascii=False))
+            return 2
+        for r in root_rows:
+            try:
+                resp = client.announce_root(
+                    document_root=r["document_root"],
+                    source_uri=r["document_uri"],
+                    chunking_version=r["chunking_version"],
+                    canonicalization_version=r["canonicalization_version"],
+                    schema_version=r["schema_version"],
+                )
+                sent_roots.append({"document_root": r["document_root"], "ack": resp})
+            except Exception as e:
+                errors.append({"document_root": r["document_root"], "error": str(e)})
+        for f in falsification_rows:
+            try:
+                resp = client.announce_falsification(
+                    cache_key=f["cache_key"],
+                    reason=f["reason"] or "",
+                )
+                sent_falsifications.append({"cache_key": f["cache_key"], "ack": resp})
+            except Exception as e:
+                errors.append({"cache_key": f["cache_key"], "error": str(e)})
+
+    print(json.dumps({
+        "status": "synced",
+        "peer": args.peer,
+        "peer_member_id": peer_info.get("member_id"),
+        "peer_epoch": peer_info.get("current_epoch"),
+        "kindergarten_seconds": args.kindergarten_seconds,
+        "announced_roots": len(sent_roots),
+        "announced_falsifications": len(sent_falsifications),
+        "kindergarten_held_roots": fresh_roots_held,
+        "kindergarten_held_falsifications": fresh_falsifications_held,
+        "errors": len(errors),
+        "sent_roots": sent_roots[: args.verbose],
+        "sent_falsifications": sent_falsifications[: args.verbose],
+        "error_samples": errors[:5],
+    }, indent=2, ensure_ascii=False))
+    return 0 if not errors else 1
+
+
+def _cmd_mesh_pull(args: argparse.Namespace) -> int:
+    """Pull a single document body from a peer by document_root.
+
+    Closes the request half of the gossip loop. The wire client already
+    verifies the peer's signature and re-derives the Merkle root from the
+    delivered leaves before returning. This verb then re-ingests the
+    delivered text through the standard ingest path so chunking_version /
+    canonicalization_version stay consistent — and rejects with rc=2 if
+    the local re-ingest produces a different document_root than requested.
+    """
+    from aborist.document import Document
+    from aborist.ingest import ingest_source
+    from aborist.mesh import is_enabled, load_identity
+    from aborist.mesh.wire import MeshWireClient
+
+    conn = connect(args.db)
+    try:
+        if load_identity(conn) is None:
+            print("error: mesh not initialized", file=sys.stderr)
+            return 2
+        if not is_enabled(conn):
+            print("error: mesh.enabled is off", file=sys.stderr)
+            return 2
+        already = conn.execute(
+            "SELECT document_root, document_uri FROM documents WHERE document_root=?",
+            (args.root,),
+        ).fetchone()
+    finally:
+        conn.close()
+
+    if already is not None:
+        print(json.dumps({
+            "status": "already_present",
+            "document_root": already["document_root"],
+            "document_uri": already["document_uri"],
+            "shard": str(args.db),
+        }, indent=2, ensure_ascii=False))
+        return 0
+
+    try:
+        with MeshWireClient(args.db, args.peer) as client:
+            body = client.request_body(root=args.root)
+    except Exception as e:
+        print(f"error: pull failed: {e}", file=sys.stderr)
+        return 2
+
+    delivered_uri = body.get("document_uri") or ""
+    delivered_text = body.get("text") or ""
+
+    class _PulledSource:
+        source_type = "mesh_pull"
+
+        def iter_documents(self):
+            yield Document(
+                uri=delivered_uri,
+                content=delivered_text,
+                source_type="mesh_pull",
+                title=None,
+            )
+
+    conn = connect(args.db)
+    try:
+        ingest_source(conn, _PulledSource())
+        row = conn.execute(
+            "SELECT document_root FROM documents WHERE document_root=?",
+            (args.root,),
+        ).fetchone()
+        if row is None:
+            # Re-ingest produced a different root than the peer claimed.
+            # The pulled text doesn't reproduce the requested root under
+            # this peer's chunker/canonicalization. Fail closed.
+            actual = conn.execute(
+                "SELECT document_root FROM documents WHERE document_uri=? "
+                "ORDER BY ingest_ts DESC LIMIT 1",
+                (delivered_uri,),
+            ).fetchone()
+            actual_root = actual["document_root"] if actual else None
+            print(
+                "error: local re-ingest produced "
+                f"{actual_root!r}, expected {args.root!r}",
+                file=sys.stderr,
+            )
+            return 2
+        with transaction(conn):
+            event_hash = append_audit(
+                conn,
+                event_type="mesh_pulled",
+                body={
+                    "document_root": args.root,
+                    "document_uri": delivered_uri,
+                    "peer": args.peer,
+                },
+                subject_root=args.root,
+            )
+    finally:
+        conn.close()
+
+    print(json.dumps({
+        "status": "pulled",
+        "document_root": args.root,
+        "document_uri": delivered_uri,
+        "shard": str(args.db),
+        "audit_event_hash": event_hash,
+    }, indent=2, ensure_ascii=False))
+    return 0
+
+
+def _cmd_crawl(args: argparse.Namespace) -> int:
+    """BFS-discover same-domain URLs from a seed and optionally ingest.
+
+    Two modes:
+
+    - default: print discovered URLs to stdout (one per line). Compose
+      with `aborist ingest --source html` if you want to feed them
+      through the standard ingest path manually.
+    - ``--ingest``: run the discovery + ingest path in a single shot,
+      capturing ETag + Last-Modified per page so a future
+      ``crawler recrawl-check`` can do conditional HEADs.
+    """
+    try:
+        from aborist.sources.crawler.bridge import crawl_seed, ingest_crawled
+    except ImportError as e:
+        print(f"error: {e}", file=sys.stderr)
+        return 2
+
+    from aborist.progress import Progress
+
+    cap = "no cap" if args.max_pages == 0 else f"max {args.max_pages}"
+    speed = "fast" if args.fast else "polite"
+    print(
+        f"  crawl: seed={args.seed_url} depth={args.depth} {cap} ({speed})",
+        file=sys.stderr,
+        flush=True,
+    )
+    crawl_progress = Progress(prefix="crawl ")
+    urls = crawl_seed(
+        args.seed_url,
+        max_depth=args.depth,
+        max_pages=args.max_pages,
+        progress=crawl_progress,
+        fast=args.fast,
+    )
+    print(
+        f"  crawl: discovery done — {len(urls)} URLs",
+        file=sys.stderr,
+        flush=True,
+    )
+
+    if not args.ingest:
+        for u in urls:
+            print(u)
+        return 0
+
+    print(
+        f"  ingest: starting on {len(urls)} URLs",
+        file=sys.stderr,
+        flush=True,
+    )
+    ingest_progress = Progress(prefix="ingest ", total_estimate=len(urls))
+    conn = connect(args.db)
+    try:
+        result = ingest_crawled(conn, urls, progress=ingest_progress)
+    finally:
+        conn.close()
+    print(json.dumps(
+        {
+            "status": "crawled_and_ingested",
+            "seed": args.seed_url,
+            "depth": args.depth,
+            "max_pages": args.max_pages,
+            "discovered": len(urls),
+            **result,
+        },
+        indent=2, ensure_ascii=False
+    ))
+    return 0
+
+
+def _cmd_crawler_recrawl_check(args: argparse.Namespace) -> int:
+    """Send conditional HEAD requests for ingested documents.
+
+    Reports each as fresh (304), stale (200, body changed), gone
+    (404/410), or unreachable. Updates `document_http_meta.last_status`
+    and `last_checked_at` so consecutive runs target the oldest checks
+    first.
+    """
+    try:
+        from aborist.sources.crawler.bridge import recrawl_check
+    except ImportError as e:
+        print(f"error: {e}", file=sys.stderr)
+        return 2
+
+    conn = connect(args.db)
+    try:
+        result = recrawl_check(
+            conn,
+            domain=args.domain,
+            limit=args.limit,
+        )
+    finally:
+        conn.close()
+    print(json.dumps(result, indent=2, ensure_ascii=False))
+    return 0
+
+
+
+[docs] +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + prog="aborist", + description="An arborist for trees and forests of cross-linked information.", + ) + p.add_argument("--version", action="version", version=f"aborist {__version__}") + p.add_argument( + "--db", + type=Path, + default=DEFAULT_DB_PATH, + help=f"path to aborist SQLite db (default: {DEFAULT_DB_PATH})", + ) + p.add_argument( + "--shards-dir", + dest="global_shards_dir", + default=None, + help=( + "for read commands: attach all shards in this directory and " + "expose them as UNION views over the standard tables" + ), + ) + sub = p.add_subparsers(dest="cmd", required=True) + + ingest = sub.add_parser("ingest", help="ingest documents from a source") + ingest.add_argument( + "--source", + required=True, + choices=[ + "wikipedia_cur", + "wikipedia_old", + "wikipedia_xml", + "wikipedia_xml_history", + "wikipedia_abstract", + "html", + "grok_export", + "grok_media", + "git_repo", + "hg_repo", + "providence", + ], + help="source type", + ) + ingest.add_argument( + "--kindergarten-seconds", + type=int, + default=None, + help=( + "(providence source only) records younger than this many " + "seconds stay opaque to ingestion — fresh thoughts cool " + "before they become substrate. Default 3600s (1h)." + ), + ) + ingest.add_argument( + "--path", + help=( + "path to dump file (wikipedia) or to xAI export root / " + "prod-grok-backend.json (grok_export, grok_media)" + ), + ) + ingest.add_argument( + "--url", action="append", help="URL to ingest (html source; repeatable)" + ) + ingest.add_argument( + "--urls-from", + dest="urls_from", + help="file with one URL per line (html source)", + ) + ingest.add_argument( + "--no-robots", + dest="no_robots", + action="store_true", + help="do not consult robots.txt (use only for explicitly authorized sites)", + ) + ingest.add_argument( + "--chunker", default=None, help="chunker name (default: tok-512-v1)" + ) + ingest.add_argument( + "--limit", type=int, default=None, help="cap number of documents" + ) + ingest.add_argument( + "--batch-size", + dest="batch_size", + type=int, + default=200, + help="documents per SQLite transaction (default 200)", + ) + ingest.add_argument( + "--shard", + default=None, + help=( + "rank/total — yield only every N-th doc for parallel ingest. " + "spawn N processes, each with --shard 0/N, 1/N, ... they " + "parallelize parser CPU and serialize writes via WAL" + ), + ) + ingest.add_argument( + "--shards-dir", + dest="shards_dir", + default=None, + help=( + "directory for attach-forever sharding. With --shard rank/total, " + "writes to shards-dir/<rank>.db instead of --db, removing the " + "WAL writer-lock contention entirely. Reads via aborist --shards-dir" + ), + ) + ingest.add_argument( + "--resume", + action="store_true", + help=( + "rsync-style: read each source's last high-water mark from this " + "DB's meta table and skip rows whose id is <= it. Safe to kill " + "and restart at any time" + ), + ) + ingest.add_argument( + "--quiet", + action="store_true", + help="suppress periodic stderr progress output", + ) + ingest.add_argument( + "--progress-interval", + dest="progress_interval", + type=float, + default=2.0, + help="seconds between stderr progress lines (default 2.0)", + ) + ingest.add_argument( + "--total-estimate", + dest="total_estimate", + type=int, + default=None, + help=( + "estimated total docs the source will yield. enables percent " + "+ ETA in progress output" + ), + ) + ingest.set_defaults(func=_cmd_ingest) + + search = sub.add_parser("search", help="keyword search (UNGROUNDED audit mode)") + search.add_argument("query", help="query string") + search.add_argument("--limit", type=int, default=20) + search.add_argument("--json", action="store_true", help="output JSON") + search.set_defaults(func=_cmd_search) + + verify = sub.add_parser( + "verify", help="round-trip Merkle proofs for N random documents" + ) + verify.add_argument("-n", type=int, default=10) + verify.set_defaults(func=_cmd_verify) + + distill = sub.add_parser( + "distill", + help="compress docs into Merkle-signed cores (surface->core or core->core)", + ) + distill.add_argument( + "--process", default="first-sentence-v1", help="distiller name" + ) + distill.add_argument( + "--kind", + choices=["surface", "core"], + default="surface", + help="source kind to scan; 'core' runs recursive distillation", + ) + distill.add_argument( + "--source-type", + dest="source_type", + default=None, + help="restrict to one source_type", + ) + distill.add_argument( + "--chunker", default=None, help="chunker for the core doc" + ) + distill.add_argument( + "--limit", type=int, default=None, help="cap number of docs scanned" + ) + distill.add_argument( + "--batch-size", + dest="batch_size", + type=int, + default=200, + help="cores written per SQLite transaction (default 200)", + ) + distill.set_defaults(func=_cmd_distill) + + ask_cmd = sub.add_parser( + "ask", + help="answer a question about a document (cache-first, STRICT)", + ) + ask_cmd.add_argument( + "--document-root", + dest="document_root", + required=True, + help="document_root to ask about", + ) + ask_cmd.add_argument( + "--question", required=True, help="question text" + ) + ask_cmd.add_argument( + "--model", + default=None, + help="model_id (default $ABORIST_LLM_MODEL or hermes-3)", + ) + ask_cmd.add_argument( + "--endpoint", + default=None, + help="OpenAI-compatible base URL (default $ABORIST_LLM_ENDPOINT)", + ) + ask_cmd.add_argument( + "--dry-run", + dest="dry_run", + action="store_true", + help="use StubClient — no network call", + ) + ask_cmd.add_argument( + "--answer-mode", dest="answer_mode", default=None, + choices=["quote", "claim_lattice_pointer", "claim_lattice"], + help=( + "answer schema. See `query --answer-mode` for full semantics. " + "Default 'quote'; 'claim_lattice_pointer' enables quote-by-pointer; " + "'claim_lattice' is the JSON variant (vLLM guided_json + lenient " + "pre-parser; pairs with grammar-constrained inference)." + ), + ) + ask_cmd.set_defaults(func=_cmd_ask) + + query_cmd = sub.add_parser( + "query", + help="multi-source RAG: question -> top-K corpus docs -> Hermes -> cache", + ) + query_cmd.add_argument("question", help="the question to ask") + query_cmd.add_argument( + "--top-k", dest="top_k", type=int, default=8, + help="max distinct source documents in context (default 8)", + ) + query_cmd.add_argument( + "--over-fetch", dest="over_fetch", type=int, default=32, + help="FTS5 hits to fetch per shard before dedup (default 32)", + ) + query_cmd.add_argument( + "--max-context-chars", dest="max_context_chars", type=int, default=None, + help=( + "cap on assembled context bytes. When omitted, falls back to " + "the per-mode default in DEFAULT_QUERY_POLICY['max_context_chars_by_mode'] " + "(quote=24000, claim_lattice_pointer=24000, claim_lattice=48000). " + "Sprint 1b 2026-05-02 — peaks measured per mode." + ), + ) + query_cmd.add_argument( + "--question-dedup", dest="question_dedup", default=None, + choices=["strict", "equivalence_class"], + help=( + "write-time question canonicalization. 'equivalence_class' " + "(default) collapses articles + trailing-punct + case so " + "variants share cache_keys. 'strict' keeps every variant " + "distinct (audit-grade)." + ), + ) + query_cmd.add_argument( + "--fidelity", dest="fidelity", default=None, + choices=["strict", "equivalence_class"], + help=( + "lookup tolerance. 'equivalence_class' (default) tries the " + "primary cache_key then falls back to the alternate dedup " + "mode's cache_key. 'strict' refuses fallback." + ), + ) + query_cmd.add_argument( + "--qa-db", dest="qa_db", default=None, + help=( + "providence_cache target DB. default: <shards-dir>/qa.db, or " + "~/.aborist/qa.db when no shards-dir" + ), + ) + query_cmd.add_argument( + "--model", default=None, + help="model_id (default $ABORIST_LLM_MODEL or hermes-3)", + ) + query_cmd.add_argument( + "--endpoint", default=None, + help="OpenAI-compatible base URL (default $ABORIST_LLM_ENDPOINT)", + ) + query_cmd.add_argument( + "--dry-run", dest="dry_run", action="store_true", + help="use StubClient — assembles context but skips the LLM call", + ) + query_cmd.add_argument( + "--json", action="store_true", + help="emit the raw record as indented JSON (default: human render)", + ) + query_cmd.add_argument( + "--burn", action="store_true", + help=( + "delete the matching live providence_cache row BEFORE lookup, " + "forcing a fresh inference. Test-ergonomic — see new behavior " + "without finding cache_keys by hand. Writes a providence_burn " + "audit event." + ), + ) + query_cmd.add_argument( + "--repair", action="store_true", + help=( + "enable mechanical repair after first verify (off by " + "default). When the verdict is HYBRID/UNGROUNDED, applies " + "synthetic_elision split, trailing_artifact trim, and " + "no_overlap drop deterministically; persists the repaired " + "answer with a providence_repair audit event." + ), + ) + query_cmd.add_argument( + "--repair-reprompts", dest="repair_reprompts", type=int, default=0, + help=( + "max LLM re-prompt iterations after mechanical repair " + "(default 0 = no re-prompt). Each iteration sends a feedback " + "turn naming failed quotes; the model is asked to rewrite " + "using only verbatim citations. Requires --repair." + ), + ) + query_cmd.add_argument( + "--answer-mode", dest="answer_mode", default=None, + choices=["quote", "claim_lattice_pointer", "claim_lattice"], + help=( + "answer schema. 'quote' (default): model writes prose with " + "verbatim quote spans inline. 'claim_lattice_pointer' (G0 " + "/ CTI quote-by-pointer): runtime builds a labeled evidence " + "map (E1, E2, …); model writes pointer-line prose ('Claim. " + "[E12]'); renderer interpolates literal spans. Synthetic-" + "elision-by-construction-impossible. No repair loop " + "(one-shot discipline). 'claim_lattice' is the JSON variant " + "(vLLM guided_json + lenient pre-parser; pairs with grammar-" + "constrained inference like Qwen 3.6 reasoner / Claude / GPT-4)." + ), + ) + query_cmd.add_argument( + "--retrieval-keywords", dest="retrieval_keywords", default=None, + help=( + "operator-supplied keywords appended to the question for " + "FTS5 retrieval ONLY — never sent to the LLM, never enters " + "cache_key, never reaches the verifier. Use to narrow " + "OR-mode retrieval on long discursive questions whose " + "content tokens get diluted by template phrasing. Example: " + "make query Q='what tech may enable one person to " + "reconstruct another person's thoughts...' " + "K='transcranial knowledge acquisition'. Pair with --burn " + "to force fresh inference (keywords are session-only and " + "cache-hits ignore them)." + ), + ) + # Ticket #000008 Phase 4 — quantifier-guard CLI flags. Each + # corresponds to a level of the §10.11.2 disable hierarchy. + query_cmd.add_argument( + "--no-quantifier-guard", + dest="no_quantifier_guard", action="store_true", + help=( + "Disable the broad-quantifier preflight guard for this " + "call. Overrides quantifier_guard_enabled in policy. " + "Bench-side telemetry (quantifier_intensity, etc.) goes " + "to None for the row. Use when the guard misclassifies." + ), + ) + query_cmd.add_argument( + "--allow-broad", + dest="allow_broad", action="store_true", + help=( + "Emergent-search mode: keep the classifier on (telemetry " + "stays useful) but don't apply caps. For broad questions " + "where the operator wants exploratory enumeration, not " + "grounded completeness." + ), + ) + query_cmd.add_argument( + "--reject-broad", + dest="reject_broad", action="store_true", + help=( + "Strict mode: when intensity is ALL/COMPREHENSIVE/" + "OPEN_REQUEST AND scope_bound_hint is unbounded, return " + "UNGROUNDED before the LLM call with a " + "BROAD_QUANTIFIER_REJECTED violation. Saves ~10-15s on " + "rejected runs. Bounded universals (e.g. all members of " + "the Beatles) are NOT rejected." + ), + ) + query_cmd.add_argument( + "--apply-quantifier-caps", + dest="apply_quantifier_caps", action="store_true", + help=( + "Flip the dry-run gate per-call. By default Phase 2 " + "lands with quantifier_guard_apply_caps=False so the " + "cap is reported on the result but not applied to the " + "verifier. This flag enables actual cap enforcement " + "for one call. Use after dry-run bench review confirms " + "the classifier output across the question set." + ), + ) + # Ticket #000010 — meta-cognition CLI flags. + query_cmd.add_argument( + "--no-preflight", + dest="no_preflight", action="store_true", + help=( + "Disable the meta-cognition preflight guard for this " + "call. Skips temporal / contradiction / false-premise / " + "out-of-corpus detectors. The QuestionState surfaces a " + "stub with empty logical_statuses so bench rows stay " + "column-aligned." + ), + ) + query_cmd.add_argument( + "--block-on-contradiction", + dest="block_on_contradiction", action="store_true", + help=( + "Hard-block on lexical contradictions (default: label-" + "only). Strict mode: questions like 'which unmarried " + "spouse is X married to' return PREFLIGHT_BLOCKED." + ), + ) + query_cmd.add_argument( + "--soft-preflight", + dest="soft_preflight", action="store_true", + help=( + "Ticket #000011 — opt-in to the model-assisted soft " + "preflight sidecar. Adds one short LLM round-trip " + "(~200ms median) before the main answer call; the model " + "classifies the question shape and returns a SOFT_* " + "advisory hint that surfaces as `· soft: <label>` on " + "the audit-line tail. NEVER enters the verifier proof " + "path; cannot create PREFLIGHT_OK or PREFLIGHT_BLOCKED." + ), + ) + query_cmd.set_defaults(func=_cmd_query) + + inspect_cmd = sub.add_parser( + "inspect", + help=( + "sidecar diagnostic for a providence_cache record — pulls " + "source chunks and classifies each unverified span " + "(paraphrase / trailing_artifact / interior_elision / " + "synthetic_elision_inside_quote / " + "no_overlap). Read-only, " + "no audit events, no v9.8 field changes." + ), + ) + inspect_cmd.add_argument( + "--cache-key", dest="cache_key", required=True, + help="64-char hex cache_key of the providence record to inspect", + ) + inspect_cmd.add_argument( + "--qa-db", dest="qa_db", default=None, + help="path to qa.db (default: <shards>/qa.db or ~/.aborist/qa.db)", + ) + inspect_cmd.add_argument( + "--json", action="store_true", + help="emit raw diagnosis as JSON (default: human render)", + ) + inspect_cmd.set_defaults(func=_cmd_inspect) + + prov_cmd = sub.add_parser( + "providence", + help="list or falsify providence_cache records", + ) + prov_cmd.add_argument("--document-uri", dest="document_uri", default=None) + prov_cmd.add_argument("--source-root", dest="source_root", default=None) + prov_cmd.add_argument("--limit", type=int, default=20) + prov_cmd.add_argument( + "--falsify", + default=None, + metavar="CACHE_KEY", + help=( + "mark a providence_cache record as failed/stale/quarantined. " + "Lookups will skip it. Audit chain records the act" + ), + ) + prov_cmd.add_argument( + "--state", + default="failed", + choices=["failed", "stale", "quarantined"], + help="falsification state to set (default: failed)", + ) + prov_cmd.add_argument( + "--reason", + default=None, + help="reason text stored in falsifications log", + ) + prov_cmd.add_argument( + "--by-actor", + dest="by_actor", + default=None, + help="who is falsifying (default: $USER)", + ) + prov_cmd.add_argument( + "--show-preflight", + dest="show_preflight", + default=None, + metavar="CACHE_KEY_PREFIX", + help=( + "Pull the preflight stage payload from a row's " + "run_dag_blob. Match by 12-char prefix. Renders the " + "preflight stage hash + run-DAG stage list. Operator " + "tool for inspecting the policy state that governed a " + "cached row (#000009 §7.2)." + ), + ) + prov_cmd.set_defaults(func=_cmd_providence) + + burn_cmd = sub.add_parser( + "burn", + help=( + "delete a leaf with no children — providence_cache, document, " + "or core (kindergarten use; falsify/evict are audit-preserving)" + ), + ) + burn_cmd.add_argument( + "--kind", + choices=("providence", "document", "core"), + default="providence", + help="leaf kind to burn (default: providence — backwards-compatible)", + ) + burn_cmd.add_argument( + "--cache-key", + dest="cache_key", + default=None, + help="cache_key (hex) of the providence record to burn (kind=providence)", + ) + burn_cmd.add_argument( + "--root", + dest="root", + default=None, + help="document_root (hex) of the document/core to burn (kind=document|core)", + ) + burn_cmd.add_argument( + "--reason", + default=None, + help="reason text recorded in the burn audit event", + ) + burn_cmd.add_argument( + "--by-actor", + dest="by_actor", + default=None, + help="who is burning (default: $USER)", + ) + burn_cmd.add_argument( + "--force", + action="store_true", + help="burn even if children exist; not recommended", + ) + burn_cmd.set_defaults(func=_cmd_burn) + + burn_kg_cmd = sub.add_parser( + "burn-kindergarten", + help=( + "burn every providence_cache record younger than the " + "kindergarten window — test-ergonomic mass cleanup that " + "matches the mesh-sync kindergarten window so only " + "un-broadcast records get busted" + ), + ) + burn_kg_cmd.add_argument( + "--kindergarten-seconds", + dest="kindergarten_seconds", + type=int, + default=3600, + help=( + "burn rows younger than this many seconds (default: 3600 = 1 " + "hour, mirrors mesh sync default). 0 = burn everything live." + ), + ) + burn_kg_cmd.add_argument( + "--reason", default=None, + help="reason text recorded in each providence_burn audit event", + ) + burn_kg_cmd.add_argument( + "--by-actor", dest="by_actor", default=None, + help="who is burning (default: $USER)", + ) + burn_kg_cmd.add_argument( + "--force", action="store_true", + help="burn even if rows have falsification children", + ) + burn_kg_cmd.add_argument( + "--dry-run", dest="dry_run", action="store_true", + help="report what would burn without writing", + ) + burn_kg_cmd.add_argument( + "--verbose", type=int, default=10, + help="include this many items in the result JSON (default: 10)", + ) + burn_kg_cmd.set_defaults(func=_cmd_burn_kindergarten) + + reclassify_cmd = sub.add_parser( + "reclassify", + help="re-run the verifier against existing live providence records " + "(no LLM calls; relabels stale classifications)", + ) + reclassify_cmd.add_argument( + "--qa-db", dest="qa_db", default=None, + help="path to qa.db (default: <shards>/qa.db or ~/.aborist/qa.db)", + ) + reclassify_cmd.add_argument( + "--limit", type=int, default=0, + help="reclassify at most N records (0 = unlimited)", + ) + reclassify_cmd.add_argument( + "--dry-run", dest="dry_run", action="store_true", + help="report what would change without writing", + ) + reclassify_cmd.add_argument( + "--entity-policy", dest="entity_policy", default=None, + choices=["strict", "hybrid", "drop", "proximity"], + help=( + "how the entity path classifies: 'strict' (legacy, overclaims), " + "'hybrid' (default — caps at HYBRID), 'drop' (skip entity path → " + "UNGROUNDED), 'proximity' (STRICT only if N entities cluster within " + "W chars in source)" + ), + ) + reclassify_cmd.add_argument( + "--compare", dest="compare", action="store_true", + help="run all four entity policies side-by-side without writing", + ) + reclassify_cmd.set_defaults(func=_cmd_reclassify) + + emergent_cmd = sub.add_parser( + "emergent", + help="surface UNGROUNDED/HYBRID claims — corpus-growth signal", + ) + emergent_cmd.add_argument( + "--aggregate", + action="store_true", + help="rank unverified quotes by frequency (vs per-record list)", + ) + emergent_cmd.add_argument("--limit", type=int, default=20) + emergent_cmd.set_defaults(func=_cmd_emergent) + + evict_cmd = sub.add_parser( + "evict", + help="demote surface chunks hot→cold (NULL content, retain leaf_hash)", + ) + evict_cmd.add_argument( + "--source-type", + dest="source_type", + default=None, + help="restrict to one source_type", + ) + evict_cmd.add_argument( + "--older-than-days", + dest="older_than_days", + type=int, + default=None, + help="only evict docs older than N days", + ) + evict_cmd.add_argument( + "--document-root", + action="append", + default=None, + help="explicit document_root(s) to evict; repeatable", + ) + evict_cmd.set_defaults(func=_cmd_evict) + + rehydrate_cmd = sub.add_parser( + "rehydrate", + help="refetch URI, verify leaves, restore cold content if root matches", + ) + rehydrate_cmd.add_argument( + "--document-root", + action="append", + default=None, + help="explicit document_root(s) to rehydrate; repeatable", + ) + rehydrate_cmd.add_argument( + "--all-cold", + dest="all_cold", + action="store_true", + help="rehydrate every document with cold chunks", + ) + rehydrate_cmd.set_defaults(func=_cmd_rehydrate) + + activity_cmd = sub.add_parser( + "activity", + help="recent Q&A + freshly cached docs (agent-readable timeline)", + ) + activity_cmd.add_argument( + "--limit", type=int, default=10, + help="max items per category (default 10)", + ) + activity_cmd.add_argument( + "--since-seconds", + dest="since_seconds", + type=int, + default=0, + help="only events newer than this many seconds (0 = all time, default)", + ) + activity_cmd.add_argument( + "--preview-chars", + dest="preview_chars", + type=int, + default=240, + help="answer preview length (default 240 chars)", + ) + activity_cmd.set_defaults(func=_cmd_activity) + + stats_cmd = sub.add_parser("stats", help="counts: docs, chunks, edges, audit") + stats_cmd.set_defaults(func=_cmd_stats) + + analyze_cmd = sub.add_parser( + "analyze", + help="compression spectrum, depth distribution, audit chain integrity", + ) + analyze_cmd.add_argument( + "--gravity-top", + dest="gravity_top", + type=int, + default=10, + help="N top inbound-linked URIs to report (default 10)", + ) + analyze_cmd.set_defaults(func=_cmd_analyze) + + # ----- snapshot subcommands ---------------------------------------------- + snap_cmd = sub.add_parser( + "snapshot", + help="corpus-level Merkle snapshots: pin a forest state by single root", + ) + snap_sub = snap_cmd.add_subparsers(dest="snap_op", required=True) + + snap_create = snap_sub.add_parser( + "create", help="compute snapshot_root from current corpus, persist + audit" + ) + snap_create.add_argument("--reason", default="manual") + snap_create.add_argument( + "--parent", + default=None, + help="explicit parent_snapshot hex (default: auto-link to latest prior snapshot)", + ) + snap_create.set_defaults(func=_cmd_snapshot_create) + + snap_list = snap_sub.add_parser("list", help="recent snapshots, newest first") + snap_list.add_argument("--limit", type=int, default=20) + snap_list.set_defaults(func=_cmd_snapshot_list) + + snap_verify = snap_sub.add_parser( + "verify", + help="recompute root from current corpus; matches=True iff nothing has changed", + ) + snap_verify.add_argument("snapshot_root", help="hex snapshot_root to verify") + snap_verify.set_defaults(func=_cmd_snapshot_verify) + + snap_diff = snap_sub.add_parser( + "diff", + help="coarse drift signal between a snapshot and the current corpus", + ) + snap_diff.add_argument("snapshot_root", help="hex snapshot_root to diff against current") + snap_diff.set_defaults(func=_cmd_snapshot_diff) + + # ----- mesh subcommands (off by default) --------------------------------- + mesh_cmd = sub.add_parser( + "mesh", + help="federation/gossip layer (off by default; opt-in via 'mesh enable')", + ) + mesh_sub = mesh_cmd.add_subparsers(dest="mesh_op", required=True) + + mesh_status = mesh_sub.add_parser("status", help="show enabled flag, identity, current epoch + roster") + mesh_status.set_defaults(func=_cmd_mesh_status) + + mesh_init = mesh_sub.add_parser("init", help="generate this peer's keys; create epoch 0") + mesh_init.add_argument("--group", required=True, help="group name") + mesh_init.add_argument("--member-id", dest="member_id", default=None, help="optional fixed member id (default: random 8-hex)") + mesh_init.set_defaults(func=_cmd_mesh_init) + + mesh_enable = mesh_sub.add_parser("enable", help="flip the mesh.enabled flag on") + mesh_enable.set_defaults(func=_cmd_mesh_enable) + + mesh_disable = mesh_sub.add_parser("disable", help="flip the mesh.enabled flag off") + mesh_disable.set_defaults(func=_cmd_mesh_disable) + + mesh_members = mesh_sub.add_parser("members", help="list current epoch's roster") + mesh_members.set_defaults(func=_cmd_mesh_members) + + mesh_add = mesh_sub.add_parser("add", help="admin-only: add a peer to the roster (bumps epoch)") + mesh_add.add_argument("--member-id", dest="member_id", required=True) + mesh_add.add_argument("--sign-pub", dest="sign_pub", required=True, help="hex Ed25519 pubkey (32 bytes / 64 hex chars)") + mesh_add.add_argument("--dh-pub", dest="dh_pub", required=True, help="hex X25519 pubkey") + mesh_add.add_argument("--role", choices=["admin", "member"], default="member") + mesh_add.set_defaults(func=_cmd_mesh_add) + + mesh_kick = mesh_sub.add_parser("kick", help="admin-only: evict a peer (bumps epoch; old signatures stay valid, new gossip is opaque to them)") + mesh_kick.add_argument("--member-id", dest="member_id", required=True) + mesh_kick.add_argument("--reason", required=True) + mesh_kick.set_defaults(func=_cmd_mesh_kick) + + mesh_rotate = mesh_sub.add_parser("rotate", help="refresh epoch secret without changing roster") + mesh_rotate.add_argument("--reason", default="scheduled") + mesh_rotate.set_defaults(func=_cmd_mesh_rotate) + + mesh_serve = mesh_sub.add_parser( + "serve", + help="run the HTTP gossip server (blocks until SIGINT)", + ) + mesh_serve.add_argument("--host", default="127.0.0.1", help="bind host (default: 127.0.0.1)") + mesh_serve.add_argument("--port", type=int, default=8400, help="bind port (default: 8400)") + mesh_serve.set_defaults(func=_cmd_mesh_serve) + + mesh_sync = mesh_sub.add_parser( + "sync", + help="announce local document_roots to a peer's gossip server", + ) + mesh_sync.add_argument("--peer", required=True, help="peer URL, e.g. http://other.example.com:8400") + mesh_sync.add_argument("--limit", type=int, default=100, help="announce at most N most-recent items per category (default: 100)") + mesh_sync.add_argument("--verbose", type=int, default=10, help="include this many ack details in output (default: 10)") + mesh_sync.add_argument( + "--no-roots", + dest="no_roots", + action="store_true", + help="skip ANNOUNCE_ROOT broadcast (only push falsifications)", + ) + mesh_sync.add_argument( + "--no-falsifications", + dest="no_falsifications", + action="store_true", + help="skip ANNOUNCE_FALSIFICATION broadcast (only push roots)", + ) + mesh_sync.add_argument( + "--kindergarten-seconds", + dest="kindergarten_seconds", + type=int, + default=3600, + help=( + "hold records younger than this many seconds back from the " + "broadcast (default: 3600 = 1 hour). Gives operators time to " + "burn or falsify before peers see it. 0 = broadcast everything." + ), + ) + mesh_sync.set_defaults(func=_cmd_mesh_sync) + + mesh_pull = mesh_sub.add_parser( + "pull", + help="pull one document body from a peer by document_root", + ) + mesh_pull.add_argument("--root", required=True, help="64-char hex document_root to pull") + mesh_pull.add_argument("--peer", required=True, help="peer URL, e.g. http://other.example.com:8400") + mesh_pull.set_defaults(func=_cmd_mesh_pull) + + crawl_cmd = sub.add_parser( + "crawl", + help=( + "BFS-discover same-domain URLs from a seed; optionally ingest " + "and store ETag/Last-Modified for cheap recrawl-checks " + "(requires aborist[crawler] extras)" + ), + ) + crawl_cmd.add_argument("--seed-url", dest="seed_url", required=True) + crawl_cmd.add_argument("--depth", type=int, default=2, help="max BFS depth (default: 2)") + crawl_cmd.add_argument( + "--max-pages", + dest="max_pages", + type=int, + default=0, + help="cap discovery at N URLs (0 = no cap, depth is the only bound; default: 0)", + ) + crawl_cmd.add_argument( + "--ingest", + action="store_true", + help="ingest the discovered pages into --db (default: print URL list only)", + ) + crawl_cmd.add_argument( + "--fast", + action="store_true", + help=( + "fast_mode: 5s timeouts, CPU*3 parallel page workers, ignore " + "robots.txt crawl-delay (Disallow is still honored). Use only " + "against domains where aggressive fetching is acceptable." + ), + ) + crawl_cmd.set_defaults(func=_cmd_crawl) + + crawler_cmd = sub.add_parser( + "crawler", + help="crawler maintenance verbs (recrawl-check, ...)", + ) + crawler_sub = crawler_cmd.add_subparsers(dest="crawler_op", required=True) + + recrawl_check_cmd = crawler_sub.add_parser( + "recrawl-check", + help=( + "send conditional HEAD requests for ingested documents and " + "classify each as fresh/stale/gone/unreachable" + ), + ) + recrawl_check_cmd.add_argument( + "--domain", + default=None, + help="restrict to documents whose URI contains this domain", + ) + recrawl_check_cmd.add_argument( + "--limit", + type=int, + default=100, + help="check at most N documents (oldest checks first; default: 100)", + ) + recrawl_check_cmd.set_defaults(func=_cmd_crawler_recrawl_check) + + return p
+ + + +
+[docs] +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + return args.func(args)
+ + + +if __name__ == "__main__": + raise SystemExit(main()) +
+
+
+
+ + +
+
+ + Made with Sphinx and @pradyunsg's + + Furo + +
+
+ +
+
+ +
+
+ +
+
+ + + + + \ No newline at end of file diff --git a/docs/_source/_build/html/_modules/aborist/concepts/query.html b/docs/_source/_build/html/_modules/aborist/concepts/query.html new file mode 100644 index 0000000..e27b335 --- /dev/null +++ b/docs/_source/_build/html/_modules/aborist/concepts/query.html @@ -0,0 +1,573 @@ + + + + + + + + aborist.concepts.query - Aborist API Reference + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + + + +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for aborist.concepts.query

+"""Retrieval-time concept lookup. Cross-shard, read-only.
+
+Public API matches the legacy ``aborist.qa.concepts`` shape so existing
+call sites in ``query.py`` keep working unchanged. Behavior changes:
+
+- Backed by the ``concept_relations`` SQLite table instead of in-Python
+  frozensets.
+- Walks every shard in ``shards_dir`` (same UNION pattern as cross-shard
+  FTS5 search). Concept relations from shard 003 are visible to a query
+  routed at shard 000 — exactly what we want for a 3.47M-doc corpus
+  split across many shards.
+- Token comparison is case-insensitive at the SQL layer (NOCASE on
+  LOWER()). Stored capitalization is preserved.
+
+Cache: a per-process LRU keyed on ``shards_dir`` mtime. Lookups in a
+hot loop don't re-walk shards. Cache invalidates when any shard file's
+mtime changes (e.g. after `aborist concepts derive` writes new rows).
+"""
+
+from __future__ import annotations
+
+import re
+import time
+from pathlib import Path
+
+from aborist.store import connect_query
+
+# Tokens that mean "user wants both sides of any rivalry shown" —
+# kept here (not in DB) because compare-phrasing detection is a
+# query-classification task, not a corpus-derived signal.
+COMPARE_WORDS: frozenset[str] = frozenset({
+    "vs", "versus", "compare", "compared", "comparison", "compares",
+    "between", "difference", "differences", "or", "either",
+})
+
+
+
+[docs] +def has_compare_phrasing(question: str) -> bool: + """True if the question contains comparison language.""" + lower = (question or "").lower() + words = set(re.findall(r"[a-z]+", lower)) + return bool(words & COMPARE_WORDS)
+ + + +# --------------------------------------------------------------------------- +# Cross-shard lookup with mtime-keyed cache +# --------------------------------------------------------------------------- + +# Cache shape: { shards_dir_str: (mtime_sig, manual_index, derived_index, rivalry_pairs, token_idf) } +# - manual_index: curated synonym edges; always expanded +# - derived_index: corpus-derived synonym edges; expansion subject to per-token cap +# - rivalry_pairs: list of (set_a, set_b) — frozensets of lowercase tokens +# - token_idf: { token_lower: doc_freq } summed across shards, used for cap-time ranking +_CACHE: dict[str, tuple[tuple, dict, dict, list, dict]] = {} + + +def _shards_mtime_signature(shards_dir: Path) -> tuple: + """Return a tuple of (path, mtime_ns) for every *.db in shards_dir. + Stable across runs as long as no shard's mtime changes.""" + if not shards_dir.is_dir(): + return () + return tuple( + (str(p.resolve()), p.stat().st_mtime_ns) + for p in sorted(shards_dir.glob("*.db")) + ) + + +def _load_token_idf(shards_dir: Path) -> dict[str, int]: + """Sum per-token doc_freq across all shards. + + The concept_token_idf table is per-shard (the `chunks_fts` index + it derives from is per-shard), so cross-shard ranking aggregates + via SUM over the UNION view exposed by connect_query. Tokens not + in any shard's table get NO entry; callers default to "treat as + rare" via dict.get() with a high-rarity default. + """ + conn = connect_query(shards_dir=shards_dir) + rows = conn.execute( + "SELECT token, SUM(doc_freq) AS df FROM concept_token_idf GROUP BY token" + ).fetchall() + conn.close() + return {(r["token"] or "").lower(): int(r["df"] or 0) for r in rows} + + +def _load_indices(shards_dir: Path) -> tuple[dict, dict, list]: + """Walk all shards, materialize the synonym & rivalry indices. + + Synonyms map every token to its **direct neighbors only**, NOT the + transitive closure across reciprocal-link chains. Why: union-find + over the full Wikipedia reciprocal-link graph collapses everything + into one giant connected component (54k+ tokens for any seed in + a 4-shard corpus). Direct-neighbor expansion preserves the legacy + frozenset semantics (every member of a group expanded to the others + in that group, but the groups didn't chain). + + Two synonym indices are built, one per evidence class: + + - ``manual_index`` — manual_legacy + manual rows. Curated; always + expanded regardless of per-token degree. + Captures the brain-tech / AMD-family / etc. + seed groups whose anchor token has many + deliberate members (e.g. telepathy → 29 + members of the brain-tech group). + - ``derived_index`` — link_reciprocity & other corpus-derived + extractors. Subject to per-token degree + cap because the Wikipedia link graph + carries topic-adjacency noise on generic + tokens (person, thoughts, language). + + Rivalry pairs use the union of both indices for closure. + """ + conn = connect_query(shards_dir=shards_dir) + rows = conn.execute( + "SELECT relation_kind, evidence_kind, token, target FROM concept_relations" + ).fetchall() + conn.close() + + manual_index: dict[str, set[str]] = {} + derived_index: dict[str, set[str]] = {} + rivalry_rows: list[tuple[str, str]] = [] + + # Curated evidence kinds — never capped at expansion time. + MANUAL_KINDS = {"manual", "manual_legacy"} + + for r in rows: + kind = r["relation_kind"] + evidence_kind = r["evidence_kind"] + a = (r["token"] or "").lower() + b = (r["target"] or "").lower() + if not a or not b or a == b: + continue + if kind == "synonym": + target_index = ( + manual_index if evidence_kind in MANUAL_KINDS else derived_index + ) + target_index.setdefault(a, set()).add(b) + target_index.setdefault(b, set()).add(a) + elif kind == "rivalry": + rivalry_rows.append((a, b)) + + # Rivalry pairs use union of manual + derived neighborhoods. + rivalry_pairs: list[tuple[frozenset[str], frozenset[str]]] = [] + for a, b in rivalry_rows: + ga = frozenset( + manual_index.get(a, set()) | derived_index.get(a, set()) | {a} + ) + gb = frozenset( + manual_index.get(b, set()) | derived_index.get(b, set()) | {b} + ) + rivalry_pairs.append((ga, gb)) + + return manual_index, derived_index, rivalry_pairs + + +def _get_indices(shards_dir: Path | str | None) -> tuple[dict, dict, list, dict]: + """Return (manual_index, derived_index, rivalry_pairs, token_idf), + using cached values when the shard mtime signature is unchanged.""" + if shards_dir is None: + return {}, {}, [], {} + p = Path(shards_dir) + key = str(p.resolve()) + sig = _shards_mtime_signature(p) + cached = _CACHE.get(key) + if cached is not None and cached[0] == sig: + return cached[1], cached[2], cached[3], cached[4] + manual, derived, riv = _load_indices(p) + idf = _load_token_idf(p) + _CACHE[key] = (sig, manual, derived, riv, idf) + return manual, derived, riv, idf + + +
+[docs] +def invalidate_cache() -> None: + """Drop all cached indices. Call after a writer commits new rows + (the mtime check would catch this on next read, but invalidating + explicitly is faster on the same-process write+read pattern).""" + _CACHE.clear()
+ + + +# --------------------------------------------------------------------------- +# Public API — matches the legacy ``aborist.qa.concepts`` shape +# --------------------------------------------------------------------------- + + +# Caps that match the legacy (frozenset) expansion size. The corpus- +# derived synonym graph is noisier than hand-curated frozensets: +# generic tokens like "person" / "thoughts" / "language" have ~20+ +# reciprocal-link neighbors each, most of which are topic-adjacency +# noise rather than actual synonyms. Without these caps a 19-token +# query expands to ~400 accept tokens, and the title-LIKE search +# multiplies that by the document count to a many-minute hang. +# +# MAX_NEIGHBORS_PER_TOKEN: any token with more than this many +# direct neighbors is treated as "too generic to expand" — we add +# only the token itself, not its neighbors. Mirrors how the legacy +# frozensets covered named entities (athlon, pentium) but not common +# words (person, thoughts). +# +# MAX_TOTAL_TOKENS: overall cap on the expanded set. Original query +# tokens are always preserved; once total exceeds the cap, neighbors +# are sorted alphabetically & truncated. Bound on title-LIKE clause +# count keeps the SQL tractable on a 3.47M-doc corpus. +MAX_NEIGHBORS_PER_TOKEN = 8 +MAX_TOTAL_TOKENS = 50 + + +
+[docs] +def synonym_expand( + tokens: set[str], + *, + shards_dir: Path | str | None = None, + max_neighbors_per_token: int = MAX_NEIGHBORS_PER_TOKEN, + max_total: int = MAX_TOTAL_TOKENS, +) -> set[str]: + """Add direct synonym neighbors for any input token whose degree + is bounded enough that its neighbors are likely topical, not + topic-adjacency noise. + + Two caps protect retrieval performance & quality: + + 1. ``max_neighbors_per_token`` — tokens with more direct neighbors + than this contribute NO expansion. Generic tokens ("person", + "thoughts") have huge degree in the Wikipedia reciprocal-link + graph; expanding them dumps random topical-cluster noise. + Specific named entities ("athlon", "telepathy") have small + focused neighborhoods that pass the cap. + + 2. ``max_total`` — overall cap on expanded set size. Bounds the + SQL clause count downstream. Original query tokens are always + preserved; if total > cap, neighbors are sorted alphabetically + & truncated. + """ + if not tokens: + return set() + if shards_dir is None: + return set(tokens) + manual_index, derived_index, _, token_idf = _get_indices(shards_dir) + qlower = {t.lower() for t in tokens} + expanded: set[str] = set(qlower) + # Manual (curated) synonyms always expand: brain-tech / AMD-family / + # etc. seed groups have legitimately many members per anchor & we + # trust the curation. + for t in qlower: + if t in manual_index: + expanded |= manual_index[t] + # Derived (corpus-extracted) synonyms cap on per-token degree. + # Generic tokens like "person" / "thoughts" / "language" have wide + # noisy neighborhoods in the reciprocal-link graph — skip those. + # Specific tokens with bounded degree expand cleanly. + for t in qlower: + if t not in derived_index: + continue + neighbors = derived_index[t] + if len(neighbors) > max_neighbors_per_token: + continue + expanded |= neighbors + if len(expanded) > max_total: + # IDF-rank the neighbors (rarer = more topical = keep first). + # Tokens absent from concept_token_idf get a sentinel high- + # rarity score so a hapax doesn't lose to a known-common + # token at the truncation boundary. When the IDF table is + # empty (backfill not run yet), ranking degenerates to + # alphabetical (fallback compatibility). + neighbors_only = expanded - qlower + # Lower doc_freq → rarer → higher rank → kept first. + # `total_docs + 1` sentinel pushes "missing" tokens to the + # rarest-bucket so they tie-break before the most common. + SENTINEL_HIGH_RARITY = 0 + ranked = sorted( + neighbors_only, + key=lambda t: (token_idf.get(t, SENTINEL_HIGH_RARITY), t), + ) + budget = max(0, max_total - len(qlower)) + expanded = qlower | set(ranked[:budget]) + return expanded
+ + + +
+[docs] +def rivalry_excluded( + tokens: set[str], + *, + shards_dir: Path | str | None = None, + compare_phrasing: bool = False, +) -> set[str]: + """Tokens whose presence in a doc title means EXCLUDE that doc. + + For each rivalry pair (A, B): if exactly ONE side appears in the + query AND no comparison language was used, exclude the OTHER side's + tokens. If both sides appear, or if the user asked for a comparison, + no exclusion (they wanted both). + """ + if compare_phrasing or not tokens or shards_dir is None: + return set() + _, _, rivalry_pairs, _ = _get_indices(shards_dir) + qlower = {t.lower() for t in tokens} + excluded: set[str] = set() + for a, b in rivalry_pairs: + a_in = bool(qlower & a) + b_in = bool(qlower & b) + if a_in and not b_in: + excluded |= b + elif b_in and not a_in: + excluded |= a + return excluded
+ +
+
+
+
+ + +
+
+ + Made with Sphinx and @pradyunsg's + + Furo + +
+
+ +
+
+ +
+
+ +
+
+ + + + + \ No newline at end of file diff --git a/docs/_source/_build/html/_modules/aborist/concepts/store.html b/docs/_source/_build/html/_modules/aborist/concepts/store.html new file mode 100644 index 0000000..8ccdafe --- /dev/null +++ b/docs/_source/_build/html/_modules/aborist/concepts/store.html @@ -0,0 +1,394 @@ + + + + + + + + aborist.concepts.store - Aborist API Reference + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + + + +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for aborist.concepts.store

+"""DB read/write helpers for concept_relations.
+
+All operations are scoped to a single shard connection. Cross-shard
+queries live in ``aborist.concepts.query``.
+
+Append-only by design: ``add_concept_relation`` uses INSERT OR IGNORE
+on the UNIQUE (source_root, relation_kind, token, target, evidence_kind)
+key, so re-derivation never duplicates rows. ``purge_by_evidence_kind``
+is the only DELETE path & lets an operator revoke one extractor's
+output without touching manual or other-extractor rows.
+"""
+
+from __future__ import annotations
+
+import sqlite3
+import time
+
+# Allowed relation_kind values. The schema's CHECK constraint enforces
+# this too — keeping the Python-side tuple in sync makes API misuse
+# fail loud at the helper level rather than as a SQLite error.
+RELATION_KINDS = ("synonym", "antonym", "rivalry", "category")
+
+
+
+[docs] +def add_concept_relation( + conn: sqlite3.Connection, + *, + source_root: str, + relation_kind: str, + token: str, + target: str, + evidence_kind: str, + confidence: float = 1.0, + derived_from: str | None = None, + derived_at: int | None = None, +) -> bool: + """Append a concept relation. Returns True if a row was inserted, + False if the (source_root, relation_kind, token, target, evidence_kind) + tuple already existed (idempotent re-derivation). + + Tokens are stored exactly as given — case preservation lets the + query layer decide normalization. Substring lookup at retrieval + time is case-insensitive via SQLite's NOCASE comparator. + """ + if relation_kind not in RELATION_KINDS: + raise ValueError( + f"relation_kind must be one of {RELATION_KINDS}, got {relation_kind!r}" + ) + if not token or not target: + raise ValueError("token and target must be non-empty") + if derived_at is None: + derived_at = int(time.time()) + cursor = conn.execute( + "INSERT OR IGNORE INTO concept_relations " + "(source_root, relation_kind, token, target, evidence_kind, " + " confidence, derived_at, derived_from) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ( + source_root, + relation_kind, + token, + target, + evidence_kind, + float(confidence), + int(derived_at), + derived_from, + ), + ) + return cursor.rowcount > 0
+ + + +
+[docs] +def concept_relations_for_token( + conn: sqlite3.Connection, + token: str, + *, + relation_kind: str | None = None, +) -> list[dict]: + """Return all relations whose ``token`` matches (case-insensitive). + If ``relation_kind`` is given, filter to that kind.""" + sql = ( + "SELECT source_root, relation_kind, token, target, evidence_kind, " + " confidence, derived_at, derived_from " + "FROM concept_relations WHERE LOWER(token) = LOWER(?)" + ) + params: tuple = (token,) + if relation_kind: + if relation_kind not in RELATION_KINDS: + raise ValueError( + f"relation_kind must be one of {RELATION_KINDS}, got {relation_kind!r}" + ) + sql += " AND relation_kind = ?" + params = params + (relation_kind,) + return [dict(row) for row in conn.execute(sql, params).fetchall()]
+ + + +
+[docs] +def purge_by_evidence_kind( + conn: sqlite3.Connection, + evidence_kind: str, + *, + derived_from: str | None = None, +) -> int: + """Delete every row with the given ``evidence_kind`` (and optional + ``derived_from``). Returns the number of rows removed. + + The intended use: revoke a buggy extractor's output cleanly. Manual + rows live under ``evidence_kind='manual'`` and are NOT touched by + a purge of any other kind. + """ + sql = "DELETE FROM concept_relations WHERE evidence_kind = ?" + params: tuple = (evidence_kind,) + if derived_from is not None: + sql += " AND derived_from = ?" + params = params + (derived_from,) + cursor = conn.execute(sql, params) + return cursor.rowcount
+ + + +def list_evidence_kinds(conn: sqlite3.Connection) -> list[tuple[str, int]]: + """Return ``[(evidence_kind, row_count), ...]`` for the shard, ordered + by row_count descending. Useful for ``aborist concepts list --kinds``.""" + rows = conn.execute( + "SELECT evidence_kind, COUNT(*) AS n " + "FROM concept_relations GROUP BY evidence_kind ORDER BY n DESC" + ).fetchall() + return [(r["evidence_kind"], r["n"]) for r in rows] +
+
+
+
+ + +
+
+ + Made with Sphinx and @pradyunsg's + + Furo + +
+
+ +
+
+ +
+
+ +
+
+ + + + + \ No newline at end of file diff --git a/docs/_source/_build/html/_modules/aborist/distill/base.html b/docs/_source/_build/html/_modules/aborist/distill/base.html new file mode 100644 index 0000000..736c592 --- /dev/null +++ b/docs/_source/_build/html/_modules/aborist/distill/base.html @@ -0,0 +1,312 @@ + + + + + + + + aborist.distill.base - Aborist API Reference + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + + + +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for aborist.distill.base

+"""Distiller ABC.
+
+A Distiller compresses a surface Document into a core Document. The runner
+generates Merkle proofs for every contributing source chunk so the resulting
+derivation row cryptographically binds the core back to its source.
+"""
+
+from __future__ import annotations
+
+from abc import ABC, abstractmethod
+from dataclasses import dataclass
+
+from aborist.document import Document
+
+
+
+[docs] +@dataclass +class DistillationResult: + """What a Distiller returns for one source. + + contributing_chunk_indices: 0-based indices into source_chunks that fed + the core's content. The runner Merkle-proves each one against the + source's document_root and stores those proofs in derivations.proof_blob. + """ + + core: Document + contributing_chunk_indices: list[int]
+ + + +
+[docs] +class Distiller(ABC): + """Pure function: surface Document + its chunks -> core DistillationResult. + + Distillers must be deterministic — same source bytes produce the same core + bytes. Bumping a distiller's algorithm requires bumping its `name`. + """ + + name: str + +
+[docs] + @abstractmethod + def distill( + self, source: Document, source_chunks: list[str] + ) -> DistillationResult: + ...
+
+ +
+
+
+
+ + +
+
+ + Made with Sphinx and @pradyunsg's + + Furo + +
+
+ +
+
+ +
+
+ +
+
+ + + + + \ No newline at end of file diff --git a/docs/_source/_build/html/_modules/aborist/distill/first_sentence.html b/docs/_source/_build/html/_modules/aborist/distill/first_sentence.html new file mode 100644 index 0000000..ce2109f --- /dev/null +++ b/docs/_source/_build/html/_modules/aborist/distill/first_sentence.html @@ -0,0 +1,330 @@ + + + + + + + + aborist.distill.first_sentence - Aborist API Reference + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + + + +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for aborist.distill.first_sentence

+"""First-sentence-per-chunk distiller (deterministic, no ML).
+
+Compresses a source by taking the first non-trivial sentence of each chunk
+and concatenating. Useful as a stub to exercise the core/derivation schema
+without external models.
+"""
+
+from __future__ import annotations
+
+import re
+
+from aborist.distill.base import DistillationResult, Distiller
+from aborist.document import Document
+
+_SENTENCE_BOUNDARY = re.compile(r"(?<=[.!?])\s+(?=[A-Z0-9])")
+_MIN_SENTENCE_LEN = 10
+
+
+
+[docs] +class FirstSentenceDistiller(Distiller): + name = "first-sentence-v1" + + def __init__(self, max_chars: int = 4096): + self.max_chars = max_chars + +
+[docs] + def distill( + self, source: Document, source_chunks: list[str] + ) -> DistillationResult: + sentences: list[str] = [] + contributing: list[int] = [] + for chunk_idx, chunk in enumerate(source_chunks): + first = self._first_sentence(chunk) + if first: + sentences.append(first) + contributing.append(chunk_idx) + + core_text = "\n".join(sentences) + if len(core_text) > self.max_chars: + core_text = core_text[: self.max_chars].rstrip() + + core = Document( + uri=f"{source.uri}#core/{self.name}", + content=core_text, + source_type=f"core:{self.name}", + title=(source.title or "") + " [CORE]", + ) + return DistillationResult(core=core, contributing_chunk_indices=contributing)
+ + + @staticmethod + def _first_sentence(text: str) -> str | None: + text = text.strip() + if not text: + return None + # Try paragraph-aware: split on blank lines first, then take first paragraph. + for para in text.split("\n\n"): + para = para.strip() + if not para: + continue + # First sentence within the paragraph. + parts = _SENTENCE_BOUNDARY.split(para, maxsplit=1) + first = parts[0].strip() + if len(first) >= _MIN_SENTENCE_LEN: + return first + return None
+ +
+
+
+
+ + +
+
+ + Made with Sphinx and @pradyunsg's + + Furo + +
+
+ +
+
+ +
+
+ +
+
+ + + + + \ No newline at end of file diff --git a/docs/_source/_build/html/_modules/aborist/distill/tfidf.html b/docs/_source/_build/html/_modules/aborist/distill/tfidf.html new file mode 100644 index 0000000..7fdf165 --- /dev/null +++ b/docs/_source/_build/html/_modules/aborist/distill/tfidf.html @@ -0,0 +1,363 @@ + + + + + + + + aborist.distill.tfidf - Aborist API Reference + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + + + +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for aborist.distill.tfidf

+"""TF-IDF top-K keyword distiller.
+
+Pure-Python, stdlib-only. Uses chunk-level "documents" as the corpus
+baseline so a term that appears in every chunk of the same source gets
+penalized relative to a term concentrated in fewer chunks.
+
+Output core content is a deterministic comma-separated keyword list —
+extreme compression toward the "tweet/haiku" end of the planet metaphor.
+"""
+
+from __future__ import annotations
+
+import math
+import re
+from collections import Counter
+
+from aborist.distill.base import DistillationResult, Distiller
+from aborist.document import Document
+
+
+_TOKEN_RE = re.compile(r"\b[a-zA-Z][a-zA-Z\-']{2,}\b")
+_STOPWORDS = frozenset(
+    """
+    the and or in of to is are was were for with on as by an be this that
+    these those from at but not have has had been they their his her its
+    our we you he she it all any if no more than such also can may will
+    would should could do does did between which where when what who how
+    some many most into through during above below after before since
+    while well much even only still about other only own same so very
+    just over under
+    """.split()
+)
+
+
+def _tokenize(text: str) -> list[str]:
+    return [t.lower() for t in _TOKEN_RE.findall(text) if t.lower() not in _STOPWORDS]
+
+
+
+[docs] +class TfidfKeywordDistiller(Distiller): + name = "tfidf-keywords-v1" + + def __init__(self, top_k: int = 16): + self.top_k = top_k + +
+[docs] + def distill( + self, source: Document, source_chunks: list[str] + ) -> DistillationResult: + if not source_chunks: + return self._empty_result(source) + + chunk_tokens = [_tokenize(c) for c in source_chunks] + n = len(chunk_tokens) + + df: Counter[str] = Counter() + for toks in chunk_tokens: + df.update(set(toks)) + + tf: Counter[str] = Counter() + for toks in chunk_tokens: + tf.update(toks) + + scores: dict[str, float] = {} + for t, f in tf.items(): + idf = math.log((n + 1) / (df[t] + 1)) + 1.0 + scores[t] = f * idf + + ranked = sorted(scores.items(), key=lambda kv: (-kv[1], kv[0])) + keywords = [t for t, _ in ranked[: self.top_k]] + kw_set = set(keywords) + + contributing: set[int] = set() + for i, toks in enumerate(chunk_tokens): + if any(t in kw_set for t in toks): + contributing.add(i) + + core_text = ", ".join(keywords) + return DistillationResult( + core=Document( + uri=f"{source.uri}#core/{self.name}", + content=core_text, + source_type=f"core:{self.name}", + title=(source.title or "") + " [KEYWORDS]", + ), + contributing_chunk_indices=sorted(contributing), + )
+ + + def _empty_result(self, source: Document) -> DistillationResult: + return DistillationResult( + core=Document( + uri=f"{source.uri}#core/{self.name}", + content="", + source_type=f"core:{self.name}", + title=(source.title or "") + " [KEYWORDS]", + ), + contributing_chunk_indices=[], + )
+ +
+
+
+
+ + +
+
+ + Made with Sphinx and @pradyunsg's + + Furo + +
+
+ +
+
+ +
+
+ +
+
+ + + + + \ No newline at end of file diff --git a/docs/_source/_build/html/_modules/aborist/document.html b/docs/_source/_build/html/_modules/aborist/document.html new file mode 100644 index 0000000..1865f66 --- /dev/null +++ b/docs/_source/_build/html/_modules/aborist/document.html @@ -0,0 +1,392 @@ + + + + + + + + aborist.document - Aborist API Reference + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + + + +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for aborist.document

+"""Document and Chunk dataclasses + canonical chunkers.
+
+Every Document carries a URI (identification, backtrack, cross-link) and content.
+Chunkers split content into byte-determined chunks before Merkle hashing. The
+chunker's name is committed in chunking_version — changing chunker invalidates
+all prior cache records under v9.8 admissibility.
+"""
+
+from __future__ import annotations
+
+import re
+import unicodedata
+from dataclasses import dataclass, field
+from typing import Protocol
+
+
+
+[docs] +@dataclass(frozen=True) +class Edge: + """A cross-link from this document to another.""" + + edge_type: str # wikilink | citation | derived_from | ... + dst_uri: str # always present + dst_root: str | None = None # filled in later if/when target is ingested + anchor: str | None = None # optional fragment / chunk index
+ + + +
+[docs] +@dataclass +class Document: + """An ingestable document: URI + content + outbound edges.""" + + uri: str + content: str # normalized text + source_type: str # wikipedia_xml | html | git | ... + title: str | None = None + edges: list[Edge] = field(default_factory=list) + extra: dict = field(default_factory=dict) # source-specific metadata
+ + + +
+[docs] +def canonicalize(text: str) -> str: + """Stable text normalization. Bumping this requires CANONICALIZATION_VERSION bump.""" + # NFC unicode, normalize whitespace runs to single spaces, strip ends. + text = unicodedata.normalize("NFC", text) + text = re.sub(r"[\r\n\t\f\v]+", "\n", text) + text = re.sub(r"[ ]{2,}", " ", text) + return text.strip()
+ + + +
+[docs] +class Chunker(Protocol): + """A chunker splits canonicalized text into ordered chunks.""" + + name: str + +
+[docs] + def split(self, text: str) -> list[str]: ...
+
+ + + +
+[docs] +class TokenChunker: + """512-token chunker (whitespace-tokenized, byte-deterministic). + + "Token" here means whitespace-separated unit, NOT a model BPE token. This + avoids tokenizer-version drift in the chunking_version. + """ + + name = "tok-512-v1" + + def __init__(self, tokens_per_chunk: int = 512): + self.tokens_per_chunk = tokens_per_chunk + +
+[docs] + def split(self, text: str) -> list[str]: + if not text: + return [] + tokens = text.split() + if not tokens: + return [] + chunks: list[str] = [] + for start in range(0, len(tokens), self.tokens_per_chunk): + chunks.append(" ".join(tokens[start : start + self.tokens_per_chunk])) + return chunks
+
+ + + +
+[docs] +class SentenceChunker: + """Sentence-aligned chunker (better for short docs like 2003 Wikipedia).""" + + name = "sent-v1" + + _split_re = re.compile(r"(?<=[.!?])\s+(?=[A-Z0-9])") + +
+[docs] + def split(self, text: str) -> list[str]: + if not text: + return [] + # Naive but deterministic. + sentences = [s.strip() for s in self._split_re.split(text) if s.strip()] + return sentences or ([text] if text else [])
+
+ + + +
+[docs] +def get_chunker(name: str | None = None) -> Chunker: + """Lookup chunker by name. Default = TokenChunker.""" + if name is None or name == TokenChunker.name: + return TokenChunker() + if name == SentenceChunker.name: + return SentenceChunker() + raise ValueError(f"unknown chunker: {name}")
+ +
+
+
+
+ + +
+
+ + Made with Sphinx and @pradyunsg's + + Furo + +
+
+ +
+
+ +
+
+ +
+
+ + + + + \ No newline at end of file diff --git a/docs/_source/_build/html/_modules/aborist/evict.html b/docs/_source/_build/html/_modules/aborist/evict.html new file mode 100644 index 0000000..0e74efc --- /dev/null +++ b/docs/_source/_build/html/_modules/aborist/evict.html @@ -0,0 +1,496 @@ + + + + + + + + aborist.evict - Aborist API Reference + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + + + +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for aborist.evict

+"""Reversible eviction + rehydrate.
+
+Implements the systematic-forgetting mechanic from the design philosophy:
+
+- evict_to_cold: surface chunks demote from `hot` to `cold`; content set to
+  NULL, FTS5 row deleted. leaf_hash retained — identity preserved.
+- rehydrate: refetch URI through the same source pipeline, re-chunk with the
+  original chunking_version, compare leaves and root. Match -> content
+  restored, tier hot. Mismatch -> drift event in audit chain, providence
+  records flipped to falsification_state='stale'. No content restored.
+
+Cores never evict.
+"""
+
+from __future__ import annotations
+
+import sqlite3
+import time
+from typing import Callable, Iterable
+
+from aborist.compress import pack_chunk
+from aborist.document import canonicalize, get_chunker
+from aborist.merkle import MerkleTree, hash_leaf
+from aborist.store import append_audit, transaction
+
+
+# Re-fetcher signature: takes a URI, returns parsed/canonicalized text or None.
+Fetcher = Callable[[str], str | None]
+
+
+def _default_html_fetcher(uri: str) -> str | None:
+    """Reuse HtmlPageSource so rehydrate runs the exact same pipeline as ingest."""
+    try:
+        from aborist.sources.html_page import HtmlPageSource
+    except ImportError:
+        return None
+    src = HtmlPageSource([uri])
+    for doc in src.iter_documents():
+        return doc.content
+    return None
+
+
+_FETCHERS: dict[str, Fetcher] = {
+    "html": _default_html_fetcher,
+}
+
+
+
+[docs] +def evict_to_cold( + conn: sqlite3.Connection, + *, + source_type: str | None = None, + older_than_days: int | None = None, + document_roots: Iterable[str] | None = None, +) -> dict: + """Demote matching surface chunks from hot to cold. + + Cores are never evicted. Content is NULLed; FTS row removed. + """ + where = ["d.kind = 'surface'", "c.tier = 'hot'"] + params: list = [] + if source_type: + where.append("d.source_type = ?") + params.append(source_type) + if older_than_days is not None: + cutoff = int(time.time()) - older_than_days * 86400 + where.append("d.ingest_ts < ?") + params.append(cutoff) + if document_roots is not None: + roots = list(document_roots) + if not roots: + return {"evicted_chunks": 0, "documents_affected": 0} + placeholders = ",".join("?" for _ in roots) + where.append(f"c.document_root IN ({placeholders})") + params.extend(roots) + + sql = ( + "SELECT c.document_root, c.idx FROM chunks c " + "JOIN documents d ON d.document_root = c.document_root " + "WHERE " + " AND ".join(where) + ) + candidates = conn.execute(sql, params).fetchall() + if not candidates: + return {"evicted_chunks": 0, "documents_affected": 0} + + per_doc: dict[str, int] = {} + with transaction(conn): + for r in candidates: + # Delete from FTS5 first so we can resolve chunk_id via the same + # row before its content goes away. Contentless FTS5 deletions + # are addressed by rowid (== chunks.chunk_id). + chunk_id_row = conn.execute( + "SELECT chunk_id FROM chunks WHERE document_root=? AND idx=?", + (r["document_root"], r["idx"]), + ).fetchone() + if chunk_id_row is not None: + conn.execute( + "DELETE FROM chunks_fts WHERE rowid=?", + (chunk_id_row["chunk_id"],), + ) + conn.execute( + "UPDATE chunks SET content=NULL, tier='cold' " + "WHERE document_root=? AND idx=?", + (r["document_root"], r["idx"]), + ) + per_doc[r["document_root"]] = per_doc.get(r["document_root"], 0) + 1 + + for doc_root, n in per_doc.items(): + append_audit( + conn, + event_type="evict_cold", + subject_root=doc_root, + body={"chunks_evicted": n}, + ) + + return { + "evicted_chunks": len(candidates), + "documents_affected": len(per_doc), + }
+ + + +
+[docs] +def rehydrate( + conn: sqlite3.Connection, + document_root: str, + *, + fetcher: Fetcher | None = None, +) -> dict: + """Refetch URI, verify leaves, restore content if and only if root matches. + + Returns a dict with `status` ∈ { + unknown_document, nothing_to_do, source_not_rehydratable, + fetch_failed, drift_detected, rehydrated + }. + """ + doc_row = conn.execute( + "SELECT document_uri, source_type, chunking_version " + "FROM documents WHERE document_root = ?", + (document_root,), + ).fetchone() + if doc_row is None: + return {"status": "unknown_document"} + + cold_chunks = conn.execute( + "SELECT idx, leaf_hash FROM chunks " + "WHERE document_root = ? AND tier = 'cold' ORDER BY idx", + (document_root,), + ).fetchall() + if not cold_chunks: + return {"status": "nothing_to_do", "cold_chunks": 0} + + # Pick fetcher by source_type unless caller supplies one. + use_fetcher = fetcher or _FETCHERS.get(doc_row["source_type"]) + if use_fetcher is None: + return { + "status": "source_not_rehydratable", + "source_type": doc_row["source_type"], + } + + try: + text = use_fetcher(doc_row["document_uri"]) + except Exception as e: # noqa: BLE001 — surface any error in status + return {"status": "fetch_failed", "error": repr(e)} + if text is None: + return {"status": "fetch_failed", "error": "fetcher returned None"} + + chunker = get_chunker(doc_row["chunking_version"]) + new_text = canonicalize(text) + new_chunk_strs = chunker.split(new_text) + new_leaves = [hash_leaf(c.encode("utf-8")) for c in new_chunk_strs] + new_root = MerkleTree.build(new_leaves).root.hex() + + if new_root != document_root: + with transaction(conn): + append_audit( + conn, + event_type="rehydrate_drift", + subject_root=document_root, + body={ + "expected_root": document_root, + "actual_root": new_root, + "uri": doc_row["document_uri"], + }, + ) + # v9.8 falsification: any cached providence record from this + # source is now stale. + conn.execute( + "UPDATE providence_cache SET falsification_state = 'stale' " + "WHERE source_root = ? AND falsification_state = 'live'", + (document_root,), + ) + return { + "status": "drift_detected", + "expected_root": document_root, + "actual_root": new_root, + } + + # Roots match. Restore content for every cold chunk. + restored = 0 + with transaction(conn): + for c in cold_chunks: + i = c["idx"] + if i >= len(new_chunk_strs): + continue + recomputed = hash_leaf(new_chunk_strs[i].encode("utf-8")).hex() + if recomputed != c["leaf_hash"]: + # Defensive: shouldn't happen if roots match, but bail safely. + continue + conn.execute( + "UPDATE chunks SET content = ?, tier = 'hot' " + "WHERE document_root = ? AND idx = ?", + (pack_chunk(new_chunk_strs[i]), document_root, i), + ) + chunk_id_row = conn.execute( + "SELECT chunk_id FROM chunks WHERE document_root = ? AND idx = ?", + (document_root, i), + ).fetchone() + if chunk_id_row is not None: + conn.execute( + "INSERT INTO chunks_fts (rowid, content) VALUES (?, ?)", + (chunk_id_row["chunk_id"], new_chunk_strs[i]), + ) + restored += 1 + append_audit( + conn, + event_type="rehydrate_success", + subject_root=document_root, + body={"chunks_restored": restored, "uri": doc_row["document_uri"]}, + ) + + return {"status": "rehydrated", "chunks_restored": restored}
+ +
+
+
+
+ + +
+
+ + Made with Sphinx and @pradyunsg's + + Furo + +
+
+ +
+
+ +
+
+ +
+
+ + + + + \ No newline at end of file diff --git a/docs/_source/_build/html/_modules/aborist/ingest.html b/docs/_source/_build/html/_modules/aborist/ingest.html new file mode 100644 index 0000000..b4e6dcc --- /dev/null +++ b/docs/_source/_build/html/_modules/aborist/ingest.html @@ -0,0 +1,696 @@ + + + + + + + + aborist.ingest - Aborist API Reference + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + + + +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for aborist.ingest

+"""Ingest pipeline: Source -> normalize -> chunk -> merkle -> upsert.
+
+Idempotent: re-ingesting the same Document is a no-op (document_root collision
+is the upsert key).
+
+Performance shape — bulk-batched writer:
+  Each batch collapses ALL inserts across N docs into a small set of
+  executemany() calls (one per table) instead of per-doc calls. Audit chain
+  hashes computed in pure Python via store.chain_audit_events, then inserted
+  in one shot. With WAL+synchronous=NORMAL, the dominant cost shifts from
+  Python<->C boundary crossings to actual SQLite work.
+"""
+
+from __future__ import annotations
+
+import sqlite3
+import time
+from dataclasses import dataclass
+
+from aborist import (
+    CANONICALIZATION_VERSION,
+    SCHEMA_VERSION,
+)
+from aborist.compress import pack_chunk, unpack_chunk
+from aborist.document import Document, canonicalize, get_chunker
+from aborist.merkle import MerkleTree, hash_leaf
+from aborist.progress import Progress
+from aborist.source import Source
+from aborist.store import (
+    chain_audit_events,
+    get_meta,
+    latest_event_hash,
+    set_meta,
+    transaction,
+)
+
+
+DEFAULT_BATCH_SIZE = 200
+
+
+@dataclass
+class _DocArtifacts:
+    document_root: str
+    leaves: list[bytes]
+    chunk_strs: list[str]
+    tree: MerkleTree
+
+
+
+[docs] +@dataclass +class IngestStats: + seen: int = 0 + inserted: int = 0 + skipped_duplicate: int = 0 + chunks_total: int = 0 + edges_total: int = 0
+ + + +
+[docs] +def ingest_source( + conn: sqlite3.Connection, + source: Source, + chunker_name: str | None = None, + limit: int | None = None, + batch_size: int = DEFAULT_BATCH_SIZE, + resume: bool = False, + progress: Progress | None = None, +) -> IngestStats: + """Ingest every document the source yields. Returns counts. + + `resume=True` reads the per-source high-water mark from this DB's meta + table and asks the source to fast-forward past it. After each successful + batch flush, the high-water mark is updated in meta. A killed process + can rsync forward by re-running with --resume. + + `progress` (optional) gets a `tick(seen, inserted=...)` call after each + batch flush. Pass an `aborist.progress.Progress` for live stderr output. + """ + chunker = get_chunker(chunker_name) + stats = IngestStats() + batch: list[tuple[Document, _DocArtifacts]] = [] + + meta_key = f"source_high_water:{source.source_type}" + if resume: + prior = get_meta(conn, meta_key) + if prior is not None and hasattr(source, "start_id"): + try: + source.start_id = int(prior) + source.last_id = int(prior) + except (TypeError, ValueError): + pass + + def flush() -> None: + if not batch: + return + inserted, skipped = _flush_batch(conn, batch, chunker.name) + stats.inserted += inserted + stats.skipped_duplicate += skipped + batch.clear() + if hasattr(source, "last_id") and source.last_id: + with transaction(conn): + set_meta(conn, meta_key, str(source.last_id)) + if progress is not None: + progress.tick(stats.seen, inserted=stats.inserted) + + for doc in source.iter_documents(): + stats.seen += 1 + if limit is not None and stats.seen > limit: + break + art = _compute_artifacts(doc, chunker) + if art is None: + stats.skipped_duplicate += 1 + continue + batch.append((doc, art)) + if len(batch) >= batch_size: + flush() + flush() + + if progress is not None: + progress.done(stats.seen, inserted=stats.inserted) + + stats.chunks_total = conn.execute("SELECT COUNT(*) FROM chunks").fetchone()[0] + stats.edges_total = conn.execute("SELECT COUNT(*) FROM edges").fetchone()[0] + return stats
+ + + +def _compute_artifacts(doc: Document, chunker) -> _DocArtifacts | None: + """Pure: canonicalize, chunk, hash leaves, build the tree. No DB access.""" + text = canonicalize(doc.content) + chunk_strs = chunker.split(text) + if not chunk_strs: + return None + leaves = [hash_leaf(c.encode("utf-8")) for c in chunk_strs] + tree = MerkleTree.build(leaves) + return _DocArtifacts( + document_root=tree.root.hex(), + leaves=leaves, + chunk_strs=chunk_strs, + tree=tree, + ) + + +def _flush_batch( + conn: sqlite3.Connection, + batch: list[tuple[Document, _DocArtifacts]], + chunker_name: str, +) -> tuple[int, int]: + """Bulk-insert the whole batch. Returns (inserted, skipped_duplicate). + + All collisions and prior-version lookups happen up front via batched + SELECTs. New rows accumulate into per-table mega-lists and flush via + one executemany per table. Audit-chain hashes are computed in pure + Python and inserted in one shot. + """ + inserted = 0 + skipped = 0 + ingest_ts = int(time.time()) + + with transaction(conn): + # 1) Collision check: which document_roots already exist? + roots = [art.document_root for _, art in batch] + existing: set[str] = _select_existing_roots(conn, roots) + inserted_this_batch: set[str] = set() + # Pre-compute the chunk_id range we'll use this batch. Reading MAX + # under BEGIN IMMEDIATE is safe — concurrent writers serialize on + # the WAL writer lock, so this snapshot won't race. + next_chunk_id = ( + conn.execute("SELECT COALESCE(MAX(chunk_id), 0) FROM chunks") + .fetchone()[0] + + 1 + ) + + # 2) Batched prior-URI resolution: one IN-clause SELECT instead of + # one per-doc SELECT. Maps each URI to the most-recent existing + # document_root in the DB (pre-batch state). + new_uris = list({doc.uri for doc, art in batch + if art.document_root not in existing}) + prior_db: dict[str, str] = {} + for slab_start in range(0, len(new_uris), 500): + slab = new_uris[slab_start : slab_start + 500] + placeholders = ",".join("?" * len(slab)) + for row in conn.execute( + "SELECT document_uri, document_root FROM documents " + f"WHERE document_uri IN ({placeholders}) " + "ORDER BY ingest_ts DESC", + slab, + ): + # First wins (most recent by ORDER BY DESC). + prior_db.setdefault(row["document_uri"], row["document_root"]) + + # Within-batch chain state: as we process, the "prior" for the next + # doc with the same URI becomes the doc we just inserted. + prior_for_doc: dict[str, str] = {} + + documents_rows: list[tuple] = [] + chunk_rows: list[tuple] = [] + fts_rows: list[tuple] = [] + merkle_rows: list[tuple] = [] + edge_rows: list[tuple] = [] + audit_events: list[dict] = [] + edges_to_upsert: list[tuple[str, Document]] = [] + + for doc, art in batch: + if art.document_root in existing or art.document_root in inserted_this_batch: + edges_to_upsert.append((art.document_root, doc)) + skipped += 1 + continue + + prior_root = prior_for_doc.get(doc.uri) or prior_db.get(doc.uri) + if prior_root == art.document_root: + prior_root = None # same content, not a real prior + + documents_rows.append( + ( + art.document_root, + doc.uri, + doc.source_type, + doc.title, + chunker_name, + CANONICALIZATION_VERSION, + SCHEMA_VERSION, + ingest_ts, + ) + ) + for i, c in enumerate(art.chunk_strs): + chunk_id = next_chunk_id + next_chunk_id += 1 + chunk_rows.append( + (chunk_id, art.document_root, i, art.leaves[i].hex(), pack_chunk(c)) + ) + # Contentless FTS5 indexes the plaintext but stores no copy; + # rowid must equal chunks.chunk_id so search-time JOINs line up. + fts_rows.append((chunk_id, c)) + for layer_idx in range(1, len(art.tree.layers)): + for node_idx, h in enumerate(art.tree.layers[layer_idx]): + merkle_rows.append( + (art.document_root, layer_idx, node_idx, h.hex()) + ) + if prior_root is not None: + edge_rows.append( + (art.document_root, prior_root, doc.uri, "supersedes", "") + ) + edges_to_upsert.append((art.document_root, doc)) + inserted_this_batch.add(art.document_root) + prior_for_doc[doc.uri] = art.document_root + audit_events.append( + { + "event_type": "ingest", + "subject_root": art.document_root, + "ts": ingest_ts, + "body": { + "document_uri": doc.uri, + "source_type": doc.source_type, + "chunks": len(art.chunk_strs), + "chunking_version": chunker_name, + "canonicalization_version": CANONICALIZATION_VERSION, + "schema_version": SCHEMA_VERSION, + "supersedes": prior_root, + }, + } + ) + inserted += 1 + + # 3) One executemany per table — minimal Python<->C boundary crossings. + if documents_rows: + conn.executemany( + "INSERT INTO documents " + "(document_root, document_uri, source_type, kind, compression_depth, " + " title, chunking_version, canonicalization_version, schema_version, " + " ingest_ts) " + "VALUES (?, ?, ?, 'surface', 0, ?, ?, ?, ?, ?)", + documents_rows, + ) + if chunk_rows: + conn.executemany( + "INSERT INTO chunks (chunk_id, document_root, idx, leaf_hash, content) " + "VALUES (?, ?, ?, ?, ?)", + chunk_rows, + ) + conn.executemany( + "INSERT INTO chunks_fts (rowid, content) VALUES (?, ?)", + fts_rows, + ) + if merkle_rows: + conn.executemany( + "INSERT INTO merkle_nodes (document_root, layer, idx, hash) " + "VALUES (?, ?, ?, ?)", + merkle_rows, + ) + + # 4) Edges: collect all wikilinks across the batch and resolve in + # one SELECT. Then one executemany. + _flush_edges(conn, edges_to_upsert) + if edge_rows: + conn.executemany( + "INSERT OR IGNORE INTO edges " + "(src_root, dst_root, dst_uri, edge_type, anchor) " + "VALUES (?, ?, ?, ?, ?)", + edge_rows, + ) + + # 5) Audit chain — hashes computed in Python, inserted in one call. + if audit_events: + prev = latest_event_hash(conn) + audit_rows, _ = chain_audit_events(prev, audit_events) + conn.executemany( + "INSERT INTO audit_events " + "(event_hash, prev_event_hash, event_type, subject_root, body, ts) " + "VALUES (?, ?, ?, ?, ?, ?)", + audit_rows, + ) + + return inserted, skipped + + +def _select_existing_roots( + conn: sqlite3.Connection, roots: list[str] +) -> set[str]: + """Single SELECT to find which document_roots already exist.""" + if not roots: + return set() + # SQLite has a default 999-param limit; chunk just in case. + found: set[str] = set() + for i in range(0, len(roots), 500): + slab = roots[i : i + 500] + placeholders = ",".join("?" * len(slab)) + for row in conn.execute( + f"SELECT document_root FROM documents WHERE document_root IN ({placeholders})", + slab, + ): + found.add(row["document_root"]) + return found + + +def _flush_edges( + conn: sqlite3.Connection, + edges_to_upsert: list[tuple[str, Document]], +) -> None: + """Upsert all wikilink/hyperlink edges across the batch. + + Resolves dst_root for previously-unresolved URIs using a single batched + SELECT (URI -> document_root) instead of N per-edge SELECTs. + """ + if not edges_to_upsert: + return + + # Collect distinct dst_uris across the batch for one resolution lookup. + distinct_uris: set[str] = set() + for _, doc in edges_to_upsert: + for e in doc.edges: + if e.dst_uri: + distinct_uris.add(e.dst_uri) + + uri_to_root: dict[str, str] = {} + if distinct_uris: + uris_list = list(distinct_uris) + for i in range(0, len(uris_list), 500): + slab = uris_list[i : i + 500] + placeholders = ",".join("?" * len(slab)) + for row in conn.execute( + "SELECT document_uri, document_root FROM documents " + f"WHERE document_uri IN ({placeholders})", + slab, + ): + # Earliest ingest wins (matches prior LIMIT 1 ASC behavior). + uri_to_root.setdefault(row["document_uri"], row["document_root"]) + + rows: list[tuple] = [] + for src_root, doc in edges_to_upsert: + for e in doc.edges: + dst_root = e.dst_root or uri_to_root.get(e.dst_uri or "", "") + rows.append( + ( + src_root, + dst_root, + e.dst_uri or "", + e.edge_type, + e.anchor or "", + ) + ) + if rows: + conn.executemany( + "INSERT OR IGNORE INTO edges " + "(src_root, dst_root, dst_uri, edge_type, anchor) VALUES (?, ?, ?, ?, ?)", + rows, + ) + + +
+[docs] +def verify_random_sample(conn: sqlite3.Connection, n: int = 10) -> dict: + """Sample N documents, regenerate Merkle proof for chunk 0, verify.""" + from aborist.merkle import hash_leaf, verify_proof + + rows = conn.execute( + "SELECT document_root FROM documents ORDER BY RANDOM() LIMIT ?", (n,) + ).fetchall() + if not rows: + return {"sampled": 0, "passed": 0, "failed": 0} + + passed = 0 + failed = 0 + for row in rows: + document_root = row["document_root"] + chunk_rows = conn.execute( + "SELECT idx, leaf_hash, content FROM chunks " + "WHERE document_root = ? ORDER BY idx ASC", + (document_root,), + ).fetchall() + if not chunk_rows: + failed += 1 + continue + leaves = [bytes.fromhex(r["leaf_hash"]) for r in chunk_rows] + c0 = chunk_rows[0] + c0_content = unpack_chunk(c0["content"]) + if c0_content is not None: + recomputed_leaf = hash_leaf(c0_content.encode("utf-8")) + if recomputed_leaf != leaves[0]: + failed += 1 + continue + tree = MerkleTree.build(leaves) + if tree.root.hex() != document_root: + failed += 1 + continue + proof = tree.proof(0) + if verify_proof(proof) and proof.root.hex() == document_root: + passed += 1 + else: + failed += 1 + return {"sampled": len(rows), "passed": passed, "failed": failed}
+ +
+
+
+
+ + +
+
+ + Made with Sphinx and @pradyunsg's + + Furo + +
+
+ +
+
+ +
+
+ +
+
+ + + + + \ No newline at end of file diff --git a/docs/_source/_build/html/_modules/aborist/merkle.html b/docs/_source/_build/html/_modules/aborist/merkle.html new file mode 100644 index 0000000..5442824 --- /dev/null +++ b/docs/_source/_build/html/_modules/aborist/merkle.html @@ -0,0 +1,461 @@ + + + + + + + + aborist.merkle - Aborist API Reference + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + + + +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for aborist.merkle

+"""Merkle tree with non-commutative HashCombine.
+
+Python port of ~/git/proxy.unturf.com/pkg/verified/merkle.go conventions:
+
+- Domain separation via single-byte prefixes (leaf=0x00, node=0x03).
+- HashCombine is non-commutative; sibling order matters always.
+- Odd layers self-duplicate the trailing element (NOT zero-pad).
+- Proof carries explicit IsLeft flag per sibling (NOT lexical sort).
+- Empty tree root is ZeroHash (32 zero bytes).
+"""
+
+from __future__ import annotations
+
+import hashlib
+from dataclasses import dataclass, field
+from typing import Iterable
+
+LEAF_PREFIX = b"\x00"
+NODE_PREFIX = b"\x03"
+ZERO_HASH = b"\x00" * 32
+HASH_LEN = 32
+
+
+def _sha256(*parts: bytes) -> bytes:
+    h = hashlib.sha256()
+    for p in parts:
+        h.update(p)
+    return h.digest()
+
+
+
+[docs] +def hash_leaf(content: bytes) -> bytes: + """Hash a leaf with domain prefix 0x00.""" + return _sha256(LEAF_PREFIX, content)
+ + + +
+[docs] +def hash_combine(left: bytes, right: bytes) -> bytes: + """Non-commutative interior combine with domain prefix 0x03.""" + if len(left) != HASH_LEN or len(right) != HASH_LEN: + raise ValueError("hash inputs must be 32 bytes") + return _sha256(NODE_PREFIX, left, right)
+ + + +
+[docs] +@dataclass(frozen=True) +class ProofNode: + """One sibling step in a Merkle inclusion proof. + + is_left=True means the sibling sits to the LEFT of the running hash, + so verification order is: HashCombine(sibling, current). + """ + + hash: bytes + is_left: bool
+ + + +
+[docs] +@dataclass(frozen=True) +class MerkleProof: + """Merkle inclusion proof: leaf + index + sibling path to root. + + Proves that a leaf at leaf_index exists in a tree with the given root. + verify_proof() recomputes the root by combining the leaf with siblings. + """ + leaf: bytes + leaf_index: int + siblings: tuple[ProofNode, ...] + root: bytes
+ + + +
+[docs] +@dataclass +class MerkleTree: + """Layered tree. layers[0] = leaves, layers[-1] = [root].""" + + layers: list[list[bytes]] = field(default_factory=list) + + @property + def root(self) -> bytes: + """Root hash of the tree (topmost layer). Empty tree → ZERO_HASH.""" + if not self.layers or not self.layers[-1]: + return ZERO_HASH + return self.layers[-1][0] + + @property + def leaves(self) -> list[bytes]: + """Leaf hashes in input order (bottom layer of tree).""" + return self.layers[0] if self.layers else [] + +
+[docs] + @classmethod + def build(cls, leaves: Iterable[bytes]) -> MerkleTree: + """Build tree from leaf hashes. Applies odd-element self-duplication.""" + leaves = list(leaves) + if not leaves: + return cls(layers=[[]]) + layers: list[list[bytes]] = [list(leaves)] + current = list(leaves) + while len(current) > 1: + nxt: list[bytes] = [] + i = 0 + while i < len(current): + left = current[i] + right = current[i + 1] if i + 1 < len(current) else current[i] + nxt.append(hash_combine(left, right)) + i += 2 + layers.append(nxt) + current = nxt + return cls(layers=layers)
+ + +
+[docs] + def proof(self, leaf_index: int) -> MerkleProof: + """Generate inclusion proof for the leaf at leaf_index.""" + if not self.layers or not self.layers[0]: + raise IndexError("empty tree has no proofs") + if leaf_index < 0 or leaf_index >= len(self.layers[0]): + raise IndexError(f"leaf_index {leaf_index} out of range") + + siblings: list[ProofNode] = [] + idx = leaf_index + # Walk up every layer except the root layer. + for layer in self.layers[:-1]: + if idx % 2 == 0: + sibling_idx = idx + 1 + is_left = False # sibling is to our right + else: + sibling_idx = idx - 1 + is_left = True # sibling is to our left + if sibling_idx >= len(layer): + # odd-element rule: self-duplicate + sibling_idx = idx + siblings.append(ProofNode(hash=layer[sibling_idx], is_left=is_left)) + idx //= 2 + + return MerkleProof( + leaf=self.layers[0][leaf_index], + leaf_index=leaf_index, + siblings=tuple(siblings), + root=self.root, + )
+
+ + + +
+[docs] +def verify_proof(proof: MerkleProof) -> bool: + """Recompute root from leaf + sibling path. Returns True iff matches.""" + current = proof.leaf + for node in proof.siblings: + if node.is_left: + current = hash_combine(node.hash, current) + else: + current = hash_combine(current, node.hash) + return current == proof.root
+ + + +
+[docs] +def proof_to_dict(proof: MerkleProof) -> dict: + """JSON-serializable form for storage in providence_cache.merkle_proof.""" + return { + "leaf": proof.leaf.hex(), + "leaf_index": proof.leaf_index, + "siblings": [ + {"hash": s.hash.hex(), "is_left": s.is_left} for s in proof.siblings + ], + "root": proof.root.hex(), + }
+ + + +
+[docs] +def proof_from_dict(d: dict) -> MerkleProof: + """Deserialize a proof from JSON dict (inverse of proof_to_dict).""" + return MerkleProof( + leaf=bytes.fromhex(d["leaf"]), + leaf_index=int(d["leaf_index"]), + siblings=tuple( + ProofNode(hash=bytes.fromhex(s["hash"]), is_left=bool(s["is_left"])) + for s in d["siblings"] + ), + root=bytes.fromhex(d["root"]), + )
+ +
+
+
+
+ + +
+
+ + Made with Sphinx and @pradyunsg's + + Furo + +
+
+ +
+
+ +
+
+ +
+
+ + + + + \ No newline at end of file diff --git a/docs/_source/_build/html/_modules/aborist/mesh/crypto.html b/docs/_source/_build/html/_modules/aborist/mesh/crypto.html new file mode 100644 index 0000000..59fec9f --- /dev/null +++ b/docs/_source/_build/html/_modules/aborist/mesh/crypto.html @@ -0,0 +1,387 @@ + + + + + + + + aborist.mesh.crypto - Aborist API Reference + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + + + +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for aborist.mesh.crypto

+"""Cryptographic primitives for the mesh layer.
+
+Ed25519 for signing (membership events, gossip envelopes).
+X25519 for key agreement (wrap epoch secrets per-member).
+ChaCha20-Poly1305 for AEAD (optional payload encryption).
+
+All key material is bytes (raw 32-byte forms) so the storage layer can
+keep keys in BLOB columns without serialization. The `cryptography`
+library is the audited backend; this module is a thin wrapper that
+hides the import surface and enforces consistent error handling.
+"""
+
+from __future__ import annotations
+
+from cryptography.exceptions import InvalidSignature, InvalidTag
+from cryptography.hazmat.primitives import hashes
+from cryptography.hazmat.primitives.asymmetric import ed25519, x25519
+from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
+from cryptography.hazmat.primitives.kdf.hkdf import HKDF
+
+
+# -----------------------------------------------------------------------------
+# Ed25519 — signing
+# -----------------------------------------------------------------------------
+
+
+
+[docs] +def generate_signing_keypair() -> tuple[bytes, bytes]: + """Return (priv_bytes, pub_bytes) for a fresh Ed25519 keypair.""" + priv = ed25519.Ed25519PrivateKey.generate() + priv_bytes = priv.private_bytes_raw() + pub_bytes = priv.public_key().public_bytes_raw() + return priv_bytes, pub_bytes
+ + + +
+[docs] +def sign(priv_bytes: bytes, message: bytes) -> bytes: + """Ed25519 sign. 64-byte signature.""" + priv = ed25519.Ed25519PrivateKey.from_private_bytes(priv_bytes) + return priv.sign(message)
+ + + +
+[docs] +def verify(pub_bytes: bytes, signature: bytes, message: bytes) -> bool: + """Ed25519 verify. Returns True/False — never raises.""" + try: + pub = ed25519.Ed25519PublicKey.from_public_bytes(pub_bytes) + pub.verify(signature, message) + return True + except (InvalidSignature, ValueError): + return False
+ + + +# ----------------------------------------------------------------------------- +# X25519 — key agreement for wrapping epoch secrets per-member +# ----------------------------------------------------------------------------- + + +
+[docs] +def generate_dh_keypair() -> tuple[bytes, bytes]: + """Return (priv_bytes, pub_bytes) for a fresh X25519 keypair.""" + priv = x25519.X25519PrivateKey.generate() + priv_bytes = priv.private_bytes_raw() + pub_bytes = priv.public_key().public_bytes_raw() + return priv_bytes, pub_bytes
+ + + +
+[docs] +def ecdh_shared_secret(priv_bytes: bytes, peer_pub_bytes: bytes) -> bytes: + """X25519 ECDH -> 32-byte shared secret (HKDF-extracted).""" + priv = x25519.X25519PrivateKey.from_private_bytes(priv_bytes) + peer = x25519.X25519PublicKey.from_public_bytes(peer_pub_bytes) + raw = priv.exchange(peer) + # HKDF-Extract+Expand to a 32-byte AEAD key. The salt is fixed; the + # info string distinguishes this key from any other use of ECDH on + # the same peer-pair (e.g. if we ever add another protocol layer). + return HKDF( + algorithm=hashes.SHA256(), + length=32, + salt=b"aborist.mesh.epoch.v1", + info=b"epoch-secret-wrap", + ).derive(raw)
+ + + +# ----------------------------------------------------------------------------- +# AEAD — ChaCha20-Poly1305 for envelope payloads + secret wrapping +# ----------------------------------------------------------------------------- + + +
+[docs] +def aead_encrypt(key: bytes, nonce: bytes, plaintext: bytes, aad: bytes = b"") -> bytes: + """Encrypt + authenticate. Returns ciphertext||tag.""" + if len(key) != 32: + raise ValueError("AEAD key must be 32 bytes") + if len(nonce) != 12: + raise ValueError("ChaCha20-Poly1305 nonce must be 12 bytes") + cipher = ChaCha20Poly1305(key) + return cipher.encrypt(nonce, plaintext, aad)
+ + + +
+[docs] +def aead_decrypt(key: bytes, nonce: bytes, ciphertext: bytes, aad: bytes = b"") -> bytes: + """Decrypt + verify. Raises ValueError on tag mismatch (no plaintext leak).""" + if len(key) != 32: + raise ValueError("AEAD key must be 32 bytes") + if len(nonce) != 12: + raise ValueError("ChaCha20-Poly1305 nonce must be 12 bytes") + cipher = ChaCha20Poly1305(key) + try: + return cipher.decrypt(nonce, ciphertext, aad) + except InvalidTag as e: + raise ValueError("AEAD authentication failed") from e
+ +
+
+
+
+ + +
+
+ + Made with Sphinx and @pradyunsg's + + Furo + +
+
+ +
+
+ +
+
+ +
+
+ + + + + \ No newline at end of file diff --git a/docs/_source/_build/html/_modules/aborist/mesh/state.html b/docs/_source/_build/html/_modules/aborist/mesh/state.html new file mode 100644 index 0000000..4cfcfb0 --- /dev/null +++ b/docs/_source/_build/html/_modules/aborist/mesh/state.html @@ -0,0 +1,685 @@ + + + + + + + + aborist.mesh.state - Aborist API Reference + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + + + +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for aborist.mesh.state

+"""Mesh state persisted in the standard aborist DB.
+
+Three tables (defined in `aborist.store.SCHEMA_SQL`):
+  mesh_identity  — this peer's keys + group name (singleton)
+  mesh_roster    — per-epoch (member_id, sign_pub, dh_pub, role) tuples
+  mesh_epochs    — epoch lifecycle: started_at, audit linkage, secret envelope
+
+The `meta.mesh.enabled` flag gates everything. Default off. Mesh-related
+CLI commands and any future network code paths short-circuit when the
+flag is unset / '0'.
+"""
+
+from __future__ import annotations
+
+import base64
+import json
+import os
+import sqlite3
+import time
+import uuid
+from dataclasses import dataclass
+
+from aborist.mesh.crypto import (
+    aead_decrypt,
+    aead_encrypt,
+    ecdh_shared_secret,
+    generate_dh_keypair,
+    generate_signing_keypair,
+)
+from aborist.store import (
+    append_audit,
+    get_meta,
+    set_meta,
+    transaction,
+)
+
+
+MESH_ENABLED_KEY = "mesh.enabled"
+
+
+
+[docs] +@dataclass +class MeshIdentity: + member_id: str + sign_priv: bytes + sign_pub: bytes + dh_priv: bytes + dh_pub: bytes + group_name: str + created_at: int
+ + + +
+[docs] +@dataclass +class MeshRosterEntry: + member_id: str + sign_pub: bytes + dh_pub: bytes + role: str # 'admin' | 'member'
+ + + +# --------------------------------------------------------------------------- +# Enable / disable flag +# --------------------------------------------------------------------------- + + +
+[docs] +def is_enabled(conn: sqlite3.Connection) -> bool: + return get_meta(conn, MESH_ENABLED_KEY) == "1"
+ + + +
+[docs] +def set_enabled(conn: sqlite3.Connection, enabled: bool) -> None: + with transaction(conn): + set_meta(conn, MESH_ENABLED_KEY, "1" if enabled else "0") + append_audit( + conn, + event_type="mesh_enable" if enabled else "mesh_disable", + body={"enabled": bool(enabled)}, + )
+ + + +# --------------------------------------------------------------------------- +# Identity +# --------------------------------------------------------------------------- + + +
+[docs] +def init_identity( + conn: sqlite3.Connection, + *, + group_name: str, + member_id: str | None = None, +) -> MeshIdentity: + """Generate this peer's keys and seed epoch 0 with this peer as founding admin. + + Idempotent on re-call only in the sense that it raises — the schema + enforces a singleton via PK = 1. Caller is expected to check + load_identity() first. + """ + if load_identity(conn) is not None: + raise RuntimeError("mesh identity already initialized") + sign_priv, sign_pub = generate_signing_keypair() + dh_priv, dh_pub = generate_dh_keypair() + member_id = member_id or _short_id() + now = int(time.time()) + + with transaction(conn): + conn.execute( + "INSERT INTO mesh_identity " + "(id, member_id, sign_priv, sign_pub, dh_priv, dh_pub, group_name, created_at) " + "VALUES (1, ?, ?, ?, ?, ?, ?, ?)", + (member_id, sign_priv, sign_pub, dh_priv, dh_pub, group_name, now), + ) + # Epoch 0 — founder is sole admin. + conn.execute( + "INSERT INTO mesh_roster (epoch_id, member_id, sign_pub, dh_pub, role) " + "VALUES (0, ?, ?, ?, 'admin')", + (member_id, sign_pub, dh_pub), + ) + # Epoch 0 secret: random 32-byte symmetric key, wrapped to founder's + # own DH key. (One-member envelope is degenerate but the structure + # is consistent — every later rotate appends a fresh entry.) + secret = os.urandom(32) + envelope = _wrap_secret_for_members( + secret, + members=[(member_id, dh_pub)], + sender_dh_priv=dh_priv, + ) + conn.execute( + "INSERT INTO mesh_epochs " + "(epoch_id, started_at, started_event_hash, secret_envelope, reason) " + "VALUES (0, ?, '', ?, 'genesis')", + (now, json.dumps(envelope, separators=(",", ":"), sort_keys=True)), + ) + event_hash = append_audit( + conn, + event_type="mesh_init", + body={ + "group_name": group_name, + "founder": member_id, + "sign_pub_hex": sign_pub.hex(), + "dh_pub_hex": dh_pub.hex(), + }, + ) + # Backfill the epoch's audit linkage. + with transaction(conn): + conn.execute( + "UPDATE mesh_epochs SET started_event_hash = ? WHERE epoch_id = 0", + (event_hash,), + ) + return MeshIdentity( + member_id=member_id, + sign_priv=sign_priv, + sign_pub=sign_pub, + dh_priv=dh_priv, + dh_pub=dh_pub, + group_name=group_name, + created_at=now, + )
+ + + +
+[docs] +def load_identity(conn: sqlite3.Connection) -> MeshIdentity | None: + row = conn.execute( + "SELECT member_id, sign_priv, sign_pub, dh_priv, dh_pub, group_name, created_at " + "FROM mesh_identity WHERE id = 1" + ).fetchone() + if row is None: + return None + return MeshIdentity( + member_id=row["member_id"], + sign_priv=bytes(row["sign_priv"]), + sign_pub=bytes(row["sign_pub"]), + dh_priv=bytes(row["dh_priv"]), + dh_pub=bytes(row["dh_pub"]), + group_name=row["group_name"], + created_at=int(row["created_at"]), + )
+ + + +# --------------------------------------------------------------------------- +# Roster + epoch queries +# --------------------------------------------------------------------------- + + +
+[docs] +def current_epoch(conn: sqlite3.Connection) -> int | None: + row = conn.execute("SELECT MAX(epoch_id) AS e FROM mesh_epochs").fetchone() + if row is None or row["e"] is None: + return None + return int(row["e"])
+ + + +def roster_at(conn: sqlite3.Connection, epoch_id: int) -> list[MeshRosterEntry]: + rows = conn.execute( + "SELECT member_id, sign_pub, dh_pub, role " + "FROM mesh_roster WHERE epoch_id = ? ORDER BY member_id", + (epoch_id,), + ).fetchall() + return [ + MeshRosterEntry( + member_id=r["member_id"], + sign_pub=bytes(r["sign_pub"]), + dh_pub=bytes(r["dh_pub"]), + role=r["role"], + ) + for r in rows + ] + + +# --------------------------------------------------------------------------- +# Epoch rotation (also used for join + kick) +# --------------------------------------------------------------------------- + + +def rotate_epoch( + conn: sqlite3.Connection, + *, + new_members: list[MeshRosterEntry], + reason: str, + actor_member_id: str, +) -> int: + """Create a new epoch with the given roster. + + `new_members` is the FULL post-rotation roster (not a diff). Eviction = + omit a member from new_members. Joins = include a new member. The + secret envelope is regenerated and wrapped to every new member's DH + pubkey via ECDH from this peer's own DH private key. + + Caller is responsible for verifying authority (e.g., admin role) before + calling. Audit chain records the rotation rationale. + """ + me = load_identity(conn) + if me is None: + raise RuntimeError("no mesh identity; call init_identity first") + prior = current_epoch(conn) + if prior is None: + raise RuntimeError("no prior epoch; call init_identity first") + new_epoch = prior + 1 + + secret = os.urandom(32) + envelope = _wrap_secret_for_members( + secret, + members=[(m.member_id, m.dh_pub) for m in new_members], + sender_dh_priv=me.dh_priv, + ) + + with transaction(conn): + for m in new_members: + conn.execute( + "INSERT INTO mesh_roster (epoch_id, member_id, sign_pub, dh_pub, role) " + "VALUES (?, ?, ?, ?, ?)", + (new_epoch, m.member_id, m.sign_pub, m.dh_pub, m.role), + ) + conn.execute( + "INSERT INTO mesh_epochs " + "(epoch_id, started_at, started_event_hash, secret_envelope, reason) " + "VALUES (?, ?, '', ?, ?)", + ( + new_epoch, + int(time.time()), + json.dumps(envelope, separators=(",", ":"), sort_keys=True), + reason, + ), + ) + event_hash = append_audit( + conn, + event_type="mesh_epoch_rotate", + body={ + "actor": actor_member_id, + "new_epoch": new_epoch, + "members": [m.member_id for m in new_members], + "reason": reason, + }, + ) + with transaction(conn): + conn.execute( + "UPDATE mesh_epochs SET started_event_hash = ? WHERE epoch_id = ?", + (event_hash, new_epoch), + ) + return new_epoch + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _short_id() -> str: + """8-hex-char member id. Collisions across a small mesh are negligible.""" + return uuid.uuid4().hex[:8] + + +def _wrap_secret_for_members( + secret: bytes, + *, + members: list[tuple[str, bytes]], + sender_dh_priv: bytes, +) -> dict[str, dict[str, str]]: + """Per-member ECDH+AEAD wrap of the epoch secret. + + Each member gets a dict {nonce_b64, ct_b64}. Decrypt path: derive the + same shared secret via ECDH(member_dh_priv, sender_dh_pub), then + ChaCha20-Poly1305 decrypt with the recorded nonce. + """ + envelope: dict[str, dict[str, str]] = {} + for member_id, member_dh_pub in members: + shared = ecdh_shared_secret(sender_dh_priv, member_dh_pub) + nonce = os.urandom(12) + ct = aead_encrypt(shared, nonce, secret, aad=member_id.encode("utf-8")) + envelope[member_id] = { + "nonce_b64": base64.b64encode(nonce).decode("ascii"), + "ct_b64": base64.b64encode(ct).decode("ascii"), + } + return envelope + + +def unwrap_secret_for_self( + conn: sqlite3.Connection, + *, + epoch_id: int, + sender_dh_pub: bytes, +) -> bytes: + """Recover the symmetric epoch secret using this peer's DH private key. + + `sender_dh_pub` is the wrapping peer's X25519 pubkey (typically the + epoch's rotator). Raises ValueError if this peer has no entry in the + epoch's envelope (i.e. they were evicted) or if the AEAD tag mismatches. + """ + me = load_identity(conn) + if me is None: + raise RuntimeError("no mesh identity") + row = conn.execute( + "SELECT secret_envelope FROM mesh_epochs WHERE epoch_id = ?", + (epoch_id,), + ).fetchone() + if row is None: + raise ValueError(f"unknown epoch: {epoch_id}") + envelope = json.loads(row["secret_envelope"]) + entry = envelope.get(me.member_id) + if entry is None: + raise ValueError( + f"this peer ({me.member_id}) is not in epoch {epoch_id}'s envelope" + ) + nonce = base64.b64decode(entry["nonce_b64"]) + ct = base64.b64decode(entry["ct_b64"]) + shared = ecdh_shared_secret(me.dh_priv, sender_dh_pub) + return aead_decrypt(shared, nonce, ct, aad=me.member_id.encode("utf-8")) + + +def recover_epoch_secret( + conn: sqlite3.Connection, + *, + epoch_id: int, +) -> bytes: + """Convenience: locate the rotator's dh_pub locally and unwrap our slot. + + The rotator is recorded in the epoch's audit event body — `actor` for + rotations (epoch >= 1), `founder` for the genesis epoch 0. We resolve + the actor's dh_pub via `mesh_roster` at that epoch (the actor was a + member of the post-rotation roster by construction). Then we hand off + to `unwrap_secret_for_self`. + + This is the path used by both senders (re-unwrapping their own slot + to AEAD-encrypt outbound gossip) and receivers (decrypting an inbound + encrypted body). Sender-side it's the simplest answer to "where does + the sender get the epoch secret" without a new caching layer. + + Raises: + RuntimeError if mesh identity not initialized. + ValueError if the epoch row is missing, the audit event is missing, + the actor is not in the epoch's roster, this peer has no slot in + the envelope (evicted), or the AEAD tag mismatches. + """ + row = conn.execute( + "SELECT started_event_hash FROM mesh_epochs WHERE epoch_id = ?", + (epoch_id,), + ).fetchone() + if row is None: + raise ValueError(f"unknown epoch: {epoch_id}") + started_event_hash = row["started_event_hash"] + if not started_event_hash: + raise ValueError(f"epoch {epoch_id} has no audit linkage") + ev = conn.execute( + "SELECT body FROM audit_events WHERE event_hash = ?", + (started_event_hash,), + ).fetchone() + if ev is None: + raise ValueError( + f"epoch {epoch_id} audit event {started_event_hash!r} not found" + ) + body = json.loads(ev["body"]) + # Genesis writes "founder"; rotations write "actor". + actor = body.get("actor") or body.get("founder") + if not actor: + raise ValueError( + f"epoch {epoch_id} audit body has no actor/founder field" + ) + rotator = next( + (m for m in roster_at(conn, epoch_id) if m.member_id == actor), + None, + ) + if rotator is None: + raise ValueError( + f"rotator {actor!r} not in epoch {epoch_id} roster" + ) + return unwrap_secret_for_self( + conn, epoch_id=epoch_id, sender_dh_pub=rotator.dh_pub + ) +
+
+
+
+ + +
+
+ + Made with Sphinx and @pradyunsg's + + Furo + +
+
+ +
+
+ +
+
+ +
+
+ + + + + \ No newline at end of file diff --git a/docs/_source/_build/html/_modules/aborist/qa/client.html b/docs/_source/_build/html/_modules/aborist/qa/client.html new file mode 100644 index 0000000..841675f --- /dev/null +++ b/docs/_source/_build/html/_modules/aborist/qa/client.html @@ -0,0 +1,493 @@ + + + + + + + + aborist.qa.client - Aborist API Reference + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + + + +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for aborist.qa.client

+"""Chat-completion clients.
+
+ChatClient is a Protocol — any object with a `chat_completion` method
+plugs in. We ship two concrete clients:
+
+- OpenAICompatibleClient — talks to any OpenAI-compatible /v1/chat/completions
+  endpoint (vllm, llama.cpp server, ollama, TGI, hosted services).
+- StubClient — offline canned responses for tests and dry-runs. No
+  network. Operation Voyeur safe.
+"""
+
+from __future__ import annotations
+
+from typing import Protocol
+
+
+
+[docs] +class ChatClient(Protocol): +
+[docs] + def chat_completion( + self, + messages: list[dict], + *, + model: str, + temperature: float = 0.1, + max_tokens: int = 512, + top_p: float = 1.0, + extra_body: dict | None = None, + ) -> str: + """Return the assistant's text response. + + ``extra_body`` is forwarded as additional fields in the JSON + request payload — used for vLLM-specific knobs like + ``guided_json`` (constrain output to a JSON Schema at sampling + time, eliminating SCHEMA_INVALID failures from prompt drift). + Endpoints that don't recognize the field ignore it; the client + passes it through opaque-ly. + """ + ...
+
+ + + +
+[docs] +class StubClient: + """Offline client for tests / --dry-run. + + Pass `answer=callable(messages, **kw) -> str` for dynamic stubbing. + """ + + def __init__(self, answer="[STUB] dry-run answer; no LLM was called."): + self._answer = answer + self.calls: list[dict] = [] + +
+[docs] + def chat_completion(self, messages, **kwargs) -> str: + self.calls.append({"messages": messages, "kwargs": kwargs}) + if callable(self._answer): + return self._answer(messages, **kwargs) + return self._answer
+
+ + + # StubClient ignores extra_body — the offline path doesn't go through + # any inference engine that would honor grammar guidance. Tests that + # want to assert extra_body was passed should inspect `self.calls`. + + +
+[docs] +class OpenAICompatibleClient: + """OpenAI-compatible chat completion over HTTP. + + Default endpoint is configurable via env. Pass api_key only if the + target requires it; uncloseai's free endpoint does not. + + Retries on transient upstream failures (HTTP 502/503/504) with + exponential backoff. The 2026-04-30 QA-modes bench saw 19 of 66 + JSON-mode runs error out with 502 from vLLM — clustered, plausibly + correlated with `guided_json` stressing the grammar engine. Retry + smooths over the cluster without changing semantics: a 502 still + fails the bench cell if all attempts exhaust, but transient bursts + no longer dominate the error column. + """ + + # HTTP status codes worth retrying — transient gateway/server + # failures from a flaky upstream. 4xx codes are client errors and + # never retried. + _RETRY_STATUS = (502, 503, 504) + + def __init__( + self, + base_url: str, + api_key: str | None = None, + timeout: float = 60.0, + max_retries: int = 3, + retry_backoff_base_s: float = 0.5, + ): + self.base_url = base_url.rstrip("/") + self.api_key = api_key + self.timeout = timeout + self.max_retries = max(1, max_retries) + self.retry_backoff_base_s = retry_backoff_base_s + # Persistent httpx.Client for HTTP/1.1 keep-alive + connection + # reuse. Constructing a fresh Client per chat_completion paid a + # TLS handshake every call (~100-300ms vs free for keep-alive), + # which adds up fast under bench --concurrency. httpx.Client is + # thread-safe for sequential or concurrent use; its internal + # connection pool serializes pool access. Closed via close(). + import httpx + self._http = httpx.Client(timeout=self.timeout) + +
+[docs] + def close(self) -> None: + """Release the underlying connection pool.""" + try: + self._http.close() + except Exception: + pass
+ + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + self.close() + return False + + @staticmethod + def _scrub_surrogates(s: str) -> str: + """Replace lone UTF-16 surrogates with U+FFFD. + + Wikipedia chunks (and other ingested text) occasionally + contain unpaired surrogates from the ingest of invalid-UTF-8 + source. httpx's json= path does ``.encode('utf-8')`` on the + serialized request body, which raises UnicodeEncodeError on + any lone surrogate. Sanitize incoming message content here + so the outbound HTTP request always serializes cleanly. We + round-trip through WTF-8 (surrogatepass) bytes, then decode + as standard UTF-8 with replacement — invalid sequences + become U+FFFD (REPLACEMENT CHARACTER). + """ + return s.encode("utf-8", errors="surrogatepass").decode("utf-8", errors="replace") + +
+[docs] + def chat_completion( + self, + messages: list[dict], + *, + model: str, + temperature: float = 0.1, + max_tokens: int = 512, + top_p: float = 1.0, + extra_body: dict | None = None, + stop: list[str] | None = None, + ) -> str: + import httpx + import time as _time + + client = self._http # persistent connection pool from __init__ + headers = {"Content-Type": "application/json"} + # Sanitize message content for httpx's json-encode path. + messages = [ + { + **m, + "content": ( + self._scrub_surrogates(m["content"]) + if isinstance(m.get("content"), str) + else m.get("content") + ), + } + for m in messages + ] + if self.api_key: + headers["Authorization"] = f"Bearer {self.api_key}" + payload = { + "model": model, + "messages": messages, + "temperature": temperature, + "max_tokens": max_tokens, + "top_p": top_p, + } + if stop: + payload["stop"] = list(stop) + # extra_body merges into the payload root — vLLM accepts knobs + # like {"guided_json": {...schema...}} or {"guided_grammar": "..."}. + # Endpoints that don't recognize a key silently drop it. + if extra_body: + for k, v in extra_body.items(): + payload[k] = v + url = f"{self.base_url}/chat/completions" + last_exc: Exception | None = None + for attempt in range(self.max_retries): + try: + resp = client.post(url, headers=headers, json=payload) + if resp.status_code in self._RETRY_STATUS and attempt < self.max_retries - 1: + # Exponential backoff: 0.5s, 1.0s, 2.0s with the + # default base. Last attempt raises through. + sleep_s = self.retry_backoff_base_s * (2 ** attempt) + _time.sleep(sleep_s) + continue + resp.raise_for_status() + data = resp.json() + return data["choices"][0]["message"]["content"] + except httpx.HTTPStatusError as e: + # Retry only the configured transient codes; raise others. + if e.response.status_code in self._RETRY_STATUS and attempt < self.max_retries - 1: + sleep_s = self.retry_backoff_base_s * (2 ** attempt) + _time.sleep(sleep_s) + last_exc = e + continue + raise + except (httpx.ConnectError, httpx.ReadTimeout, httpx.RemoteProtocolError) as e: + # Network-layer transient errors get the same retry. + if attempt < self.max_retries - 1: + sleep_s = self.retry_backoff_base_s * (2 ** attempt) + _time.sleep(sleep_s) + last_exc = e + continue + raise + # Defensive — loop should have returned or raised. + if last_exc is not None: + raise last_exc + raise RuntimeError("OpenAICompatibleClient.chat_completion: retry loop exhausted without raising")
+
+ +
+
+
+
+ + +
+
+ + Made with Sphinx and @pradyunsg's + + Furo + +
+
+ +
+
+ +
+
+ +
+
+ + + + + \ No newline at end of file diff --git a/docs/_source/_build/html/_modules/aborist/qa/dag.html b/docs/_source/_build/html/_modules/aborist/qa/dag.html new file mode 100644 index 0000000..b615cf3 --- /dev/null +++ b/docs/_source/_build/html/_modules/aborist/qa/dag.html @@ -0,0 +1,731 @@ + + + + + + + + aborist.qa.dag - Aborist API Reference + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + + + +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for aborist.qa.dag

+"""Per-run Merkle-DAG provenance for providence records.
+
+Each query/ask call passes through several stages:
+
+    question → retrieval → context → prompt → answer → verify → final_label
+
+Each stage emits a hash; the run's identity is the Merkle root over the
+ordered sequence of stage hashes. Stored on the providence record as
+``run_dag_root`` (alongside ``cache_key``). The DAG is verifiable: given
+the persisted node list & the same Merkle conventions aborist uses
+elsewhere (non-commutative HashCombine, prefix 0x03, leaf prefix 0x00,
+self-duplicate odd rule), an auditor can recompute the root from the
+nodes & confirm the run was constructed as recorded.
+
+Distinct from the linear ``audit_events`` chain — that chain tracks
+state-changing operations across the DB. This DAG tracks the
+computation provenance of one specific answer. Both coexist; the
+record's ``audit_event_hash`` links to the chain, ``run_dag_root`` &
+``run_dag_blob`` carry the per-run computation graph.
+
+Stages chosen to mirror the toy-Hermes design (fox 2026-04-30):
+
+    question      hash of question_hash (8-dim cache_key dim)
+    retrieval     hash of sources summary (document_roots + roles +
+                  scores) — captures which docs ranked & how
+    context       context_root (Merkle root over sorted source roots,
+                  the "source" dim of the cache_key)
+    prompt        conversation_hash (the assembled messages)
+    answer        sha256(answer_text)
+    verify        hash of verdict summary (audit_mode, verifier_method,
+                  n_quotes, n_verified, claim_statuses)
+    final_label   hash of (audit_mode, verifier_method, lookup_path)
+
+The DAG is NOT part of cache_key. cache_key inputs (the 8 dims)
+determine the answer; the answer determines the DAG. Folding the DAG
+back into cache_key would create a circular dependency.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import json
+
+from aborist.merkle import MerkleTree
+
+
+def _sha256_hex(s: str) -> str:
+    # ``errors='surrogatepass'`` lets lone UTF-16 surrogates through as
+    # their WTF-8 form. Hermes occasionally emits text with unpaired
+    # surrogates inside multi-byte sequences; bare ``.encode('utf-8')``
+    # raises UnicodeEncodeError on those, which previously aborted the
+    # run with no Merkle root. The hash stays deterministic because the
+    # WTF-8 byte sequence is reversible & unique per input.
+    return hashlib.sha256(s.encode("utf-8", errors="surrogatepass")).hexdigest()
+
+
+def _canonical_json(obj) -> str:
+    return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
+
+
+
+[docs] +def localize_failure( + *, + audit_mode: str, + n_sources: int, + n_quotes: int, + n_verified: int, +) -> str | None: + """Map a non-STRICT verdict to the pipeline stage that introduced + the failure. Returns ``None`` for STRICT outcomes. + + Stage labels (in pipeline order): + + - ``retrieval`` — no admitted sources. Title/body gates rejected + everything, or the corpus genuinely lacks the topic. Repair path: + ingest more sources or relax the breadth threshold. + - ``context`` — sources admitted but no quotes extracted. Could be + a context-truncation issue (per-source cap dropped the relevant + paragraph) or a model that declined to cite anything. Repair path: + raise per-source cap; tighten prompt. + - ``answer`` — sources retrieved & quotes extracted but they don't + verify. The model either fabricated content, paraphrased inside + quotes, or appended citation tails. Repair path: the + ``mechanical_repair`` pass + (when wired) the re-prompt feedback + loop. + + The toy-Hermes design pass calls this "chain-segment failure + localization" — debugging becomes typed instead of vague. An + operator reading ``failure_stage='answer'`` knows retrieval & + context were fine; the model is what to fix. ``failure_stage='retrieval'`` + means stop tuning the verifier & go ingest a relevant source. + """ + if audit_mode == "STRICT": + return None + if n_sources == 0: + return "retrieval" + if n_quotes == 0: + return "context" + # Quotes were extracted but didn't all verify (or none did). + return "answer"
+ + + +PREFLIGHT_NODE_VERSION = "preflight-node-v1" + + +
+[docs] +def build_preflight_node_payload( + *, + question_state: dict | None = None, + quantifier: dict | None = None, + answer_contract: dict | None = None, + prompt_contract: dict | None = None, + evidence_contract: dict | None = None, + policy_refs: dict | None = None, +) -> dict: + """Build the canonical nested-clause payload for the preflight + DAG stage. Returns a JSON-ready dict; pair with + :func:`preflight_node_hash` to get the SHA-256 hex. + + Five-clause structure per ticket #000009 §8.2 / feedback §3: + + - ``classifier`` — quantifier classifier output (#000008): + intensity, matched_token, explicit_count, scope_bound_hint, + is_broad, classifier_version, operational_shape. + - ``answer_contract`` — guard / cap / reject decisions taken + on this run. + - ``prompt_contract`` — reminder enabled / injected / + template_id (#000008 §10.5). + - ``evidence_contract`` — exposure budget, one-claim-per-line + discipline (#000010 §10.4). + - ``policy_refs`` — governance_policy_hash + model_profile_hash + + answer_mode. Reference-by-hash rather than raw policy + bundles (feedback §4: avoid double-committing + already-hashed state). + + Plus the metacog ``question_state`` from #000010 — that's its + own clause for now (logical_statuses, false_premise_hints, + contradiction_pairs). It's hashed separately by + `metacognition.preflight_policy_hash` already. + + Any clause may be None / empty — the resulting payload is + still stable. Includes ``node_version`` so legacy runs without + the node can be unambiguously labeled `unavailable_legacy_run` + by audit tools. + """ + return { + "stage": "preflight", + "node_version": PREFLIGHT_NODE_VERSION, + "classifier": dict(quantifier) if quantifier else {}, + "answer_contract": dict(answer_contract) if answer_contract else {}, + "prompt_contract": dict(prompt_contract) if prompt_contract else {}, + "evidence_contract": dict(evidence_contract) if evidence_contract else {}, + "policy_refs": dict(policy_refs) if policy_refs else {}, + # Metacognition QuestionState carries + # ``preflight_policy_hash`` internally so flipping a metacog + # detector invalidates this clause via that field. Stored + # nested so audit-replay can read all metacog signal in one + # place without descending into the quantifier classifier. + "question_state": dict(question_state) if question_state else {}, + }
+ + + +
+[docs] +def preflight_node_hash( + *, + question_state: dict | None = None, + quantifier: dict | None = None, + answer_contract: dict | None = None, + prompt_contract: dict | None = None, + evidence_contract: dict | None = None, + policy_refs: dict | None = None, +) -> str: + """Hash the preflight decision into a stable SHA-256 hex string. + + Returns the hash of the nested-clause payload built by + :func:`build_preflight_node_payload`. See that function for the + five-clause structure. + + Audit-replay payoff: two cache rows that share the same + question + same model output + same verifier verdict but + different preflight policy state produce different hashes + here, which propagate to ``run_dag_root`` via + :func:`build_run_dag`. + + Backward compatibility note: Pre-2026-05-04 (`c36e85c`) callers + used a flat 3-key payload (`question_state` / `quantifier` / + `policy_state`). Hashes computed with that callsite will NOT + match this restructured callsite — `run_dag_root` values for + rows written between `c36e85c` and the current commit are + treated as a discrete generation; they're still verifiable by + re-reading `run_dag_blob` (the persisted blob captures the + payload that was actually hashed). + """ + payload = build_preflight_node_payload( + question_state=question_state, + quantifier=quantifier, + answer_contract=answer_contract, + prompt_contract=prompt_contract, + evidence_contract=evidence_contract, + policy_refs=policy_refs, + ) + return _sha256_hex(_canonical_json(payload))
+ + + +
+[docs] +def build_run_dag( + *, + question_hash: str, + sources: list[dict], + context_root: str, + conversation_hash: str, + answer_text: str, + audit_mode: str, + verifier_method: str, + n_quotes: int, + n_verified: int, + claim_statuses: list[dict] | None = None, + lookup_path: str | None = None, + evidence_map_root: str | None = None, + answer_mode: str | None = None, + violations: list[dict] | None = None, + raw_answer_text: str | None = None, + parsed_lattice: list | None = None, + rendered_text: str | None = None, + retrieval_plan_hash: str | None = None, + preflight_hash: str | None = None, + preflight_payload: dict | None = None, +) -> dict: + """Return ``{"root": <hex>, "nodes": [<stage>, <hash>], ...}``. + + All inputs are already-computed hashes or text; no I/O. Idempotent & + deterministic — same inputs always produce the same root, byte-for- + byte across machines (as long as the Merkle conventions stay pinned; + they do, via ``aborist.merkle``). + + Two base DAG shapes; both gain an optional ``preflight`` stage + when ``preflight_hash`` is supplied (Ticket #000009): + + - **Quote mode (default).** 7 stages — + ``question / retrieval / context / prompt / answer / verify / + final_label``. Triggered when ``evidence_map_root`` is None. + Backward-compatible with all run_dag_root values written by code + that pre-dates G0. With ``preflight_hash``, becomes 8 stages — + ``question / preflight / retrieval / ...``. + + - **Claim-lattice-pointer mode (G0 / CTI).** 9 stages — + ``question / retrieval / evidence_map / prompt / raw_answer / + parsed_claim_lattice / verify / render / final_label``. Triggered + when ``evidence_map_root`` is non-None. Splits the single + ``answer`` node into three: the model's raw output, the parsed + claim-lattice, and the rendered prose with literal spans + interpolated. ``context`` drops out (the context IS the evidence + map). All three of ``raw_answer_text`` / ``parsed_lattice`` / + ``rendered_text`` should be supplied; missing args fall back to + ``answer_text`` for the raw_answer & render hashes and ``[]`` for + the parsed_lattice hash. With ``preflight_hash``, becomes 10 + stages. + + ``answer_mode`` & ``violations`` fold into the verify & final_label + payloads when provided. ``preflight_hash`` (Ticket #000009) is + optional; when None, the DAG shape remains 7/9 stages exactly so + pre-#000009 records can be re-validated. When supplied, the + preflight stage inserts at position 1 (between ``question`` and + ``retrieval``) per ticket #000009 §3.1. + """ + sources_summary = [ + { + "document_root": s.get("document_root"), + "source_role": s.get("source_role"), + "score": s.get("score"), + "chunk_idx": s.get("chunk_idx"), + } + for s in sources + ] + sources_summary_hash = _sha256_hex(_canonical_json(sources_summary)) + # Retrieval stage hash: when a retrieval_plan_hash is supplied + # (per ticket #000001 — provenance binding for operator-influenced + # retrieval inputs like keywords / top_k / over_fetch), the stage + # hash binds BOTH the plan (input) and the sources_summary + # (output). Without a plan supplied, fall back to the historical + # sources-summary-only hash so pre-#000001 records keep their + # run_dag_root values stable. Greenfield records that omit the + # plan stay readable by the run-DAG validator. + if retrieval_plan_hash is not None: + retrieval_hash = _sha256_hex(_canonical_json({ + "retrieval_plan_hash": retrieval_plan_hash, + "sources_summary_hash": sources_summary_hash, + })) + else: + retrieval_hash = sources_summary_hash + answer_hash = _sha256_hex(answer_text) + failure_stage = localize_failure( + audit_mode=audit_mode, + n_sources=len(sources), + n_quotes=n_quotes, + n_verified=n_verified, + ) + verify_payload = { + "audit_mode": audit_mode, + "verifier_method": verifier_method, + "n_quotes": n_quotes, + "n_verified": n_verified, + "claim_statuses": claim_statuses or [], + "failure_stage": failure_stage, + } + if violations is not None: + verify_payload["violations"] = violations + verify_hash = _sha256_hex(_canonical_json(verify_payload)) + final_label_payload = { + "audit_mode": audit_mode, + "verifier_method": verifier_method, + "lookup_path": lookup_path, + } + if answer_mode is not None: + final_label_payload["answer_mode"] = answer_mode + final_label_hash = _sha256_hex(_canonical_json(final_label_payload)) + + if evidence_map_root is None: + # Quote-mode 7-stage shape — backward-compatible. + nodes = [ + {"stage": "question", "hash": question_hash}, + {"stage": "retrieval", "hash": retrieval_hash}, + {"stage": "context", "hash": context_root}, + {"stage": "prompt", "hash": conversation_hash}, + {"stage": "answer", "hash": answer_hash}, + {"stage": "verify", "hash": verify_hash}, + {"stage": "final_label", "hash": final_label_hash}, + ] + else: + # Pointer-mode 9-stage shape (CTI). ``context`` drops out; + # ``answer`` splits into raw_answer / parsed_claim_lattice / + # render so each provenance step gets its own commitment. + raw_text = raw_answer_text if raw_answer_text is not None else answer_text + rendered = rendered_text if rendered_text is not None else answer_text + raw_answer_hash = _sha256_hex(raw_text) + # Parsed lattice = list of {claim_text, evidence_ids[]} dicts in + # input order; canonical-json so reordering claims changes the + # hash. Pointer ids are run-dependent — we prefer the + # content-addressed evidence_ids here for run-stable provenance. + parsed_lattice_hash = _sha256_hex( + _canonical_json(parsed_lattice or []) + ) + rendered_hash = _sha256_hex(rendered) + nodes = [ + {"stage": "question", "hash": question_hash}, + {"stage": "retrieval", "hash": retrieval_hash}, + {"stage": "evidence_map", "hash": evidence_map_root}, + {"stage": "prompt", "hash": conversation_hash}, + {"stage": "raw_answer", "hash": raw_answer_hash}, + {"stage": "parsed_claim_lattice", "hash": parsed_lattice_hash}, + {"stage": "verify", "hash": verify_hash}, + {"stage": "render", "hash": rendered_hash}, + {"stage": "final_label", "hash": final_label_hash}, + ] + # Ticket #000009 — preflight stage binding. When supplied, + # insert ``preflight`` between ``question`` and ``retrieval``. + # Optional so legacy run_dag_root values from pre-#000009 code + # remain reproducible (None → original 7/9-stage shape). The + # preflight_hash bundles #000008 quantifier output, #000010 + # QuestionState, AND the policy decisions taken on this run + # — see preflight_node_hash() for the canonical payload. + if preflight_hash is not None: + nodes.insert( + 1, + {"stage": "preflight", "hash": preflight_hash}, + ) + leaves = [bytes.fromhex(n["hash"]) for n in nodes] + root_hex = MerkleTree.build(leaves).root.hex() + out = {"root": root_hex, "nodes": nodes} + # Ticket #000009 §7.2 — recoverable preflight payload. Storing + # the canonical dict alongside the leaf hash means + # `aborist providence --show-preflight` can render the full + # 5-clause CTI contract (classifier / answer_contract / + # prompt_contract / evidence_contract / policy_refs + + # question_state) from `run_dag_blob` without needing a + # separate column or re-running the classifier. Audit replay + # CAN re-verify the hash matches: + # _sha256_hex(_canonical_json(preflight_payload)) == preflight_hash + # (caller-side check; verify_run_dag does not enforce because + # the hash is in `nodes` and the payload is sidecar data.) + if preflight_payload is not None: + out["preflight_payload"] = preflight_payload + return out
+ + + +
+[docs] +def build_reject_run_dag( + *, + question_hash: str, + preflight_hash: str, + rejection_reason: str, + answer_text: str, + audit_mode: str = "UNGROUNDED", + verifier_method: str = "claim_lattice_pointer", + violations: list[dict] | None = None, + preflight_payload: dict | None = None, +) -> dict: + """3-stage reject-broad run-DAG: ``question → preflight → + final_label``. + + Ticket #000009 §8.2 / 2026-05-04 feedback §6.2: preflight + rejection currently early-returns from ``query()`` before the + standard ``build_run_dag()`` runs, so reject rows have no + auditable Merkle commitment. This builder fills that gap with + a minimal DAG shape that captures the rejection without + pretending retrieval / prompt / raw_model_output happened. + + The returned shape is INTENTIONALLY shorter than the standard + 7/9/8/10-stage shapes — `audit replay can read the stage + list` and tell instantly that this row is a preflight + rejection: 3 stages always means reject path. + + `final_label` carries the rejection_reason + answer_text hash + so two rejections that differ only in their (rendered) + rationale string still produce different roots. The + rejection_reason is the canonical string from the violation + (`"preflight rejection — broad-quantifier query with + unbounded scope. ..."`), NOT the operator-facing rendered + answer_text — that lets policy template changes invalidate + the hash even if the operator-visible text is unchanged. + """ + final_label_payload = { + "audit_mode": audit_mode, + "verifier_method": verifier_method, + "lookup_path": "preflight", + "rejection_reason": rejection_reason, + "answer_text_hash": _sha256_hex(answer_text or ""), + } + if violations is not None: + final_label_payload["violations"] = violations + final_label_hash = _sha256_hex(_canonical_json(final_label_payload)) + nodes = [ + {"stage": "question", "hash": question_hash}, + {"stage": "preflight", "hash": preflight_hash}, + {"stage": "final_label", "hash": final_label_hash}, + ] + leaves = [bytes.fromhex(n["hash"]) for n in nodes] + root_hex = MerkleTree.build(leaves).root.hex() + out = {"root": root_hex, "nodes": nodes} + if preflight_payload is not None: + out["preflight_payload"] = preflight_payload + return out
+ + + +
+[docs] +def verify_run_dag(blob: str | dict) -> bool: + """Recompute the Merkle root from ``blob`` and check it matches. + + Used by audit tooling. Accepts either a parsed dict or the JSON + string we persist in ``providence_cache.run_dag_blob``. + """ + if isinstance(blob, str): + blob = json.loads(blob) + nodes = blob.get("nodes") or [] + if not nodes: + return False + leaves = [bytes.fromhex(n["hash"]) for n in nodes] + return MerkleTree.build(leaves).root.hex() == blob.get("root")
+ +
+
+
+
+ + +
+
+ + Made with Sphinx and @pradyunsg's + + Furo + +
+
+ +
+
+ +
+
+ +
+
+ + + + + \ No newline at end of file diff --git a/docs/_source/_build/html/_modules/aborist/qa/evidence.html b/docs/_source/_build/html/_modules/aborist/qa/evidence.html new file mode 100644 index 0000000..5f192e0 --- /dev/null +++ b/docs/_source/_build/html/_modules/aborist/qa/evidence.html @@ -0,0 +1,777 @@ + + + + + + + + aborist.qa.evidence - Aborist API Reference + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + + + +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for aborist.qa.evidence

+"""Evidence map for claim-lattice-pointer (quote-by-pointer) answer mode.
+
+Builds a deterministic table of evidence objects from already-retrieved
+chunks. The model references each object by a short ``pointer_id``
+("E1", "E2", ...) which the runtime maps back to a content-addressed
+``evidence_id`` for the cache, run-DAG, and audit chain.
+
+Design pinned by G0 ticket (2026-04-29) and the CTI / Clause Lattice
+Intelligence reframe:
+
+    Models should not generate verbatim quotes.
+    They should point to evidence IDs extracted by deterministic code.
+
+This kills the synthetic-elision class by construction — the model
+never types the quote string, so it can't drop characters from one.
+
+Two-layer id scheme:
+
+- ``pointer_id``    short numeric tag the model sees in the prompt and
+                     writes back in pointer-line answers. ``E`` + the
+                     1-based position of the evidence object in the map
+                     (``E1``, ``E2``, …, ``E37``). One BPE token per id
+                     in standard tokenizers. Stays in the model's
+                     in-distribution citation style.
+- ``evidence_id``   content-addressed handle. ``E`` + first 8 hex of
+                     ``sha256(chunk_root:offset_start:offset_end)``.
+                     Same chunk + same offsets in two runs → same id,
+                     forever. The cache_key, run-DAG, and audit chain
+                     all use this form so provenance is run-stable.
+
+The verifier (``verify_claim_lattice``) maps each pointer_id the model
+writes back to its content-addressed evidence_id before persistence.
+The model's literal output is run-dependent (run #1's ``E1`` and run
+#2's ``E1`` likely point at different chunks); the content-addressed
+layer is what stays stable.
+
+For the first cut every chunk produces exactly one evidence object
+covering ``offset_start=0 .. offset_end=len(span)``. Sub-chunk
+extraction (paragraph-level, sentence-level) is a future refinement;
+the schema already accepts arbitrary offsets so adding finer-grained
+splits later doesn't break the contract.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import re
+from dataclasses import asdict, dataclass
+
+from aborist.merkle import MerkleTree
+
+
+def _sha256_hex(s: str) -> str:
+    # ``errors='surrogatepass'`` for model-output text containing lone
+    # UTF-16 surrogates; same rationale as ``aborist.qa.dag._sha256_hex``.
+    return hashlib.sha256(s.encode("utf-8", errors="surrogatepass")).hexdigest()
+
+
+
+[docs] +@dataclass(frozen=True) +class EvidenceObject: + """One pinned span the model may reference by ``pointer_id``. + + All fields are deterministic from inputs; same chunk + offsets yields + the same object byte-for-byte. + + - ``pointer_id`` prompt-facing id ("E1", "E2", …) — what the + model sees & writes back. Position-derived; + run-dependent on purpose. + - ``evidence_id`` content-addressed handle ("E" + 8 hex of + sha256(chunk_root:start:end)) — what the + cache, run-DAG, and audit chain use. + Run-stable. + - ``source_root`` document_root the chunk belongs to + - ``document_uri`` human-readable URI (for the renderer) + - ``title`` doc title (for the renderer & prompt) + - ``chunk_idx`` chunk index within the document + - ``chunk_root`` leaf hash of the chunk + - ``offset_start`` byte offset within the chunk (0 for whole-chunk) + - ``offset_end`` end offset (exclusive) + - ``source_role`` role classification (primary_answer_source / ...) + - ``text_hash`` sha256 of the span (for tamper detection) + - ``span`` the literal text (what the renderer interpolates) + """ + + pointer_id: str + evidence_id: str + source_root: str + document_uri: str + title: str | None + chunk_idx: int + chunk_root: str + offset_start: int + offset_end: int + source_role: str + text_hash: str + span: str + +
+[docs] + def to_dict(self) -> dict: + return asdict(self)
+
+ + + +def _evidence_id_for(chunk_root: str, offset_start: int, offset_end: int) -> str: + """Content-addressed handle. Same chunk + offsets = same ID, forever.""" + h = _sha256_hex(f"{chunk_root}:{offset_start}:{offset_end}") + return f"E{h[:8]}" + + +
+[docs] +def build_evidence_map( + chunks: list[dict], +) -> list[EvidenceObject]: + """Build the evidence table from retrieved chunks. + + ``chunks`` is a list of dicts with keys: + source_root, document_uri, title (optional), chunk_idx, + chunk_root, span, source_role (optional, default 'unclassified') + + Returns a list of ``EvidenceObject``s, one per chunk, in input order. + The 1-based position drives ``pointer_id`` (E1, E2, …); the chunk's + content drives ``evidence_id`` (sha256-derived). For the first cut + each chunk = one whole-span evidence object (offset 0 .. len(span)). + Sub-chunk splitting is a future refinement. + """ + out: list[EvidenceObject] = [] + for i, c in enumerate(chunks): + span = c["span"] + offset_start = 0 + offset_end = len(span) + chunk_root = c["chunk_root"] + evidence_id = _evidence_id_for(chunk_root, offset_start, offset_end) + out.append( + EvidenceObject( + pointer_id=f"E{i + 1}", + evidence_id=evidence_id, + source_root=c["source_root"], + document_uri=c["document_uri"], + title=c.get("title"), + chunk_idx=c["chunk_idx"], + chunk_root=chunk_root, + offset_start=offset_start, + offset_end=offset_end, + source_role=c.get("source_role", "unclassified"), + text_hash=_sha256_hex(span), + span=span, + ) + ) + return out
+ + + +
+[docs] +def evidence_map_root(evidence: list[EvidenceObject]) -> str: + """Merkle root over the sorted evidence_id leaves. + + Sorting makes the root order-independent — two retrieval runs that + return the same chunks in different orders produce the same root. + Use as the ``evidence_map`` stage hash in the run-DAG. + """ + if not evidence: + return "00" * 32 + leaves_hex = sorted(_sha256_hex(e.evidence_id) for e in evidence) + if len(leaves_hex) == 1: + return leaves_hex[0] + leaves = [bytes.fromhex(h) for h in leaves_hex] + return MerkleTree.build(leaves).root.hex()
+ + + +
+[docs] +def render_evidence_block(e: EvidenceObject) -> str: + """Format one evidence object for the LLM prompt. + + Header carries the prompt-facing pointer_id, title (or URI tail), + and source role so the model has everything it needs to cite + without typing the span: + + === E1 (Jurassic_Park_(film) | primary_answer_source) === + <literal span text> + + The runtime maps E1 back to the content-addressed evidence_id + before persistence; the model never sees the hex form. + """ + label = (e.title or e.document_uri.rsplit("/", 1)[-1]) or "untitled" + return f"=== {e.pointer_id} ({label} | {e.source_role}) ===\n{e.span}"
+ + + +
+[docs] +def render_evidence_map(evidence: list[EvidenceObject]) -> str: + """Concatenated evidence blocks, ready to drop into the prompt.""" + return "\n\n".join(render_evidence_block(e) for e in evidence)
+ + + +
+[docs] +def render_evidence_block_for_json(e: EvidenceObject) -> str: + """Format one evidence object for the JSON-mode LLM prompt. + + 2026-04-30: header uses the prompt-facing ``pointer_id`` (E1, E2, + …) — same as claim_lattice_pointer mode — instead of the + content-addressed ``evidence_id`` (long hex). The change closes a + real failure mode: small models (Hermes-3-8B observed) were + fabricating plausible-looking content-addressed IDs (e.g. + ``E1b6e396`` when the runtime had ``Eed1b6e396``) → UNKNOWN_ + EVIDENCE_ID → UNGROUNDED, even when the answer text was correct. + Pointer IDs (``E1``-``E10``) are short, enumerable, and fabrication- + obvious — the model can't invent ``E27`` if only ``E1``-``E10`` were + shown. + + The runtime still stores content-addressed ``evidence_id`` in the + cache & run-DAG (resolved on-the-fly in ``verify_claim_lattice_json``); + only the prompt-facing string changes:: + + === E1 (Jurassic_Park_(film) | primary_answer_source) === + <literal span text> + """ + label = (e.title or e.document_uri.rsplit("/", 1)[-1]) or "untitled" + return f"=== {e.pointer_id} ({label} | {e.source_role}) ===\n{e.span}"
+ + + +
+[docs] +def render_evidence_map_for_json(evidence: list[EvidenceObject]) -> str: + """Concatenated evidence blocks for JSON mode.""" + return "\n\n".join(render_evidence_block_for_json(e) for e in evidence)
+ + + +
+[docs] +def evidence_map_by_pointer_id( + evidence: list[EvidenceObject], +) -> dict[str, EvidenceObject]: + """Index by prompt-facing pointer_id (E1, E2, …).""" + return {e.pointer_id: e for e in evidence}
+ + + +
+[docs] +def evidence_map_by_evidence_id( + evidence: list[EvidenceObject], +) -> dict[str, EvidenceObject]: + """Index by content-addressed evidence_id (E1f8e4c2a, …).""" + return {e.evidence_id: e for e in evidence}
+ + + +
+[docs] +def render_claim_lattice( + claims: list[dict], + by_id: dict[str, EvidenceObject], + *, + window: int = 200, +) -> str: + """Convert structured claims to human-readable prose with literal spans. + + Each claim becomes one bullet line followed by inlined evidence + excerpts. The model's claim text is rendered verbatim; each cited + pointer_id is followed by a **spotlight excerpt** of the literal + source span — a window of ``window`` chars centered on the first + content token from the claim that appears in the span. Falls back + to the leading window when no claim token matches. + + Why spotlight over leading-N truncation: when the cited evidence is + a whole article and the model lazy-anchors every claim at the same + pointer, the leading-N strategy displayed the same article-intro + sentence under every claim. The spotlight finds the part of the + span the claim is *about* — "Brachiosaurus appears in the film" + + a 15 KB article span gets a window centered on the first + "brachiosaurus" mention, not the production-history opener. Same + cited evidence id, but the displayed text actually supports + different claims differently. + + ``by_id`` is the pointer_id → EvidenceObject index — what + ``evidence_map_by_pointer_id`` returns. Determinism: same + (claims, by_id, window) → same prose, byte-for-byte. Unknown ids + render as ``[<id>: ?]`` so violations are visible at a glance. + """ + lines: list[str] = [] + for c in claims: + text = (c.get("text") or "").strip() + if not text: + continue + lines.append(f"- {text}") + for eid in c.get("pointer_ids") or c.get("evidence_ids") or []: + obj = by_id.get(eid) + if obj is None: + lines.append(f' [{eid}: ?]') + continue + # Provenance-clear evidence pointer: + # [E5 | <source title> | <chunk_root prefix>: "<excerpt>"] + # Closes the visual confusion observed 2026-05-01 on the + # Orwell run where `[E5: "..."]` displayed alongside a + # source list whose `[5]` slot was a different document + # (E# is a chunk pointer, not a 1-indexed source rank). + # Title comes from the EvidenceObject (URI tail fallback); + # chunk_root prefix is the first 8 hex chars — enough for + # the operator to disambiguate while staying compact. + label = obj.title or obj.document_uri.rsplit("/", 1)[-1] or "untitled" + chunk_prefix = (obj.chunk_root or "")[:8] + excerpt = _spotlight_excerpt(text, obj.span, window=window) + lines.append(f' [{eid} | {label} | {chunk_prefix}: "{excerpt}"]') + return "\n".join(lines)
+ + + +# Stopwords for spotlight content-token extraction. Smaller set than +# verify._ENGLISH_STOPWORDS — we only need to filter the words the +# model is most likely to share between claim text & every span. A +# handful of high-frequency 4+ char fillers is enough; the spotlight +# falls back to the leading window when no token matches anyway, so +# false negatives degrade gracefully. +_SPOTLIGHT_STOPWORDS = frozenset({ + "from", "with", "into", "onto", "upon", "this", "that", "these", + "those", "have", "been", "being", "their", "there", "they", + "them", "your", "what", "when", "where", "while", "which", + "would", "could", "should", "might", "shall", "first", "last", + "also", "very", "much", "many", "more", "most", "less", "some", + "such", "than", "then", "back", "next", "after", "before", + "between", "through", "across", "above", "below", "during", + "without", "within", "until", "since", "about", "around", + "along", "among", "appear", "appears", "appeared", "feature", + "features", "include", "includes", "shown", "shows", +}) + +_TOKEN_PUNCT_STRIP_R = ".,;:!?\"'()[]{}—-" + + +def _content_tokens(text: str) -> list[str]: + """Lowercase content tokens from ``text``, sorted by length desc. + + Filters: drop ``< 4`` chars (function words), drop a small stopword + set, dedup. Sorted longest-first so the spotlight matches the most + specific topical token before generic ones — for "Brachiosaurus + appears in the film", that's ``brachiosaurus`` ahead of ``film``. + """ + seen: set[str] = set() + out: list[str] = [] + for raw in text.lower().split(): + t = raw.strip(_TOKEN_PUNCT_STRIP_R) + if len(t) < 4 or t in _SPOTLIGHT_STOPWORDS or t in seen: + continue + seen.add(t) + out.append(t) + out.sort(key=len, reverse=True) + return out + + +_SENTENCE_END_RE = re.compile(r"[.!?][\"')\]]?\s+(?=[A-Z\"'(\[])|[.!?][\"')\]]?$") + + +def _expand_to_sentence_boundaries(span: str, start: int, end: int) -> tuple[int, int]: + """Expand a (start, end) byte window in ``span`` outward to the + nearest sentence boundaries. + + Boundary discovery uses a conservative regex: ``[.!?][\"')\\]]?`` + followed by whitespace + capital letter (or end of span). The + result is byte-clean and idempotent — re-expanding an already- + sentence-bounded window returns the same indices. + + Returns clamped ``(new_start, new_end)``. If the input is the + whole span or no sentence boundary is detectable in either + direction, the input is returned unchanged. + """ + if start <= 0 and end >= len(span): + return 0, len(span) + # Walk left from `start` to find the previous sentence boundary + # (or start of span). Use the regex's match positions in the + # text leading up to `start`. + new_start = 0 + for m in _SENTENCE_END_RE.finditer(span, 0, start): + new_start = m.end() + # Walk right from `end` to find the next sentence boundary (or + # end of span). The first match whose START is >= end is the + # boundary that closes the spotlit sentence. + new_end = len(span) + for m in _SENTENCE_END_RE.finditer(span, end): + new_end = m.end() + break + return new_start, new_end + + +def _spotlight_excerpt(claim_text: str, span: str, *, window: int) -> str: + """Return a sentence-bounded excerpt of ``span`` centered on the + first claim-token match. + + Pre-2026-05-01 the excerpt was a fixed-width byte window with + leading/trailing ``"..."`` markers; that frequently cut mid-word + and produced excerpts like ``"...freewheeling plot about a boy + and a girl, and the many amazing creatures they have for friends + and p..."`` (the trailing ``p...`` is a half-word truncation). The + new version finds the spotlight token by claim-content-token + match (same as before), then expands outward to the nearest + sentence boundaries — never cuts a word. + + Behavior: + + 1. If ``span`` already fits in ``window``, return it unchanged. + 2. Otherwise locate the first content-token match in ``span``. + 3. Expand the match position to the surrounding sentence(s). + 4. If the expanded sentence range fits within ``2 * window`` bytes + (a soft budget — sentences carry meaning intact), return it + with leading/trailing ellipsis only when the range doesn't + reach the span boundaries. + 5. If the expanded range exceeds ``2 * window``, fall back to the + window-centered approach but EXPAND the window's edges to the + nearest WORD boundaries (`\\b`-equivalent) so we never cut a + word. + 6. If no claim token matches anywhere, return the leading window + expanded to the nearest sentence end. + + Determinism: same (claim, span, window) → same byte-exact output. + """ + if len(span) <= window: + return span + span_lower = span.lower() + tokens = _content_tokens(claim_text) + + # Find ALL match positions for ALL content tokens, then pick the + # position whose ±half-window cluster contains the most distinct + # tokens. The Homer-Simpson-boss case (2026-05-01) showed why + # first-match-of-longest-token loses: "homer" matches early in + # voice-actor prose, but the actual answer ("Mr. Burns") is + # deeper in the chunk where boss/burns/homer all cluster + # together. Density picks the load-bearing slice; first-match + # picks whatever phrasing the chunk happens to lead with. + half = window // 2 + positions: list[tuple[str, int]] = [] + for tok in tokens: + i = 0 + while True: + j = span_lower.find(tok, i) + if j < 0: + break + positions.append((tok, j)) + i = j + len(tok) + + if not positions: + # No content match — return the leading sentence(s) up to + # ~window bytes, expanded to the next sentence end so we + # never cut a word at the boundary. + new_start, new_end = _expand_to_sentence_boundaries(span, 0, min(window, len(span))) + # Cap at 2× window so a runaway sentence doesn't blow the + # excerpt budget. + if new_end - new_start > 2 * window: + new_end = _word_boundary_before(span, new_start + 2 * window) + suffix = "..." if new_end < len(span) else "" + return f"{span[new_start:new_end]}{suffix}" + + # Density rank: for each candidate position, count distinct + # tokens whose match positions fall within ±half. Pick the + # position with max distinct count; tie-break on smallest idx + # (deterministic, reproducible). + best_score = -1 + match_idx = positions[0][1] + for _tok, idx in positions: + distinct: set[str] = set() + lo, hi = idx - half, idx + half + for tok2, idx2 in positions: + if lo <= idx2 <= hi: + distinct.add(tok2) + score = len(distinct) + if score > best_score or (score == best_score and idx < match_idx): + best_score = score + match_idx = idx + + raw_start = max(0, match_idx - half) + raw_end = min(len(span), match_idx + len(tokens[0]) + half) + + # Expand outward to sentence boundaries — this is the main change. + new_start, new_end = _expand_to_sentence_boundaries(span, raw_start, raw_end) + + # Soft budget cap: if the expanded sentence range is more than + # 2× window, fall back to the windowed form but truncate at WORD + # boundaries so we don't cut a word. + if new_end - new_start > 2 * window: + new_start = _word_boundary_after(span, max(0, match_idx - half)) + new_end = _word_boundary_before(span, min(len(span), new_start + 2 * window)) + + prefix = "..." if new_start > 0 else "" + suffix = "..." if new_end < len(span) else "" + return f"{prefix}{span[new_start:new_end].strip()}{suffix}" + + +def _word_boundary_after(span: str, idx: int) -> int: + """Smallest ``i >= idx`` such that ``span[i-1]`` is whitespace or + ``i == 0``. Walks forward to a clean word start.""" + if idx <= 0: + return 0 + while idx < len(span) and not span[idx - 1].isspace(): + idx += 1 + return idx + + +def _word_boundary_before(span: str, idx: int) -> int: + """Largest ``i <= idx`` such that ``span[i]`` is whitespace or + ``i == len(span)``. Walks backward to a clean word end.""" + if idx >= len(span): + return len(span) + while idx > 0 and not span[idx].isspace(): + idx -= 1 + return idx +
+
+
+
+ + +
+
+ + Made with Sphinx and @pradyunsg's + + Furo + +
+
+ +
+
+ +
+
+ +
+
+ + + + + \ No newline at end of file diff --git a/docs/_source/_build/html/_modules/aborist/qa/keys.html b/docs/_source/_build/html/_modules/aborist/qa/keys.html new file mode 100644 index 0000000..d35fb44 --- /dev/null +++ b/docs/_source/_build/html/_modules/aborist/qa/keys.html @@ -0,0 +1,598 @@ + + + + + + + + aborist.qa.keys - Aborist API Reference + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + + + +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for aborist.qa.keys

+"""The 8-dim Merkle-AGI v9.8 cache_key.
+
+v9.8 invariant: no answer is reused unless all eight match and the
+record is live (not failed/stale/quarantined):
+
+    1. source_root              — content fingerprint of the document
+    2. question_hash             — SHA-256 of normalized question text
+    3. model_profile_hash        — model_id + revision + quantization
+    4. conversation_hash         — full canonical OpenAI messages array
+    5. governance_policy_hash    — sampling/policy parameters dict
+    6. schema_version            — aborist DB schema version
+    7. canonicalization_version  — text normalization rules
+    8. chunking_version          — chunker name & parameters
+
+Bumping ANY of these eight dimensions yields a distinct cache_key, so
+prior records cannot be served. This is the runtime drift detection
+the providence whitepaper compresses into "cache_key = source_root +
+':' + question_hash" — that's a simplification; the rigorous form is
+all eight dimensions hashed together.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import json
+
+from aborist.document import canonicalize
+
+
+def _sha256(s: str) -> str:
+    # ``errors='surrogatepass'`` survives lone UTF-16 surrogates from
+    # model output; same rationale as ``aborist.qa.dag._sha256_hex``.
+    return hashlib.sha256(s.encode("utf-8", errors="surrogatepass")).hexdigest()
+
+
+def _canonical_json(obj) -> str:
+    return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
+
+
+QUESTION_DEDUP_MODES = ("strict", "equivalence_class")
+DEFAULT_QUESTION_DEDUP = "equivalence_class"
+
+# Lookup-time fidelity. Decoupled from write-time `question_dedup`:
+# write determines under which `cache_key` a record lands; fidelity
+# determines which `cache_key`s a lookup will check.
+#
+#   strict             only the cache_key matching the agent's policy
+#                      is checked. No fallback. Audit-grade behavior.
+#   equivalence_class  primary cache_key checked first; if miss AND the
+#                      OTHER dedup mode produces a different cache_key,
+#                      the alternate is also checked. Lets a fast-cache
+#                      agent reuse records written under either mode.
+FIDELITY_MODES = ("strict", "equivalence_class")
+DEFAULT_FIDELITY = "equivalence_class"
+
+
+
+[docs] +def canonical_question( + question: str, *, mode: str = DEFAULT_QUESTION_DEDUP +) -> str: + """Canonical form of `question` for the given dedup ``mode``. + + Two modes: + + - ``"equivalence_class"`` (default): four-step canonicalization — + ``canonicalize()`` (NFC + ws-collapse + strip ends), then + lowercase, then trailing-punctuation strip, then standalone-article + filter (``the``, ``a``, ``an``). All variants of "Who is THE + Batman?" / "who is batman" / "who is X." collapse to one form. + The default for chat-style agents that prefer fast cache hits. + + - ``"strict"``: only ``canonicalize()`` — NFC + ws-collapse + strip + ends. Case-sensitive, punctuation-sensitive, article-sensitive. + Maximum granularity. The choice for audit-grade agents that want + every distinct phrasing to get its own answer. + + Exposed as a function so callers can dedup BEFORE hashing — e.g. + inject the canonical form into the user message used for + ``conversation_hash``, while still sending the verbatim question to + the LLM. Without this split ``"who is batman"``, ``"who is + batman?"``, and ``"who is the batman?"`` collapse on + ``question_hash`` (under equivalence_class) but each hits + ``conversation_hash`` differently, missing cache. + + The choice of mode flows through ``policy["question_dedup"]`` into + ``governance_policy_hash`` so two agents under different modes + write records under different ``cache_key``s — they coexist in + parallel namespaces, never collide. + """ + if mode not in QUESTION_DEDUP_MODES: + raise ValueError( + f"question dedup mode must be one of {QUESTION_DEDUP_MODES}, got {mode!r}" + ) + canon = canonicalize(question) + if mode == "strict": + return canon + canon = canon.lower().rstrip(_QUESTION_TRAILING_STRIP) + tokens = [t for t in canon.split() if t not in _QUESTION_ARTICLE_STRIP] + return " ".join(tokens)
+ + + +
+[docs] +def question_hash( + question: str, *, mode: str = DEFAULT_QUESTION_DEDUP +) -> str: + """SHA-256 of the dedup-mode-canonicalized question. + + See ``canonical_question`` for what each mode does. The hash is the + SHA-256 of the canonical form. Bumping + ``_QUESTION_TRAILING_STRIP`` or ``_QUESTION_ARTICLE_STRIP`` (the + equivalence-class strip sets) orphans prior cache records whose + canonical question contained newly-stripped tokens; they live as + history but won't be re-hit on lookup. + + Equivalence class examples (mode="equivalence_class"):: + + "who is X" | + "who is X?" | + "Who Is X." | -> same question_hash + "who is the X" | + "who is a X" | + "who is an X?" | (CJK question mark) + + Strict mode (mode="strict") distinguishes all of those. + + What's IN the trailing-strip set: ``.?!,;:`` (ASCII), ``?!。、`` + (CJK full-width), ``…`` (ellipsis). Pairs like ``"`` ``'`` ``)`` + ``]`` ``}`` are NOT — naive one-sided stripping breaks balance. + Apostrophes aren't either — ``X's`` is a different question from + ``X``. + """ + return _sha256(canonical_question(question, mode=mode))
+ + + +# Trailing punctuation that carries no semantic difference at the end +# of a question. Order doesn't matter (rstrip walks char-by-char from +# the right). Repeats handled trivially: ``X???`` → ``X``. +# +# ASCII: . ? ! , ; : +# CJK: ? U+FF1F full-width question mark +# ! U+FF01 full-width exclamation +# 。 U+3002 ideographic full stop +# 、 U+3001 ideographic comma +# Other: … U+2026 horizontal ellipsis +_QUESTION_TRAILING_STRIP = ".?!,;:?!。、…" + +# English articles stripped as standalone tokens after lowercasing. The +# question equivalence class treats "the foo" and "foo" as the same +# question — fox's 2026-04-29 catch: `who is the batman` & `who is +# batman` produced different cache records under earlier rules. Tokens +# are matched as EXACT lowercase strings, so substrings like "thesis" +# (contains "the") stay untouched. +# +# Conservative on purpose: only ASCII English articles. "El", "la", +# "los", "le", "les", "der", "die", "das" etc. are not stripped today. +# Adding them when needed flows through the same equivalence-class +# expansion the trailing-punctuation set went through. +_QUESTION_ARTICLE_STRIP = frozenset({"the", "a", "an"}) + + +
+[docs] +def model_profile_hash( + model_id: str, revision: str = "", quantization: str = "" +) -> str: + """SHA-256 of model identity. Bumping any field bumps the cache key.""" + return _sha256(f"{model_id}|{revision}|{quantization}")
+ + + +
+[docs] +def conversation_hash(messages: list[dict]) -> str: + """SHA-256 of canonical JSON of the full OpenAI messages array. + + Order matters: a 6-turn dialogue arriving at the same final question + produces a different hash than a single-turn ask. + """ + return _sha256(_canonical_json(messages))
+ + + +
+[docs] +def governance_policy_hash(policy: dict) -> str: + """SHA-256 of canonical JSON of the sampling/policy dict. + + Includes temperature, top_p, max_tokens, and the system prompt — any + of those changing means the answer is governed differently and the + cache must miss. + """ + return _sha256(_canonical_json(policy))
+ + + +# Verifier-policy fields — the subset of `policy` that names what +# the deterministic verifier does. Separate from the broader +# `governance_policy_hash` so an auditor can answer "did the verifier +# rules change?" with a single hash diff rather than scanning the +# whole policy. See docs/cti-architecture.md §6 + the de-novo +# synthesis (2026-05-01) on verifier-policy identity. +# +# Adding a field here bumps `verifier_policy_hash` for every cached +# record on next lookup. Removing a field does the same. Reordering +# does not (set membership, not list ordering). +_VERIFIER_POLICY_FIELDS = frozenset({ + # Mode + parser identity + "answer_mode", + # Pointer-mode hard checks + "claim_lattice_max_pointers_per_claim", + "claim_lattice_min_citation_coverage", + "claim_lattice_min_claim_content_tokens", + "claim_lattice_lazy_anchor_demote_threshold", + "claim_lattice_lazy_anchor_demote_min_pairs", + "claim_lattice_allowed_source_roles", + # Retrieval-side knob with verifier consequences + "claim_lattice_max_chunks_per_source", + # JSON variant identity + "claim_lattice_use_guided_json", + "claim_lattice_json_stop_sequences", + # Warrant-lite (relation-question hard check, Ticket H, 2026-05-01) + "claim_lattice_warrant_check_enabled", + "claim_lattice_deflection_check_enabled", + "claim_lattice_format_collapse_check_enabled", + # Subject-tokens-absent / premise-parroting (Ticket #000006 amend + # 2026-05-02b, Rule 9). Threshold of question∩claim content tokens + # absent from cited evidence union that demotes STRICT → HYBRID. + "claim_lattice_subject_tokens_absent_threshold", + # Ticket #000008 Phase 2-4 — quantifier preflight guard. The seven + # fields together control whether broad-quantifier classification + # affects the per-call claim cap, which modes are gated, whether + # the reminder fires, and whether reject-broad early-return takes + # over. Plus #000010 adds six more for metacognition (see below). + # Adding a model to model_profiles.py PROFILES doesn't bump + # governance_policy_hash on its own (the dict isn't policy); + # but flipping any of these knobs DOES bump the hash, which + # invalidates prior cache records on lookup — exactly the + # invalidation we want when a guard knob changes. + "quantifier_guard_enabled", + "quantifier_guard_apply_caps", + "quantifier_apply_caps_modes", + "quantifier_caps_by_intensity", + "quantifier_guard_modes", + "quantifier_reminder_enabled", + "quantifier_reject_broad", + # Ticket #000010 — Meta-Cognition Preflight Guard. The six + # fields together control whether preflight runs and which + # detectors fire. Flipping any of them invalidates prior cache + # records on lookup — same governance discipline as #000008. + "metacognition_enabled", + "metacognition_temporal_check", + "metacognition_contradiction_check", + "metacognition_false_premise_check", + "metacognition_out_of_corpus_check", + "metacognition_block_on_contradiction", + # Quote-mode entity policy + "entity_policy", + "entity_proximity_n", + "entity_proximity_window", + # Wikitext base-prose pinning (changes verifier surface) + "base_version", +}) + + +
+[docs] +def verifier_policy_hash(policy: dict) -> str: + """SHA-256 of canonical JSON of the verifier-relevant subset of policy. + + Pulls `_VERIFIER_POLICY_FIELDS` out of `policy` and hashes only + those. Empty dict → constant hash (`sha256("{}")`). Folded into + `cache_key` as a 9th dimension so a verifier-policy change is + observable from the cache_key alone, separate from + `governance_policy_hash` which folds in temperature / top_p / + prompts. + + The two hashes overlap (verifier fields ARE in the broader policy + dict and so contribute to governance_policy_hash too). That's + intentional — bumping a verifier rule bumps BOTH dimensions. + Bumping a non-verifier field (e.g. temperature) bumps ONLY + governance_policy_hash. The asymmetry is what makes the audit + legible: which dimension changed answers a question that scanning + the whole policy dict cannot. + """ + subset = {k: v for k, v in policy.items() if k in _VERIFIER_POLICY_FIELDS} + return _sha256(_canonical_json(subset))
+ + + +
+[docs] +def cache_key( + source_root: str, + question_hash_value: str, + model_profile_hash_value: str, + conversation_hash_value: str, + governance_policy_hash_value: str, + schema_version: str, + canonicalization_version: str, + chunking_version: str, + verifier_policy_hash_value: str | None = None, +) -> str: + """SHA-256 of the cache-identity dimensions joined with '|'. + + 8-dim form (legacy): omit `verifier_policy_hash_value` (or pass + None). The result matches pre-2026-05-01 cache identity and + keeps backward compatibility with cached records written before + the 9th dimension landed. + + 9-dim form: pass `verifier_policy_hash_value` explicitly. Records + written under the 9-dim form bind to the verifier-policy + identity; lookups with a different verifier_policy_hash miss. + The 9th dimension is the explicit "did the verifier rules + change?" gate. + + Any drift in any dimension produces a distinct cache_key. + """ + parts = [ + source_root, + question_hash_value, + model_profile_hash_value, + conversation_hash_value, + governance_policy_hash_value, + schema_version, + canonicalization_version, + chunking_version, + ] + if verifier_policy_hash_value is not None: + parts.append(verifier_policy_hash_value) + return _sha256( + "|".join(parts) + )
+ +
+
+
+
+ + +
+
+ + Made with Sphinx and @pradyunsg's + + Furo + +
+
+ +
+
+ +
+
+ +
+
+ + + + + \ No newline at end of file diff --git a/docs/_source/_build/html/_modules/aborist/qa/metacognition.html b/docs/_source/_build/html/_modules/aborist/qa/metacognition.html new file mode 100644 index 0000000..32970fb --- /dev/null +++ b/docs/_source/_build/html/_modules/aborist/qa/metacognition.html @@ -0,0 +1,880 @@ + + + + + + + + aborist.qa.metacognition - Aborist API Reference + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + + + +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for aborist.qa.metacognition

+"""Meta-Cognition Preflight Guard — Ticket #000010 Phase 1.
+
+Runtime epistemic control layer that classifies a question's shape
+BEFORE generation, so the model never answers from the surface form
+alone when the question is ill-posed (false-premise, contradictory,
+under-specified, broad-quantifier, time-sensitive, out-of-corpus,
+reference-frame ambiguous).
+
+Pure and deterministic. No I/O, no model call, no retrieval call.
+Reuses ``aborist.qa.quantifier.classify_question_quantifier`` for
+the broad-quantifier rung; adds four new lightweight detectors:
+
+  - temporal sensitivity     (current/latest/today/CEO/etc.)
+  - contradiction (lexical)  (unmarried+spouse, always+sometimes-not)
+  - false-premise (lite)     (presupposition patterns)
+  - out-of-corpus            (my-uploaded-X / my-file shapes)
+
+Reference-frame detection lives in ``aborist.qa.query._detect_frame``
+(ticket #000002) and is called from the surrounding runtime, not
+from this module — keeps detection pure-on-question (no corpus
+lookup needed here).
+
+The output is a ``QuestionState`` dataclass that becomes a CTI root
+node. First pass surfaces it on the ``query()`` result dict only;
+run-DAG node binding deferred to the same Phase 5 work tracked in
+ticket #000009 (both nodes can land together).
+
+Hard rule (D1): No LLM in this hard path. Model-assisted preflight,
+if added later, labels itself ``SOFT_PREFLIGHT_HINT`` and never
+produces a ``PREFLIGHT_OK`` / ``PREFLIGHT_BLOCKED`` without
+deterministic support.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import re
+from dataclasses import asdict, dataclass, field
+from typing import Any, Literal
+
+PREFLIGHT_VERSION = "metacognition-v0.1"
+
+
+# ---------------------------------------------------------------- types
+
+LogicalStatus = Literal[
+    "well_formed",
+    "under_specified",
+    "false_premise_suspected",
+    "contradictory_question",
+    "out_of_corpus_risk",
+    "stale_risk",
+    "reference_frame_ambiguous",
+    "broad_quantifier_unbounded",
+]
+
+PreflightResult = Literal[
+    "PREFLIGHT_OK",
+    "PREFLIGHT_PARTIAL",
+    "PREFLIGHT_BLOCKED",
+]
+
+TemporalSensitivity = Literal["high", "medium", "low"]
+
+
+
+[docs] +@dataclass(frozen=True) +class QuestionState: + """Runtime epistemic state for one question. + + All fields are deterministic from the question + per-call + `model_profile` / `corpus_profile` / `policy` inputs. No + randomness, no LLM. Hashable via `preflight_policy_hash` so + the run-DAG (Phase 5) can bind the decision into the audit + chain. + """ + + raw_question: str + question_hash: str + logical_statuses: tuple[LogicalStatus, ...] + question_shape: str + quantifier_intensity: str | None + quantifier_matched_token: str | None + scope_bound_hint: str + reference_frames: tuple[str, ...] + temporal_sensitivity: TemporalSensitivity + temporal_matched_tokens: tuple[str, ...] + contradiction_pairs: tuple[tuple[str, str], ...] + false_premise_hints: tuple[dict, ...] + corpus_requirement: str + known_boundaries: tuple[str, ...] + answer_constraints: dict + preflight_result: PreflightResult + preflight_policy_hash: str + classifier_version: str = PREFLIGHT_VERSION + +
+[docs] + def to_dict(self) -> dict: + """Convert to JSON-serializable dict for run-DAG / bench.""" + out = asdict(self) + # asdict converts inner dataclasses but tuples-of-tuples + # come back as nested lists already — keep them as lists + # for JSON hygiene. + return out
+
+ + + +# ---------------------------------------------------------------- temporal + +# Lexical patterns at start-of-question or as standalone tokens. +# `current`, `latest`, `today`, `now`, `as of`, `this year`. +_TEMPORAL_HIGH_PATTERNS = [ + re.compile(r"\bcurrent(?:ly)?\b", re.IGNORECASE), + re.compile(r"\blatest\b", re.IGNORECASE), + re.compile(r"\btoday\b", re.IGNORECASE), + re.compile(r"\bright now\b", re.IGNORECASE), + re.compile(r"\bas of\b", re.IGNORECASE), + re.compile(r"\bthis year\b", re.IGNORECASE), + re.compile(r"\bthis month\b", re.IGNORECASE), + re.compile(r"\bthis week\b", re.IGNORECASE), + re.compile(r"\brecently?\b", re.IGNORECASE), +] + +# Role-shape patterns: questions about who currently holds a role. +# Conservative — only positions with rapid turnover. +_TEMPORAL_ROLE_PATTERNS = [ + re.compile(r"\bCEO\b"), + re.compile(r"\bpresident of\b", re.IGNORECASE), + re.compile(r"\bprime minister\b", re.IGNORECASE), + re.compile(r"\bcurrent (?:champion|holder|price|stock)\b", re.IGNORECASE), +] + + +
+[docs] +def detect_temporal_sensitivity( + question: str, +) -> tuple[TemporalSensitivity, tuple[str, ...]]: + """Return ``(sensitivity, matched_tokens)``. + + `high` = explicit temporal anchor (`current`, `latest`, etc.) + OR rapid-turnover role pattern. `medium` reserved for future + weekly/monthly cadence detection (not implemented in this + pass). `low` = no temporal markers detected (the default). + """ + matched: list[str] = [] + for pat in _TEMPORAL_HIGH_PATTERNS: + m = pat.search(question) + if m: + matched.append(m.group(0)) + for pat in _TEMPORAL_ROLE_PATTERNS: + m = pat.search(question) + if m: + matched.append(m.group(0)) + if matched: + return "high", tuple(matched) + return "low", ()
+ + + +# ---------------------------------------------------------------- contradiction + +# Conservative lexical-contradiction pairs. Only fires when BOTH +# tokens appear in the question. False positives are operator- +# hostile so we keep the list short and obvious. +_CONTRADICTION_PAIRS: tuple[tuple[str, str], ...] = ( + ("unmarried", "spouse"), + ("unmarried", "married"), + ("never", "always"), + ("alive", "dead"), + ("nonexistent", "existing"), + ("only", "also"), +) + + +
+[docs] +def detect_contradiction( + question: str, +) -> tuple[tuple[str, str], ...]: + """Return tuple of (token_a, token_b) pairs whose BOTH members + appear in ``question`` (case-insensitive whole-word match). + + Returns empty tuple when no contradiction detected. The caller + decides whether to label-only or block — by default this + surfaces in the audit-line tail, NOT a hard block, since false + positives on contradiction would refuse legitimate questions. + """ + q_lower = question.lower() + found: list[tuple[str, str]] = [] + for a, b in _CONTRADICTION_PAIRS: + # Word-boundary match on each side independently. + a_re = re.compile(rf"\b{re.escape(a)}\b") + b_re = re.compile(rf"\b{re.escape(b)}\b") + if a_re.search(q_lower) and b_re.search(q_lower): + found.append((a, b)) + return tuple(found)
+ + + +# ---------------------------------------------------------------- false premise + +# Presupposition patterns. Each pattern extracts an implied relation +# from the question shape. The verifier uses `false_premise_hints` +# as a `required_evidence` hint — the question is NOT blocked, but +# the audit-line tail surfaces "false premise suspected" so the +# operator knows the system didn't blindly accept the premise. + +# Subject and predicate character classes deliberately allow periods +# ("Mr.", "U.S."), apostrophes ("Homer's"), and hyphens +# ("by-law"). End-marker is `?` only (declarative variants of these +# question-shapes are not the target). +_FP_SUBJ = r"[\w\s\.\-']+?" +_FP_PRED = r"[\w\s\.\-']+?" + +_FALSE_PREMISE_PATTERNS = [ + # "when did X stop Y?" → presupposes X did Y + ( + re.compile( + rf"\bwhen did\s+(?P<subject>{_FP_SUBJ})\s+stop\s+(?P<predicate>{_FP_PRED})\s*\?", + re.IGNORECASE, + ), + "stopped_doing", + "X did Y at some prior time", + ), + # "why did X cause Y?" → presupposes X caused Y + ( + re.compile( + rf"\bwhy did\s+(?P<subject>{_FP_SUBJ})\s+cause\s+(?P<predicate>{_FP_PRED})\s*\?", + re.IGNORECASE, + ), + "caused", + "X caused Y", + ), + # "how did X become Y?" → presupposes X became Y + ( + re.compile( + rf"\bhow did\s+(?P<subject>{_FP_SUBJ})\s+become\s+(?P<predicate>{_FP_PRED})\s*\?", + re.IGNORECASE, + ), + "became", + "X became Y", + ), + # "when did X become Y?" → presupposes X became Y + ( + re.compile( + rf"\bwhen did\s+(?P<subject>{_FP_SUBJ})\s+become\s+(?P<predicate>{_FP_PRED})\s*\?", + re.IGNORECASE, + ), + "became", + "X became Y", + ), +] + + +
+[docs] +def detect_false_premise(question: str) -> tuple[dict, ...]: + """Return tuple of presupposition dicts surfacing the implied + relation. Each dict carries: + + kind — pattern label (stopped_doing, caused, ...) + presupposition — natural-language statement of the + presupposition + subject — extracted subject token-span + predicate — extracted predicate token-span + + First-pass detection only. The verifier uses these as soft + hints; downstream the audit-line tail surfaces "false premise + suspected" so the operator can read the audit log and check + whether the cited evidence supports the presupposition. + + Returns empty tuple when no pattern fires. + """ + hints: list[dict] = [] + if not question: + return () + # Append a `?` if the question lacks one — the patterns + # require a sentence-ending marker for the predicate group. + test_q = question if question.rstrip().endswith(("?", ".")) else question + "?" + for pat, kind, presup_template in _FALSE_PREMISE_PATTERNS: + m = pat.search(test_q) + if m: + subject = m.group("subject").strip() + predicate = m.group("predicate").strip() + hints.append({ + "kind": kind, + "presupposition": presup_template.replace( + "X", subject + ).replace("Y", predicate), + "subject": subject, + "predicate": predicate, + }) + return tuple(hints)
+ + + +# ---------------------------------------------------------------- out-of-corpus + +# Patterns that signal the operator is asking about a private / +# uploaded / non-corpus document. Conservative — defaults to +# "likely_in_corpus" for typical encyclopedic questions. +_OUT_OF_CORPUS_PATTERNS = [ + re.compile(r"\bmy (?:uploaded|unpublished|attached|private) [\w\s]+\b", re.IGNORECASE), + re.compile(r"\bthe file (?:i sent|i uploaded|i attached)\b", re.IGNORECASE), + re.compile(r"\bthe document (?:i sent|i uploaded|i attached)\b", re.IGNORECASE), + re.compile(r"\bin my (?:contract|email|notes|spreadsheet|inbox)\b", re.IGNORECASE), + re.compile(r"\bwhat does my [\w\s]+ say\b", re.IGNORECASE), +] + + +
+[docs] +def detect_out_of_corpus(question: str) -> bool: + """Return True iff the question references a private / uploaded + document that the encyclopedic corpus cannot have.""" + for pat in _OUT_OF_CORPUS_PATTERNS: + if pat.search(question): + return True + return False
+ + + +# ---------------------------------------------------------------- preflight + +def _question_hash(question: str) -> str: + """Stable SHA-256 of the raw question string. Lower-cased, + whitespace-normalized so trivial variants share a hash.""" + canon = re.sub(r"\s+", " ", question.strip().lower()) + return hashlib.sha256(canon.encode("utf-8")).hexdigest() + + +def _preflight_policy_hash(*, policy: dict | None, version: str) -> str: + """Hash of the policy fields that drive preflight behavior + + the classifier version. Bumping any of them invalidates prior + QuestionState records on lookup.""" + relevant = { + "metacognition_enabled": (policy or {}).get("metacognition_enabled", True), + "metacognition_temporal_check": (policy or {}).get( + "metacognition_temporal_check", True + ), + "metacognition_contradiction_check": (policy or {}).get( + "metacognition_contradiction_check", True + ), + "metacognition_false_premise_check": (policy or {}).get( + "metacognition_false_premise_check", True + ), + "metacognition_out_of_corpus_check": (policy or {}).get( + "metacognition_out_of_corpus_check", True + ), + "metacognition_block_on_contradiction": (policy or {}).get( + "metacognition_block_on_contradiction", False + ), + "version": version, + } + payload = "|".join(f"{k}={relevant[k]}" for k in sorted(relevant)) + return hashlib.sha256(payload.encode("utf-8")).hexdigest() + + +def _classify_question_shape( + quantifier_intensity: str | None, + temporal: TemporalSensitivity, + has_contradiction: bool, + has_false_premise: bool, + has_out_of_corpus: bool, +) -> str: + """Map the detector outputs onto a coarse shape mnemonic. + + Used by downstream policy + audit display so the operator can + eyeball the question type without parsing the full QuestionState. + """ + if has_out_of_corpus: + return "out_of_corpus" + if has_contradiction: + return "contradictory" + if has_false_premise: + return "presupposing" + if temporal == "high": + return "time_sensitive" + if quantifier_intensity in {"ALL", "COMPREHENSIVE", "OPEN_REQUEST"}: + return "broad_request" + if quantifier_intensity in {"SMALL_NUM_EXPLICIT", "COMPARATIVE_BOUND"}: + return "bounded_count" + if quantifier_intensity == "ABSENT": + return "negation" + if quantifier_intensity == "PROPORTIONAL": + return "proportional" + return "single_fact" + + +
+[docs] +def preflight_question( + question: str, + *, + model_profile_id: str | None = None, + corpus_profile: dict | None = None, + reference_frames: tuple[str, ...] = (), + policy: dict | None = None, +) -> QuestionState: + """Classify ``question`` deterministically into a QuestionState. + + Pure function. Reuses the Phase 1 quantifier classifier + (#000008) plus four new lightweight detectors (temporal, + contradiction, false-premise-lite, out-of-corpus). + + `corpus_profile` is an optional dict carrying corpus boundary + metadata (e.g. ``{"corpus_latest_timestamp": "2003-05-16"}``); + when present, the temporal detector cross-checks against it. + First-pass implementation just records `corpus_requirement` + based on the temporal sensitivity — full cutoff arithmetic + deferred to a future amend. + + `reference_frames` is passed in by the caller because frame + detection requires retrieved sources (lives in + `aborist.qa.query._detect_frame`). Empty tuple is the default + for "no frame routing happened". + + `policy` overrides for the per-detector enables. Defaults are + permissive (all checks on) per ticket #000010 §7.3. + """ + from aborist.qa.quantifier import classify_question_quantifier + + policy = policy or {} + enabled = bool(policy.get("metacognition_enabled", True)) + temporal_on = bool(policy.get("metacognition_temporal_check", True)) + contradiction_on = bool(policy.get("metacognition_contradiction_check", True)) + false_premise_on = bool(policy.get("metacognition_false_premise_check", True)) + out_of_corpus_on = bool(policy.get("metacognition_out_of_corpus_check", True)) + block_on_contradiction = bool( + policy.get("metacognition_block_on_contradiction", False) + ) + + # Empty-question short-circuit. + if not question or not question.strip(): + return QuestionState( + raw_question=question or "", + question_hash=_question_hash(question or ""), + logical_statuses=(), + question_shape="empty", + quantifier_intensity=None, + quantifier_matched_token=None, + scope_bound_hint="unknown", + reference_frames=(), + temporal_sensitivity="low", + temporal_matched_tokens=(), + contradiction_pairs=(), + false_premise_hints=(), + corpus_requirement="not_applicable", + known_boundaries=("empty question",), + answer_constraints={}, + preflight_result="PREFLIGHT_BLOCKED", + preflight_policy_hash=_preflight_policy_hash( + policy=policy, version=PREFLIGHT_VERSION + ), + ) + + # Master kill — return a stub QuestionState with no detector + # output so the result schema stays consistent. Caller can + # distinguish "guard off" from "well-formed question" via the + # logical_statuses tuple being empty AND classifier_version. + if not enabled: + return QuestionState( + raw_question=question, + question_hash=_question_hash(question), + logical_statuses=(), + question_shape="metacognition_disabled", + quantifier_intensity=None, + quantifier_matched_token=None, + scope_bound_hint="unknown", + reference_frames=(), + temporal_sensitivity="low", + temporal_matched_tokens=(), + contradiction_pairs=(), + false_premise_hints=(), + corpus_requirement="not_evaluated", + known_boundaries=(), + answer_constraints={}, + preflight_result="PREFLIGHT_OK", + preflight_policy_hash=_preflight_policy_hash( + policy=policy, version=PREFLIGHT_VERSION + ), + ) + + # Reuse the #000008 quantifier classifier. + quant = classify_question_quantifier(question) + + # Run the four new detectors (each gateable). + if temporal_on: + temporal, temporal_matched = detect_temporal_sensitivity(question) + else: + temporal, temporal_matched = "low", () + + if contradiction_on: + contradictions = detect_contradiction(question) + else: + contradictions = () + + if false_premise_on: + false_premise = detect_false_premise(question) + else: + false_premise = () + + if out_of_corpus_on: + out_of_corpus = detect_out_of_corpus(question) + else: + out_of_corpus = False + + # Compose logical statuses. + statuses: list[LogicalStatus] = [] + if quant.get("is_broad") and quant.get("scope_bound_hint") == "unbounded": + statuses.append("broad_quantifier_unbounded") + elif quant.get("is_broad"): + statuses.append("under_specified") + if temporal == "high": + statuses.append("stale_risk") + if contradictions: + statuses.append("contradictory_question") + if false_premise: + statuses.append("false_premise_suspected") + if out_of_corpus: + statuses.append("out_of_corpus_risk") + if reference_frames and len(reference_frames) > 1: + statuses.append("reference_frame_ambiguous") + if not statuses: + statuses.append("well_formed") + + # Compose answer constraints. + answer_constraints: dict[str, Any] = {} + if "broad_quantifier_unbounded" in statuses or "under_specified" in statuses: + answer_constraints["bounded_or_reject"] = True + answer_constraints["max_claims_hint"] = quant.get("explicit_count") or 8 + if "stale_risk" in statuses: + answer_constraints["requires_current_source"] = True + if "false_premise_suspected" in statuses: + answer_constraints["require_premise_evidence"] = [ + h["presupposition"] for h in false_premise + ] + if "out_of_corpus_risk" in statuses: + answer_constraints["expected_corpus_status"] = "out_of_corpus" + + # Known boundaries — human-readable hints for the audit-line + # render layer + bench operator. + boundaries: list[str] = [] + if temporal_matched: + boundaries.append( + f"temporal markers: {', '.join(temporal_matched)}" + ) + if contradictions: + boundaries.append( + "lexical contradiction: " + + ", ".join(f"{a}/{b}" for a, b in contradictions) + ) + if false_premise: + boundaries.append( + "presuppositions: " + + "; ".join(h["presupposition"] for h in false_premise) + ) + if out_of_corpus: + boundaries.append("references private/uploaded document") + if quant.get("is_broad"): + boundaries.append( + f"broad quantifier ({quant.get('intensity')}, " + f"scope={quant.get('scope_bound_hint')})" + ) + + # Decide preflight result. + # PREFLIGHT_BLOCKED only when an explicit blocking condition is + # set in policy (default False for contradiction-block); otherwise + # PREFLIGHT_PARTIAL when any non-OK status fires; PREFLIGHT_OK + # when only "well_formed" is present. + if "well_formed" in statuses and len(statuses) == 1: + preflight_result: PreflightResult = "PREFLIGHT_OK" + elif "out_of_corpus_risk" in statuses: + preflight_result = "PREFLIGHT_BLOCKED" + elif block_on_contradiction and "contradictory_question" in statuses: + preflight_result = "PREFLIGHT_BLOCKED" + else: + preflight_result = "PREFLIGHT_PARTIAL" + + corpus_requirement = ( + "needs_current_source" + if temporal == "high" + else "out_of_corpus_likely" + if out_of_corpus + else "encyclopedic" + ) + + return QuestionState( + raw_question=question, + question_hash=_question_hash(question), + logical_statuses=tuple(statuses), + question_shape=_classify_question_shape( + quantifier_intensity=quant.get("intensity"), + temporal=temporal, + has_contradiction=bool(contradictions), + has_false_premise=bool(false_premise), + has_out_of_corpus=out_of_corpus, + ), + quantifier_intensity=quant.get("intensity"), + quantifier_matched_token=quant.get("matched_token"), + scope_bound_hint=quant.get("scope_bound_hint", "unknown"), + reference_frames=tuple(reference_frames), + temporal_sensitivity=temporal, + temporal_matched_tokens=tuple(temporal_matched), + contradiction_pairs=tuple(contradictions), + false_premise_hints=tuple(false_premise), + corpus_requirement=corpus_requirement, + known_boundaries=tuple(boundaries), + answer_constraints=answer_constraints, + preflight_result=preflight_result, + preflight_policy_hash=_preflight_policy_hash( + policy=policy, version=PREFLIGHT_VERSION + ), + )
+ +
+
+
+
+ + +
+
+ + Made with Sphinx and @pradyunsg's + + Furo + +
+
+ +
+
+ +
+
+ +
+
+ + + + + \ No newline at end of file diff --git a/docs/_source/_build/html/_modules/aborist/qa/quantifier.html b/docs/_source/_build/html/_modules/aborist/qa/quantifier.html new file mode 100644 index 0000000..5c7f1ac --- /dev/null +++ b/docs/_source/_build/html/_modules/aborist/qa/quantifier.html @@ -0,0 +1,703 @@ + + + + + + + + aborist.qa.quantifier - Aborist API Reference + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + + + +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for aborist.qa.quantifier

+"""Pure quantifier preflight classifier — Ticket #000008 Phase 1.
+
+Maps a question string onto the ten-rung intensity ladder defined in
+``docs/tickets/ticket-000008-broad-quantifier-preflight-guard.md`` §2.
+The classifier exists to estimate **expected number of claims in the
+answer** and **format-discipline risk on small models**. It is not
+formal-semantics quantifier theory; the operational axis is what
+matters.
+
+Pure function. No I/O. No model call. No retrieval call. Folds into
+``governance_policy_hash`` via ``classifier_version`` (added to
+``aborist.qa.keys._VERIFIER_POLICY_FIELDS`` in Phase 2).
+
+Intensity rungs (highest wins for multi-quantifier questions):
+
+    1.  ABSENT             universal-negation, single-claim shape
+    2.  SINGULAR           one-fact wh / definite reference
+    3.  PROPORTIONAL       descriptive fraction (`most`, `half`)
+    4.  SMALL_NUM_EXPLICIT bounded by digit/word (`top 3`, `seven X`)
+    5.  COMPARATIVE_BOUND  bounded by inequality (`at least 5`)
+    6.  FEW                small set, vague (`some`, `a few`)
+    7.  MANY               medium set, vague (`many`, `numerous`)
+    8.  ALL                universal quantifier (`all`, `every`)
+    9.  COMPREHENSIVE      exhaustive request (`complete list of`,
+                          `tell me everything`)
+    10. OPEN_REQUEST       verb-driven enumeration (`tell me about`,
+                          `describe`, `explain`)
+
+Returns a dict with:
+
+    intensity              one of the ten rungs (or "SINGULAR" by default)
+    matched_token          the lexical surface form that triggered the rung
+    explicit_count         int when SMALL_NUM_EXPLICIT or COMPARATIVE_BOUND;
+                          None otherwise
+    is_broad               True for ALL / COMPREHENSIVE / OPEN_REQUEST
+    operational_shape      mnemonic for downstream policy (e.g.
+                          "universal_enumeration", "exhaustive_request")
+    scope_bound_hint       "bounded" | "unbounded" | "unknown"
+                          (see ticket §10.1 — bounded ≠ unbounded
+                           universals; classifier defaults to "unknown"
+                           when intensity is broad and no domain anchor
+                           is present)
+
+Highest-intensity-wins arbitration: when a question contains
+overlapping markers (e.g. "tell me about all the planets"), pick the
+rung farther from SINGULAR. The order in ``_RUNG_PRIORITY`` codifies
+this — later rungs win.
+"""
+
+from __future__ import annotations
+
+import re
+
+CLASSIFIER_VERSION = "quantifier-v0.1"
+
+# ---------------------------------------------------------------- intensities
+
+_RUNG_PRIORITY = (
+    "SINGULAR",
+    "PROPORTIONAL",
+    "SMALL_NUM_EXPLICIT",
+    "COMPARATIVE_BOUND",
+    "FEW",
+    "MANY",
+    "ABSENT",
+    "ALL",
+    # OPEN_REQUEST sits below COMPREHENSIVE: "tell me everything
+    # about X" is BOTH OPEN_REQUEST-shape ("tell me about") AND
+    # COMPREHENSIVE-shape ("everything"). COMPREHENSIVE wins because
+    # it carries the explicit exhaustive request — see §2.2 of
+    # ticket #000008 ("COMPREHENSIVE strictly stronger than ALL").
+    # On the cap table, COMPREHENSIVE caps higher than OPEN_REQUEST
+    # for large models because the operator is asking for depth
+    # explicitly; both clamp at 5 on small models.
+    "OPEN_REQUEST",
+    "COMPREHENSIVE",
+)
+
+_BROAD_RUNGS = frozenset({"ALL", "COMPREHENSIVE", "OPEN_REQUEST"})
+
+
+# Lexical patterns per rung. Patterns are compiled with re.IGNORECASE
+# at module-load. Order within each rung doesn't matter — first match
+# wins for matched_token reporting, but rung selection is by priority
+# (later rung = higher intensity, see _classify).
+
+# OPEN_REQUEST — verb-driven enumeration without explicit quantifier.
+_OPEN_REQUEST_PATTERNS = [
+    r"\btell me (?:about|all about)\b",
+    r"\btell me everything\b",
+    r"\bdescribe\b",
+    r"\bexplain\b",
+    r"\bsummari[sz]e\b",
+    r"\bgive me (?:an? )?(?:overview|summary)\b",
+    r"\bwalk me through\b",
+    r"\bdiscuss\b",
+    r"\belaborate on\b",
+    r"\bexpound on\b",
+    r"\bwhat about\b",
+]
+
+# COMPREHENSIVE — exhaustive request, strictly stronger than ALL.
+_COMPREHENSIVE_PATTERNS = [
+    r"\bcomprehensive\b",
+    r"\bcomplete (?:list|inventory|set|enumeration|account)\b",
+    r"\bexhaustive\b",
+    r"\bdefinitive\b",
+    r"\beverything (?:you know|there is)\b",
+    r"\btell me everything\b",
+    r"\bthe whole (?:story|picture|thing)\b",
+    r"\bthe full (?:story|picture|account)\b",
+    r"\bfrom a to z\b",
+    r"\ball there is to know\b",
+]
+
+# ALL — universal quantifier.
+_ALL_PATTERNS = [
+    r"\ball\b",
+    r"\bevery\b",
+    r"\beach (?:and every )?\b",
+    r"\bevery single\b",
+    r"\bthe whole\b",
+    r"\bthe entirety of\b",
+    r"\bthe totality of\b",
+    r"\bany\b",            # universal use ("any X is Y")
+    r"\bwhatever\b",
+    r"\bwhoever\b",
+]
+
+# MANY — medium set, vague.
+_MANY_PATTERNS = [
+    r"\bmany\b",
+    r"\bvarious\b",
+    r"\bmultiple\b",
+    r"\bnumerous\b",
+    r"\ba number of\b",
+    r"\blots of\b",
+    r"\bplenty of\b",
+    r"\ba great many\b",
+    r"\bmultitudes\b",
+    r"\bseveral dozen\b",
+]
+
+# FEW — small set, vague.
+_FEW_PATTERNS = [
+    r"\bsome\b",
+    r"\ba few\b",
+    r"\bseveral\b",
+    r"\ba couple\b",
+    r"\ba handful\b",
+    r"\ba small number of\b",
+    r"\ba smattering of\b",
+    r"\bnot many\b",
+    r"\bhardly any\b",
+]
+
+# SMALL_NUM_EXPLICIT — bounded by digit or numeric word.
+# Matched token reports the count phrase; explicit_count populated
+# from the digit / lexicon below.
+_SMALL_NUM_DIGIT = re.compile(
+    r"\btop (\d+)\b|\b(\d+) (?:biggest|smallest|largest|most|best|worst)\b"
+    r"|\b(?:first|last|top) (\d+)\b",
+    re.IGNORECASE,
+)
+_NUMBER_WORDS = {
+    "one": 1, "two": 2, "three": 3, "four": 4, "five": 5,
+    "six": 6, "seven": 7, "eight": 8, "nine": 9, "ten": 10,
+    "eleven": 11, "twelve": 12, "dozen": 12,
+}
+_SMALL_NUM_WORD_PATTERNS = [
+    rf"\b(?:top |first |last )?({w})\b" for w in _NUMBER_WORDS
+]
+_PAIR_OF = re.compile(r"\bpair of\b", re.IGNORECASE)
+_HANDFUL_OF = re.compile(r"\ba handful of\b", re.IGNORECASE)
+
+# COMPARATIVE_BOUND — bounded by inequality.
+# at_least / at_most / more_than / fewer_than / under / over /
+# up_to / between A and B.
+_COMPARATIVE_PATTERNS = [
+    re.compile(r"\bat least (\d+)\b", re.IGNORECASE),
+    re.compile(r"\bat most (\d+)\b", re.IGNORECASE),
+    re.compile(r"\bno more than (\d+)\b", re.IGNORECASE),
+    re.compile(r"\bmore than (\d+)\b", re.IGNORECASE),
+    re.compile(r"\bfewer than (\d+)\b", re.IGNORECASE),
+    re.compile(r"\bless than (\d+)\b", re.IGNORECASE),
+    re.compile(r"\bunder (\d+)\b", re.IGNORECASE),
+    re.compile(r"\bover (\d+)\b", re.IGNORECASE),
+    re.compile(r"\bup to (\d+)\b", re.IGNORECASE),
+    re.compile(r"\bbetween (\d+) and (\d+)\b", re.IGNORECASE),
+]
+
+# PROPORTIONAL — descriptive fraction.
+_PROPORTIONAL_PATTERNS = [
+    r"\bmost\b",
+    r"\bmajority of\b",
+    r"\bminority of\b",
+    r"\bhalf (?:of|the)\b",
+    r"\ba third of\b",
+    r"\ba quarter of\b",
+    r"\b\d+%\s*of\b",
+    r"\bthe bulk of\b",
+    r"\bthe lion's share of\b",
+]
+
+# Count-question short-circuit. `how many` / `how much` at the
+# start of a question (or after a leading wh-clause like
+# "and how many...") is asking for a single numeric answer, not
+# enumeration. Without this short-circuit the bare `\bmany\b`
+# pattern below misfires.
+#
+# Anchored so we only short-circuit when the question is a count-
+# question SHAPE — `how many` further into the question (e.g.
+# "list the states; how many are there?") doesn't take precedence
+# over the rest of the question's quantifier markers.
+_COUNT_QUESTION_RE = re.compile(
+    r"^\s*(?:and\s+|but\s+|so\s+)?how (?:many|much)\b",
+    re.IGNORECASE,
+)
+
+
+# ABSENT — universal-negation.
+_ABSENT_PATTERNS = [
+    r"\bnone\b",
+    r"\bno (?:one|body|where)\b",
+    r"\bnothing\b",
+    r"\bnobody\b",
+    r"\bnowhere\b",
+    r"\bneither\b",
+    r"\bnever\b",
+    r"\bnot a single\b",
+    r"\bzero\b",
+    # Negative wh-shape: "which X is/are/do/does/did/has/have not Y".
+    # Catches both copular ("which X is not Y") and auxiliary
+    # ("which X do not Y") forms — both express universal negation
+    # over the X universe.
+    r"\bwhich \w+ (?:is |are |do |does |did |has |have |were |was )?not\b",
+    r"\bwho (?:is |are |do |does |did |has |have )?not\b",
+    r"\bwhat (?:is |are |do |does |did |has |have )?not\b",
+]
+
+# Compile each rung's patterns once.
+_OPEN_REQUEST_RE = [re.compile(p, re.IGNORECASE) for p in _OPEN_REQUEST_PATTERNS]
+_COMPREHENSIVE_RE = [re.compile(p, re.IGNORECASE) for p in _COMPREHENSIVE_PATTERNS]
+_ALL_RE = [re.compile(p, re.IGNORECASE) for p in _ALL_PATTERNS]
+_MANY_RE = [re.compile(p, re.IGNORECASE) for p in _MANY_PATTERNS]
+_FEW_RE = [re.compile(p, re.IGNORECASE) for p in _FEW_PATTERNS]
+_PROPORTIONAL_RE = [re.compile(p, re.IGNORECASE) for p in _PROPORTIONAL_PATTERNS]
+_ABSENT_RE = [re.compile(p, re.IGNORECASE) for p in _ABSENT_PATTERNS]
+
+
+# ---------------------------------------------------------------- shape mnemonics
+
+_OPERATIONAL_SHAPE = {
+    "ABSENT": "universal_negation",
+    "SINGULAR": "single_fact",
+    "PROPORTIONAL": "descriptive_fraction",
+    "SMALL_NUM_EXPLICIT": "bounded_count",
+    "COMPARATIVE_BOUND": "bounded_inequality",
+    "FEW": "small_set_vague",
+    "MANY": "medium_set_vague",
+    "ALL": "universal_enumeration",
+    "COMPREHENSIVE": "exhaustive_request",
+    "OPEN_REQUEST": "verb_driven_enumeration",
+}
+
+
+# ---------------------------------------------------------------- scope hint
+
+# Lexical anchors that hint a bounded universe — when present alongside
+# a broad quantifier, scope_bound_hint upgrades from "unknown" to
+# "bounded". This is a heuristic; the corpus arity check (§10.1) is
+# left for a future refinement.
+_BOUNDED_DOMAIN_ANCHORS = (
+    re.compile(r"\bthe (?:beatles|fab four)\b", re.IGNORECASE),
+    re.compile(r"\b(?:US|U\.S\.|united states) (?:states|presidents)\b", re.IGNORECASE),
+    re.compile(r"\bplanets (?:in (?:the|our) solar system)?\b", re.IGNORECASE),
+    re.compile(r"\bfounding fathers\b", re.IGNORECASE),
+    re.compile(r"\bcontinents\b", re.IGNORECASE),
+    re.compile(r"\boceans\b", re.IGNORECASE),
+    # Year/season-anchored questions tend to bound the universe to one
+    # event — "winners of the 2024 World Series" is bounded.
+    re.compile(r"\b(?:19|20)\d{2}\b"),
+    re.compile(r"\b(?:season|year|championship|tournament|event) (?:of|for)\b", re.IGNORECASE),
+)
+
+
+def _scope_bound_hint(question: str, intensity: str) -> str:
+    """Return ``"bounded"``, ``"unbounded"``, or ``"unknown"``.
+
+    Bounded universals (``"all members of the Beatles"``, ``"every US
+    state"``) have a finite, corpus-known answer set. Unbounded
+    universals (``"winners of all major sports"``) have an undefined
+    set under the current scope. The classifier defaults to
+    ``"unknown"`` for non-broad intensities (the question doesn't
+    have universal pressure to gate) and to ``"unbounded"`` for broad
+    intensities lacking a domain anchor.
+
+    Heuristic only. The corpus arity check (§10.1 future refinement)
+    will sharpen this when implemented.
+    """
+    if intensity not in _BROAD_RUNGS:
+        return "unknown"
+    for anchor in _BOUNDED_DOMAIN_ANCHORS:
+        if anchor.search(question):
+            return "bounded"
+    return "unbounded"
+
+
+# ---------------------------------------------------------------- classifier
+
+
+[docs] +def classify_question_quantifier(question: str) -> dict: + """Classify ``question`` onto the ten-rung intensity ladder. + + Highest-intensity-wins arbitration: when multiple rungs match, + pick the one farther from SINGULAR. Operationally that means + "tell me about all the planets" classifies OPEN_REQUEST (later + in the priority order than ALL), even though ALL also matched. + The downstream cap is the broader rung's cap, which is what we + want under enumeration pressure. + + Returns a dict; see module docstring for fields. + + Empty / whitespace-only questions classify SINGULAR (no quantifier + pressure) with no matched_token. + """ + if not question or not question.strip(): + return { + "intensity": "SINGULAR", + "matched_token": None, + "explicit_count": None, + "is_broad": False, + "operational_shape": _OPERATIONAL_SHAPE["SINGULAR"], + "scope_bound_hint": "unknown", + "classifier_version": CLASSIFIER_VERSION, + } + + # Count-question short-circuit (caught by 2026-05-03 dry-run + # review across bench/qa_questions.txt). `how many X?` and + # `how much X?` ask for a SINGLE numeric answer ("50 states", + # "206 bones") — not enumeration. Without this short-circuit, + # the bare `\bmany\b` pattern in _MANY_PATTERNS misfires on + # `how many` and the question lands in MANY rung (cap 8 on + # Hermes), which is wrong: a count question deserves cap 1 + # (SINGULAR), not 8. + # + # The same applies to `how often`, `how long`, `how big` — + # all count/measurement questions with single-fact answers. + # We catch the dominant `how many|much` shape here; the others + # already classify SINGULAR by default. + if _COUNT_QUESTION_RE.search(question): + m = _COUNT_QUESTION_RE.search(question) + return { + "intensity": "SINGULAR", + "matched_token": m.group(0), + "explicit_count": None, + "is_broad": False, + "operational_shape": _OPERATIONAL_SHAPE["SINGULAR"], + "scope_bound_hint": "unknown", + "classifier_version": CLASSIFIER_VERSION, + } + + candidates: list[tuple[str, str, int | None]] = [] + + # Per-rung detection. Earlier rungs run first but rung selection + # uses _RUNG_PRIORITY (highest-priority match wins). + def _check(rung_re_list: list[re.Pattern], rung_name: str) -> None: + for pat in rung_re_list: + m = pat.search(question) + if m: + token = m.group(0) + candidates.append((rung_name, token, None)) + return + + _check(_ABSENT_RE, "ABSENT") + _check(_PROPORTIONAL_RE, "PROPORTIONAL") + _check(_FEW_RE, "FEW") + _check(_MANY_RE, "MANY") + _check(_ALL_RE, "ALL") + _check(_COMPREHENSIVE_RE, "COMPREHENSIVE") + _check(_OPEN_REQUEST_RE, "OPEN_REQUEST") + + # SMALL_NUM_EXPLICIT — explicit digit or number word. + digit_match = _SMALL_NUM_DIGIT.search(question) + if digit_match: + # Pull the first non-None group as the count. + count = next( + (int(g) for g in digit_match.groups() if g is not None), + None, + ) + candidates.append(("SMALL_NUM_EXPLICIT", digit_match.group(0), count)) + else: + # Number-word path. Try each word; first hit wins. + for word, count in _NUMBER_WORDS.items(): + if re.search(rf"\b(?:top |first |last )?{word}\b", + question, re.IGNORECASE): + candidates.append( + ("SMALL_NUM_EXPLICIT", word, count) + ) + break + # `pair of` → 2; `a handful of` → 5 (folds into FEW + # operationally, but record as SMALL_NUM_EXPLICIT with + # count=2/5 so the cap is exact). + if _PAIR_OF.search(question): + candidates.append(("SMALL_NUM_EXPLICIT", "pair of", 2)) + if _HANDFUL_OF.search(question): + candidates.append(("SMALL_NUM_EXPLICIT", "a handful of", 5)) + + # COMPARATIVE_BOUND — explicit inequality. + for pat in _COMPARATIVE_PATTERNS: + m = pat.search(question) + if m: + # `between A and B` → use the upper bound; otherwise the + # single captured number. + groups = [int(g) for g in m.groups() if g is not None] + count = max(groups) if groups else None + candidates.append(("COMPARATIVE_BOUND", m.group(0), count)) + break + + if not candidates: + # Default — SINGULAR for any wh-question or definite reference + # without quantifier markers. + intensity = "SINGULAR" + matched_token: str | None = None + explicit_count: int | None = None + else: + # Highest-intensity wins (later in _RUNG_PRIORITY = higher). + priority = {r: i for i, r in enumerate(_RUNG_PRIORITY)} + candidates.sort(key=lambda c: priority.get(c[0], -1), reverse=True) + intensity, matched_token, explicit_count = candidates[0] + + return { + "intensity": intensity, + "matched_token": matched_token, + "explicit_count": explicit_count, + "is_broad": intensity in _BROAD_RUNGS, + "operational_shape": _OPERATIONAL_SHAPE[intensity], + "scope_bound_hint": _scope_bound_hint(question, intensity), + "classifier_version": CLASSIFIER_VERSION, + }
+ +
+
+
+
+ + +
+
+ + Made with Sphinx and @pradyunsg's + + Furo + +
+
+ +
+
+ +
+
+ +
+
+ + + + + \ No newline at end of file diff --git a/docs/_source/_build/html/_modules/aborist/qa/query.html b/docs/_source/_build/html/_modules/aborist/qa/query.html new file mode 100644 index 0000000..c2c5e83 --- /dev/null +++ b/docs/_source/_build/html/_modules/aborist/qa/query.html @@ -0,0 +1,3431 @@ + + + + + + + + aborist.qa.query - Aborist API Reference + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + + + +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for aborist.qa.query

+"""Multi-source corpus Q&A.
+
+Pose a question, the tree finds related cached docs, assembles them as context,
+asks Hermes, caches the answer.
+
+The flow:
+    1. FTS5 search across all shards (chunks_fts can't be UNION'd in views,
+       so we query each shard's index independently and merge by score).
+    2. Title-boost rerank: hits whose title contains query tokens get a
+       score bump. Title is a strong topical signal that BM25 alone misses
+       (BM25 favors short docs with rare body tokens — Tell_(poker) outranks
+       Back_to_the_Future without a title boost).
+    3. Pick top-K distinct documents within a character budget.
+    4. Compute `context_root` = Merkle root over the sorted source
+       document_roots — that's the "source" dimension of v9.8's 8-dim
+       cache_key for this multi-source answer.
+    5. Cache lookup; hit returns the persisted audit_mode.
+    6. Miss calls Hermes via the OpenAI-compatible client, then runs the
+       faithfulness check (`verify_quotes`) — every double-quoted span in
+       the answer is verbatim-matched against the assembled context.
+       Result classifies the answer:
+           STRICT   every quote (>=1) verified against context
+           HYBRID   some claims sourced, some emergent (training-derived)
+           UNGROUNDED   no quotes verify — purely emergent
+    7. Persist record with merkle_proof = {context_root, sources: [...]},
+       audit_mode, and unverified_quotes (the spans the model produced
+       that didn't appear in any source — corpus-growth signal).
+
+Per-source proofs are not bundled here (the source roots themselves are
+already content-addressed). A verifier asks the shards for any specific
+chunk's proof on demand.
+"""
+
+from __future__ import annotations
+
+import json
+import re
+import time
+from dataclasses import dataclass
+from pathlib import Path
+
+from aborist import (
+    CANONICALIZATION_VERSION,
+    CHUNKING_VERSION,
+    SCHEMA_VERSION,
+)
+from aborist.compress import unpack_chunk
+from aborist.merkle import MerkleTree
+from aborist.qa.client import ChatClient
+from aborist.qa.prompts import (
+    CLAIM_LATTICE_GROUNDING_REMINDER,
+    CLAIM_LATTICE_JSON_GROUNDING_REMINDER,
+    CLAIM_LATTICE_JSON_SYSTEM_PROMPT,
+    CLAIM_LATTICE_SYSTEM_PROMPT,
+)
+from aborist.qa.concepts import (
+    has_compare_phrasing,
+    rivalry_excluded,
+    synonym_expand,
+)
+from aborist.qa.keys import (
+    DEFAULT_FIDELITY,
+    DEFAULT_QUESTION_DEDUP,
+    FIDELITY_MODES,
+    QUESTION_DEDUP_MODES,
+    cache_key,
+    canonical_question,
+    conversation_hash,
+    governance_policy_hash,
+    model_profile_hash,
+    question_hash,
+    verifier_policy_hash,
+)
+from aborist.qa.dag import build_run_dag
+from aborist.qa.frame import FrameDetection, detect_frame as _detect_frame
+from aborist.qa.retrieval_plan import RetrievalPlan, retrieval_plan_hash
+from aborist.qa.evidence import (
+    build_evidence_map,
+    evidence_map_root,
+    render_evidence_map,
+    render_evidence_map_for_json,
+)
+from aborist.qa.repair import mechanical_repair, reprompt_repair
+from aborist.qa.verify import (
+    ANSWER_MODES,
+    CLAIM_LATTICE_JSON_SCHEMA,
+    verify_claim_lattice_json,
+    DEFAULT_ANSWER_MODE,
+    verify_claim_lattice,
+    verify_quotes,
+)
+
+try:
+    from aborist.wikitext import BASE_VERSION as _WIKITEXT_BASE_VERSION
+    from aborist.wikitext import to_base as _wikitext_to_base
+except ImportError:  # pragma: no cover
+    _WIKITEXT_BASE_VERSION = None
+    _wikitext_to_base = None
+from aborist.search import FTS5Backend
+from aborist.store import (
+    append_audit,
+    connect,
+    discover_shards,
+    transaction,
+)
+
+
+_TITLE_TOKEN_RE = re.compile(r"[A-Za-z][A-Za-z0-9]*")
+# Hyphen-joined run of two-or-more word tokens. Used by
+# `_hyphen_fold_variants` to emit joined-no-hyphen variants. See
+# Ticket #000007 for the FTS5 hyphen-tokenization-asymmetry rationale:
+# `Bipolar disorder` indexes as [bipolar], `Bi-Polar (album)` indexes
+# as [bi, polar, ...]. Without the fold, query "bi-polar" hits only
+# the album cluster.
+_HYPHEN_RUN_RE = re.compile(
+    r"[A-Za-z][A-Za-z0-9]*(?:-[A-Za-z][A-Za-z0-9]*)+"
+)
+# Kept in sync with FTS5 stopwords in aborist.search.fts5 — both filter
+# the same set of question-shaping words. "tell" leaking into title-LIKE
+# search caused "tell me about permacomputer" to pull Tell_(poker), the
+# Tell-Tale_Heart movie, Tell_City Indiana, etc.
+_TITLE_STOPWORDS = frozenset(
+    """
+    the a an is are was were be been being of to in on at for with by from
+    as about into through during and or but not no nor so yet too very also just
+    what who where when why how which this that these those such i you he she
+    it we they me him her us them do does did have has had can could should
+    would will may might
+    tell show describe explain summarize say give list find make please
+    all there know everything anything something
+    """.split()
+)
+
+
+def _hyphen_fold_variants(s: str) -> set[str]:
+    """For each hyphen-joined run of word tokens in ``s``, emit the
+    joined-no-hyphen form. Lets retrieval reach indexed forms that
+    survived the FTS5 hyphen-split asymmetry (Ticket #000007):
+
+        "bi-polar is rare?"  -> {"bipolar"}
+        "high-school co-op"  -> {"highschool", "coop"}
+        "plain query"        -> set()
+
+    Stopword and length filters mirror ``_title_query_tokens`` so a
+    junk fold like "of-the" -> "ofthe" never enters the candidate set.
+    """
+    out: set[str] = set()
+    for run in _HYPHEN_RUN_RE.findall(s):
+        joined = run.replace("-", "").lower()
+        if len(joined) > 1 and joined not in _TITLE_STOPWORDS:
+            out.add(joined)
+    return out
+
+
+def _title_query_tokens(s: str) -> set[str]:
+    base = {
+        t.lower()
+        for t in _TITLE_TOKEN_RE.findall(s)
+        if t.lower() not in _TITLE_STOPWORDS and len(t) > 1
+    }
+    # Hyphen-fold: additively include joined-no-hyphen variants for
+    # hyphenated runs in `s`. Symmetric — the function is called on
+    # both queries and titles, and additive fold preserves existing
+    # match patterns (e.g. `Coca-Cola history` query keeps {coca,
+    # cola, cocacola, history} so a `Coca-Cola` title still passes
+    # title-breadth via {coca, cola, cocacola}). See Ticket #000007.
+    base |= _hyphen_fold_variants(s)
+    return base
+
+
+def _rerank_by_title(
+    hits: list,
+    question: str,
+    boost: float = 10.0,
+) -> list:
+    """Boost hits whose title overlaps query tokens. Pure ordering aid."""
+    qtokens = _title_query_tokens(question)
+    if not qtokens:
+        return hits
+    for h in hits:
+        if not h.title:
+            continue
+        ttokens = _title_query_tokens(h.title.replace("_", " "))
+        overlap = len(qtokens & ttokens)
+        if overlap:
+            h.score += overlap * boost
+    hits.sort(key=lambda h: -h.score)
+    return hits
+
+
+def _filter_by_title_relevance(
+    hits: list,
+    question: str,
+    *,
+    core_match_roots: set[str] | None = None,
+    body_density_check: callable | None = None,
+    phrase_match_roots: set[str] | None = None,
+    hyphen_fold_anchors: set[str] | None = None,
+    fallback_top_n: int = 5,
+    shards_dir=None,
+) -> list:
+    """Concept-aware relevance filter with five accept paths:
+
+    1. Title-token overlap (after synonym expansion). Strongest signal.
+    2. TF-IDF core keyword overlap — `core_match_roots` is a precomputed
+       set of source document_roots whose TF-IDF cores contain query
+       tokens. Closes the gap for neologisms like "permacomputer" that
+       never appear in titles but are distinctive enough to be TF-IDF
+       keywords of conversation bodies.
+    3. Body density — docs mentioning the query token >= N times pass
+       even without title or core match. Cheap proxy for "actually about
+       the topic." `body_density_check(hit)` returns bool.
+    4. Phrase-match — docs whose body contains a verbatim 4+ token
+       sequence from the question pass even when title and content
+       tokens don't overlap. Closes the allusion gap (2026-05-01
+       Orwell case): "has oceania always been at war with east asia"
+       has zero token overlap with the title "Nineteen Eighty-Four"
+       but the body contains the verbatim phrase "always been at
+       war" — without this accept path, the phrase-route hit gets
+       filtered out before it can rerank into the top-K. The
+       upstream phrase route already gates on 4-token-min sequences
+       (see _question_phrases) so false-positive risk is low.
+    5. Hyphen-fold anchor — when the question has hyphenated runs
+       (Ticket #000007), `hyphen_fold_anchors` is the joined-form
+       set ({"bipolar"} for "bi-polar is rare?"). Title-side stem
+       overlap with this anchor passes the filter even when the
+       breadth threshold fails. Rescues non-hyphen titles like
+       `Bipolar disorder` from rejection while leaving non-hyphen
+       queries (anchors empty) unaffected.
+
+    Rivalry exclusion (Intel-titled docs in AMD queries) still applies on
+    every accept path.
+
+    If all five accept paths together produce nothing, fall back to the
+    top `fallback_top_n` body-BM25 hits — the LLM gets enough context to
+    say "I don't know" rather than fabricating from a single tangential
+    source.
+    """
+    qtokens = _title_query_tokens(question)
+    if not qtokens:
+        return hits
+    qtokens_stem = {_stem_token_for_match(t) for t in qtokens}
+    accept = synonym_expand(qtokens, shards_dir=shards_dir)
+    exclude = rivalry_excluded(
+        qtokens,
+        compare_phrasing=has_compare_phrasing(question),
+        shards_dir=shards_dir,
+    )
+    core_roots = core_match_roots or set()
+    phrase_roots = phrase_match_roots or set()
+    anchor_stems = (
+        {_stem_token_for_match(a) for a in hyphen_fold_anchors}
+        if hyphen_fold_anchors
+        else set()
+    )
+    # Title-overlap breadth threshold scales with query length, mirroring
+    # _body_density_passes: ≤2 tokens require ALL, 3+ require N-1. Without
+    # this, a 2-token query like "supermans girlfriend" admits docs that
+    # share only ONE token with the title (e.g. `Girlfriends` the TV show)
+    # — title-overlap fires first & body-density never gets to reject.
+    title_breadth = len(qtokens) if len(qtokens) <= 2 else len(qtokens) - 1
+
+    kept: list = []
+    for h in hits:
+        ttokens = _title_query_tokens(h.title.replace("_", " ")) if h.title else set()
+        ttokens_stem = {_stem_token_for_match(t) for t in ttokens}
+        if exclude & ttokens:
+            continue  # rivalry: opposing-side title, drop it
+        # Direct stem-aware match against query tokens (each qtoken must
+        # be present, possessive/plural-tolerant). Strict signal.
+        direct_matches = len(qtokens_stem & ttokens_stem)
+        if direct_matches >= title_breadth:
+            kept.append(h)
+            continue
+        # Synonym fallback only for 1-token queries — otherwise a single
+        # synonym hit (e.g. "amd" matching an "intel"-titled doc via the
+        # Intel/AMD group) would over-recall.
+        if len(qtokens) == 1 and accept & ttokens:
+            kept.append(h)
+            continue
+        if h.document_root in core_roots:
+            kept.append(h)
+            continue
+        if h.document_root in phrase_roots:
+            kept.append(h)
+            continue
+        # Accept-path 5: hyphen-fold anchor (Ticket #000007). The
+        # joined-form variant from a hyphenated query token (e.g.
+        # "bipolar" from "bi-polar") matching the title's stem set
+        # is enough signal to pass — rescues `Bipolar disorder` from
+        # the breadth gate when the query was "bi-polar is rare?".
+        # Empty anchor set on non-hyphen queries — zero side effect.
+        if anchor_stems and (anchor_stems & ttokens_stem):
+            kept.append(h)
+            continue
+        if body_density_check is not None and body_density_check(h):
+            kept.append(h)
+            continue
+    if not kept:
+        return hits[: max(1, fallback_top_n)] if hits else []
+    return kept
+
+
+DEFAULT_QUERY_POLICY = {
+    # Ticket #000007 — query-layer hyphen-fold marker. Folds into
+    # `governance_policy_hash` so records produced under the new
+    # rule (`Bipolar disorder` reachable from query "bi-polar")
+    # cache-split cleanly from pre-fold records. Code applies the
+    # fold unconditionally; this flag exists to make the policy
+    # transition observable from the cache_key alone.
+    "hyphen_fold_v1": True,
+    # Ticket #000006 amend 2026-05-02b (Rule 9) — premise-parroting /
+    # subject-tokens-absent demote. Threshold = number of question∩
+    # claim content tokens that must be absent from the union of
+    # cited evidence spans before STRICT → HYBRID. Default 3 keeps
+    # the signal unambiguous (single-token absence is often a
+    # stem-variant near-miss; three+ is the parroting fingerprint).
+    # Folds into verifier_policy_hash + governance_policy_hash.
+    "claim_lattice_subject_tokens_absent_threshold": 3,
+    "system_prompt": (
+        "You are answering a question using ONLY the sources provided below. "
+        "Each source is delimited by '=== Source: <URI> ===' headers.\n\n"
+        "GROUNDING RULE (most important):\n"
+        "For EVERY factual claim in your answer, include a verbatim quote "
+        "from a source enclosed in double quotes (\"...\"). The quoted span "
+        "must appear word-for-word in one of the sources. Make a claim "
+        "only when a verbatim quote in one of the sources supports it. "
+        "Quote the source word-for-word inside the double quotes.\n\n"
+        "ATTRIBUTION RULES:\n"
+        "1. A single source may discuss MULTIPLE products, companies, or "
+        "competitors. Read carefully and attribute facts only to the entity "
+        "the source explicitly names for that fact.\n"
+        "2. When the question asks about ONE specific entity (e.g., 'fastest "
+        "AMD CPU'), keep facts about that entity in the answer; leave facts "
+        "about competitors (Intel, Pentium) out, even when the source "
+        "discusses both — they are different products that happen to share "
+        "an article.\n"
+        "3. When referencing model numbers like 'Athlon XP 3200+', remember "
+        "that AMD's PR-rating numbers (3200+, 2500+) are model labels, not "
+        "the clock speed in MHz. Quote the source's own wording for clock "
+        "speeds; let the source state the speed.\n"
+        "4. When the sources contain the answer, write it. When the answer "
+        "is absent from the sources, say 'I don't know based on the "
+        "provided sources.' and stop there. Stay inside the sources at "
+        "all times — let the corpus speak."
+    ),
+    # Restated rule fired as a user message immediately before the sources
+    # turn. Hermes (and most instruction-tuned 8B models) follow recent
+    # user-turn instructions more reliably than a system-turn rule that
+    # decays under long context. Repetition is not redundancy — it raises
+    # the prior on the response shape we want.
+    "grounding_reminder": (
+        "REMINDER: wrap every factual claim in double quotes (\"...\") "
+        "and the quoted span must appear word-for-word in one of the "
+        "sources. Each claim earns a verbatim quote. "
+        "Example:\n\n"
+        "  Q: who founded Apple?\n"
+        "  Sources: ...Apple Inc. was founded by Steve Jobs, Steve "
+        "Wozniak, and Ronald Wayne in 1976...\n"
+        "  A: Apple's founders are named in the source: \"Apple Inc. was "
+        "founded by Steve Jobs, Steve Wozniak, and Ronald Wayne\".\n\n"
+        "Now answer the question on the next message."
+    ),
+    "temperature": 0.1,
+    "top_p": 1.0,
+    "max_tokens": 768,
+    # Entity-path policy for the faithfulness verifier. See
+    # aborist/qa/verify.py:ENTITY_POLICIES. Default 'proximity' promotes
+    # to STRICT only if N verified entities cluster within W chars of
+    # each other in source — separating "source documents these entities
+    # as a group" (cast list / infobox / roster) from "source mentions
+    # them incidentally in scattered prose". Lives in policy so changing
+    # it bumps governance_policy_hash and invalidates cache cleanly.
+    "entity_policy": "proximity",
+    "entity_proximity_n": 3,
+    "entity_proximity_window": 300,
+    # Strip wikitext markup before the LLM ever sees the context. Lets
+    # Hermes quote prose verbatim and shrinks token bills (~43% on
+    # Wikipedia chunks). Bumps governance_policy_hash so prior cached
+    # answers under raw-wikitext policy stay distinct on lookup. No-op
+    # if mwparserfromhell isn't installed.
+    "base_version": _WIKITEXT_BASE_VERSION,
+    # Mechanical answer repair after first verify. Off by default so
+    # existing callers don't see answer text mutate. When on:
+    # synthetic_elision splits, trailing_artifact trims, and no_overlap
+    # claim drops are applied deterministically; the repaired answer is
+    # re-verified & persisted (cache_key inputs unchanged, only
+    # answer_text differs from what the LLM produced). One audit event
+    # `providence_repair` records the pre→post transition. Bumps
+    # governance_policy_hash so on/off agents share no cache silos.
+    "repair_enabled": False,
+    # Maximum re-prompt iterations after mechanical repair. 0 = no
+    # re-prompt (mechanical only). 1 = at most one extra LLM call
+    # asking the model to rewrite around the failed quotes.
+    "repair_max_reprompts": 0,
+    # G0 / CTI — claim-lattice-pointer answer mode. See
+    # aborist/qa/runner.py:DEFAULT_POLICY for full semantics. Default
+    # "quote" preserves existing behavior; "claim_lattice_pointer"
+    # instructs the runtime to build an evidence map, show short
+    # pointer ids (E1, E2, …) to the model, and accept pointer-line
+    # output ("Claim. [E12]") that the verifier maps back to
+    # content-addressed evidence_ids for the cache & run-DAG.
+    "answer_mode": DEFAULT_ANSWER_MODE,
+    "claim_lattice_system_prompt": CLAIM_LATTICE_SYSTEM_PROMPT,
+    "claim_lattice_grounding_reminder": CLAIM_LATTICE_GROUNDING_REMINDER,
+    # Reference-frame polarity preamble (Ticket #000002 / Module L).
+    # Injected as an additional user-role message before the
+    # grounding_reminder when `detect_frame` classifies the query as
+    # `reference` (allusion-shape query whose phrase route surfaced a
+    # fictional / reference-work source). The preamble nudges the
+    # model toward multi-frame answers — distinguish what the cited
+    # work depicts as actual continuity from what in-universe
+    # propaganda or characters claim. Pure prompt-side hint;
+    # verifier still runs the same hard checks. Empty string disables
+    # the augmentation. Folds into governance_policy_hash so two
+    # policies with different preamble text produce different
+    # cache_keys.
+    "claim_lattice_polarity_preamble": (
+        "This question may be a reference to {reference_title}. When "
+        "you answer, distinguish what the cited work depicts as "
+        "actual continuity from what in-universe propaganda or "
+        "characters claim within it. Cite evidence for each "
+        "substantive claim using the pointer format above."
+    ),
+    "claim_lattice_allowed_source_roles": [
+        "primary_answer_source",
+        "secondary_context_source",
+        "background_source",
+        "unclassified",
+        # Self-reference: STRICT-trusted-as-fact unless falsified.
+        # See docs/self-reference-design.md.
+        "self_reference_source",
+    ],
+    "claim_lattice_max_pointers_per_claim": 2,
+    # Cap on evidence blocks (chunks) per retrieved source. Default 2.
+    # Without this cap, a long Wikipedia article alone can split into
+    # ~20 chunks, each becoming a separate E* — the model then sees
+    # E1-E26 for what is really 5 sources and writes a mega-claim
+    # citing all of them. With the cap, 5 sources × 2 = 10 evidence
+    # blocks max. Chunks within each source are still relevance-ranked
+    # (distinct_query_tokens DESC, total_mentions DESC, chunk_idx ASC)
+    # so the cap keeps the most-relevant 2 chunks. Folds into
+    # governance_policy_hash; changing the cap invalidates prior
+    # cached records.
+    "claim_lattice_max_chunks_per_source": 2,
+    "claim_lattice_min_citation_coverage": 0.30,
+    "claim_lattice_min_claim_content_tokens": 2,
+    "claim_lattice_lazy_anchor_demote_threshold": 0.5,
+    "claim_lattice_lazy_anchor_demote_min_pairs": 3,
+    "claim_lattice_warrant_check_enabled": True,
+    # Deflection check: subject-anchor heuristic catches "model deflected
+    # to an adjacent grounded question" (e.g. "who burns the amazon
+    # river?" answered about rainforest deforestation, "river" never
+    # in answer). Promoted from sidecar to soft-demote 2026-05-02 —
+    # DEFLECTION_DETECTED downgrades EVIDENCE-WARRANTED → ANCHOR-
+    # WARRANTED via the existing soft-demote ladder path.
+    "claim_lattice_deflection_check_enabled": True,
+    "claim_lattice_format_collapse_check_enabled": True,
+    # Ticket #000008 Phase 2 — quantifier preflight guard. See
+    # aborist/qa/runner.py:DEFAULT_POLICY for the full rationale.
+    # Phase 2 lands with apply_caps=False (dry-run); cap is
+    # reported on result dict but not applied to the verifier.
+    "quantifier_guard_enabled": True,
+    "quantifier_guard_apply_caps": False,
+    # Mode allowlist when apply_caps flips True. See runner.DEFAULT_POLICY
+    # for the n=5 bench data driving the JSON-only default.
+    "quantifier_apply_caps_modes": ["claim_lattice"],
+    "quantifier_caps_by_intensity": {},
+    "quantifier_guard_modes": ["claim_lattice_pointer", "claim_lattice"],
+    # Phase 3 — default ON for lattice modes per the 2026-05-03 bench
+    # A/B (#000008 §12). See runner.DEFAULT_POLICY for full rationale.
+    "quantifier_reminder_enabled": True,
+    # Phase 4 — strict reject for broad-unbounded. See runner.py for
+    # rationale. Default OFF.
+    "quantifier_reject_broad": False,
+    # Ticket #000010 — see runner.DEFAULT_POLICY for rationale.
+    "metacognition_enabled": True,
+    "metacognition_temporal_check": True,
+    "metacognition_contradiction_check": True,
+    "metacognition_false_premise_check": True,
+    "metacognition_out_of_corpus_check": True,
+    "metacognition_block_on_contradiction": False,
+    # Ticket #000011 — soft preflight sidecar. See runner.DEFAULT_POLICY
+    # for full rationale. Default OFF.
+    "soft_preflight_enabled": False,
+    # Claim-count ceiling — see runner.DEFAULT_POLICY for rationale.
+    # Bench finding (york-england "tell me all there is to know")
+    # caught the runaway shape; cap of 12 admits entity-list
+    # questions while flagging encyclopedic spam. Folds into
+    # governance_policy_hash on change.
+    "claim_lattice_max_claims_per_answer": 12,
+    # JSON variant — `answer_mode="claim_lattice"`. Pairs with grammar-
+    # constrained inference (vLLM guided_json, Claude/GPT-4 native
+    # JSON, Qwen 3.6 reasoner). Lenient pre-parser keeps the path
+    # survivable on inference paths without grammar guidance. Toggling
+    # `claim_lattice_use_guided_json=False` disables the extra_body
+    # pass for endpoints that 400 on unknown fields.
+    "claim_lattice_json_system_prompt": CLAIM_LATTICE_JSON_SYSTEM_PROMPT,
+    "claim_lattice_json_grounding_reminder": CLAIM_LATTICE_JSON_GROUNDING_REMINDER,
+    "claim_lattice_use_guided_json": True,
+    "claim_lattice_json_stop_sequences": ["\n\n"],
+    # Per-mode context-budget defaults. Sprint 1b (2026-05-02) bench
+    # measured each answer mode's peak strict-rate bucket on a sweep
+    # over 8 KB → 1 MB context budgets. Previous flat default of
+    # 60 KB was past peak for both quote and pointer modes (which
+    # degrade after 32 KB) and only marginally above peak for the
+    # JSON variant (which keeps climbing into the 32-64 KB bucket).
+    # Defaults below are the mid of each mode's peak bucket rounded
+    # to nice numbers:
+    #   quote                  → 16-32KB peak  → 24000
+    #   claim_lattice_pointer  → 16-32KB peak  → 24000
+    #   claim_lattice (JSON)   → 32-64KB peak  → 48000
+    # Selected by `query()` when the caller does not pass an explicit
+    # `max_context_chars`. Any explicit caller value still wins
+    # (backward-compatible at the API boundary). The mapping itself
+    # lives in policy so changes fold into governance_policy_hash and
+    # partition the cache namespace cleanly. See
+    # docs/qa-modes-bench.md "recommended context budget".
+    "max_context_chars_by_mode": {
+        "quote": 24000,
+        "claim_lattice_pointer": 24000,
+        "claim_lattice": 48000,
+    },
+}
+
+
+@dataclass
+class _Hit:
+    document_root: str
+    document_uri: str
+    title: str | None
+    score: float
+    shard_path: str
+    chunk_idx: int
+    source_role: str = "unclassified"
+
+
+# Heuristic role classification + per-role budget multiplier. Lets the
+# primary answer page (e.g. `Jurassic Park (film)` for a JP film query)
+# claim a wider context slice than peripheral pages (`Jurassic Park (film
+# score)`, `Jurassic Park video games`). The running `char_budget` check
+# still bounds total context to `max_context_chars`; weights just shift
+# how the budget gets divided.
+SOURCE_ROLE_BUDGET_WEIGHTS = {
+    "primary_answer_source": 2.0,
+    "secondary_context_source": 1.0,
+    "noisy_background_source": 0.5,
+    "sequel_background_source": 0.5,
+    "background_source": 1.0,
+    "unclassified": 1.0,
+    # Self-promoted providence records (STRICT live, past kindergarten
+    # window). Same budget weight as background — Wikipedia stays the
+    # canonical primary; self-reference is supplementary anchoring.
+    # Trust model: STRICT-as-fact unless the verifier falsifies it.
+    "self_reference_source": 1.0,
+}
+
+# Title patterns that demote a source's role. Lower-cased substring match.
+# 2026-04-30: extended to catch tie-in spinoff titles. The JP-dinosaurs
+# query lazy-anchored on "Jurassic Park: Operation Genesis" (a video game)
+# whose enumerative dinosaur tables pattern-matched the question shape
+# more cleanly than the actual film article's prose. Adding the explicit
+# game subtitle plus generic markers ("the game", "video games") so
+# similar tie-ins classify as noisy and drop out of the evidence map.
+_NOISY_TITLE_MARKERS = (
+    "score", "music", "soundtrack", "video game", "video games",
+    "merchandise", "discography", "operation genesis", "the game",
+)
+_SEQUEL_TITLE_MARKERS = (
+    " iii", " ii)", " ii ", " iv", " v ", " v)", "lost world", "sequel",
+    " 2)", " 3)", " 4)",
+)
+_SECONDARY_TITLE_MARKERS = (
+    "list of", "characters", "franchise", "history of", "people",
+    "timeline of",
+)
+
+
+def _classify_source_role(
+    title: str | None,
+    qtokens_stem: set[str],
+    *,
+    document_uri: str | None = None,
+) -> str:
+    """Tag a source by its likely role for an N-token query.
+
+    URI-scheme classification fires first: documents whose URI starts
+    with ``aborist://providence/`` are self-promoted providence
+    records (per ``aborist/sources/providence.py``) and classify as
+    ``self_reference_source`` regardless of title shape — that role
+    captures the trust model "STRICT-as-fact unless verifier
+    falsifies."
+
+    Order matters for the title-based fallback: noisy/sequel/secondary
+    markers fire first because they catch peripheral pages whose
+    titles otherwise overlap query tokens fully (e.g. `Jurassic Park
+    (film score)` shares 3 stems with `{dinosaur, jurassic, park,
+    film}` but is not the primary answer source for a dinosaurs
+    question). Primary requires the strongest title coverage (N-1
+    of N stems present).
+    """
+    if document_uri and document_uri.startswith("aborist://providence/"):
+        return "self_reference_source"
+    if not title:
+        return "unclassified"
+    t = title.lower()
+    if any(k in t for k in _NOISY_TITLE_MARKERS):
+        return "noisy_background_source"
+    if any(k in t for k in _SEQUEL_TITLE_MARKERS):
+        return "sequel_background_source"
+    if any(k in t for k in _SECONDARY_TITLE_MARKERS):
+        return "secondary_context_source"
+    title_tokens = _title_query_tokens(t.replace("_", " "))
+    title_stems = {_stem_token_for_match(tok) for tok in title_tokens}
+    if qtokens_stem and len(title_stems & qtokens_stem) >= max(1, len(qtokens_stem) - 1):
+        return "primary_answer_source"
+    return "background_source"
+
+
+def _search_titles(conn, qtokens: list[str], limit: int) -> list[tuple]:
+    """SQL LIKE over documents.title — finds the HTTP article that FTS5
+    misses because list-pages with many URLs have higher 'http' term
+    frequency than the actual protocol article. Returns rows shaped
+    to match the FTS5 hit tuple.
+
+    Bug fix 2026-05-02 (fox case: ``"what date did back to the
+    future come out?"``): the previous SQL had no ``ORDER BY``, so
+    SQLite returned rows in arbitrary internal order and ``LIMIT``
+    truncated before the actual title-token-overlap winners landed.
+    The film article ``"Back to the Future"`` (title contains
+    ``back`` AND ``future``) lost the LIMIT race to substring-match
+    junk like ``"Out (poker)"`` (matches ``out`` substring),
+    ``"Aberdeen, South Dakota"`` (``south`` contains ``out``),
+    ``"Backplane"`` (``back`` substring), etc.
+
+    Fix: ``ORDER BY LENGTH(title) ASC`` (shorter titles win the
+    LIMIT race; title-purity proxy) and bump LIMIT to ``limit * 4``
+    so the post-filter (word-boundary stem-aware token-set
+    intersect) sees enough candidates to surface genuine matches
+    even on long-question OR'd LIKE queries.
+
+    The earlier 2026-05-02 v1 of this fix used a per-row
+    ``CASE WHEN ... THEN 1 ELSE 0 END + ...`` token-hit score in
+    SQL, which broke on long questions (fox 2026-05-02:
+    18-content-token query → SQLite "Expression tree is too large
+    (maximum depth 1000)" — each ``CASE WHEN`` is multiple
+    expression-tree nodes; ``+``-chained N times exceeded the
+    bound). The simplified shape is universal: short queries see
+    a tight LIMIT, long queries see ``limit * 4`` candidates the
+    caller's post-filter ranks via stem-aware token intersection.
+    Title-length-asc keeps the LIMIT race biased toward
+    title-pure matches.
+
+    Also: cap the OR-chain at MAX_TITLE_LIKE_TOKENS to bound the
+    expression-tree depth. Beyond ~24 tokens the post-filter is
+    doing all the work anyway; extra LIKEs just inflate the
+    candidate set without adding signal.
+    """
+    if not qtokens:
+        return []
+    over_fetch_limit = limit * 4
+
+    # Try FTS5 documents_fts first — O(K) hash lookup vs the un-indexable
+    # LOWER(title) LIKE '%tok%' that this function used through 2026-
+    # 05-02. The MATCH expression OR-joins the input tokens (quoted to
+    # neutralize FTS5 syntax). Empirical: ~0.05s/shard regardless of
+    # token count, vs. 10-15s for the LIKE form on the 870k-doc shard.
+    # Falls back to LIKE only on shards whose documents_fts isn't
+    # populated yet (e.g. legacy ingest before this index landed).
+    bounded = list(qtokens)[:24]
+    has_fts = conn.execute(
+        "SELECT 1 FROM documents_fts WHERE rowid = (SELECT MIN(rowid) FROM documents_fts) LIMIT 1"
+    ).fetchone()
+    if has_fts:
+        # Quote each token & OR-join. FTS5's tokenizer applies the same
+        # porter stemming we use elsewhere, so 'permacomputer' / 'permac'
+        # match coherently.
+        match_expr = " OR ".join(f'"{t.lower().replace(chr(34), chr(34) * 2)}"' for t in bounded)
+        try:
+            rows = conn.execute(
+                "SELECT d.document_root, d.document_uri, d.title "
+                "FROM documents_fts AS f "
+                "JOIN documents AS d ON d.rowid = f.rowid "
+                "WHERE documents_fts MATCH ? "
+                "ORDER BY LENGTH(d.title) ASC LIMIT ?",
+                (match_expr, over_fetch_limit),
+            ).fetchall()
+            return rows
+        except Exception:
+            # Malformed MATCH (rare; tokenizer-strange chars survived
+            # the quote escape). Fall through to LIKE form.
+            pass
+
+    # LIKE fallback — kept for shards lacking documents_fts data.
+    # Capped at 24 tokens so the OR-chain expression-tree stays under
+    # SQLite's depth-1000 limit on long queries.
+    clauses = " OR ".join(["LOWER(title) LIKE ?"] * len(bounded))
+    likes = [f"%{t.lower()}%" for t in bounded]
+    params = likes + [over_fetch_limit]
+    rows = conn.execute(
+        f"SELECT document_root, document_uri, title FROM documents "
+        f"WHERE {clauses} "
+        f"ORDER BY LENGTH(title) ASC LIMIT ?",
+        params,
+    ).fetchall()
+    return rows
+
+
+def _question_phrases(question: str, *, n: int = 4) -> list[str]:
+    """Extract verbatim n-token sliding-window phrases from the question.
+
+    Used by the phrase-pattern retrieval route to catch allusions /
+    idioms / fictional-world references whose diagnostic signal is
+    the EXACT sequence including function words. Stopword stripping
+    would kill this:
+
+        "always been at war"   — diagnostic Orwell signal
+        "always war"           — generic, useless
+
+    So we DON'T strip stopwords here. Skip phrases whose tokens are
+    all ≤ 3 chars (pure boilerplate, no diagnostic value). Output
+    is lowercase, deduped, in source order. Default ``n=4`` is the
+    sweet spot empirically: 3-grams are too noisy ("the cat in"
+    matches loads of things), 5-grams miss shorter idioms ("winter
+    is coming" → 3 tokens).
+    """
+    import re as _re
+    tokens = _re.findall(r"[A-Za-z][A-Za-z0-9]+", question)
+    if len(tokens) < n:
+        return []
+    out: list[str] = []
+    seen: set[str] = set()
+    for i in range(len(tokens) - n + 1):
+        window = tokens[i:i + n]
+        if max(len(t) for t in window) < 4:
+            continue  # all-short-tokens → boilerplate
+        phrase = " ".join(t.lower() for t in window)
+        if phrase in seen:
+            continue
+        seen.add(phrase)
+        out.append(phrase)
+    return out
+
+
+def _search_phrases(conn, phrases: list[str], limit: int) -> list[tuple]:
+    """FTS5 phrase-match search across chunk bodies.
+
+    Each phrase becomes an FTS5 quoted-phrase token (``'"phrase"'``);
+    we OR the phrases so any verbatim match wins. Returns sqlite3.Row
+    objects with columns ``document_root, idx, document_uri, title``
+    matching the rest of the search-route surface.
+
+    Why this exists: AND-mode FTS5 token-matching (the default body
+    search route) treats query tokens independently — a doc must
+    contain every token but the tokens can be anywhere. For
+    allusion-shape queries the diagnostic signal is the verbatim
+    sequence:
+
+        Q = "has oceania always been at war with east asia"
+        body BM25 surfaces literal-geography articles (Oceania, Asia,
+        Far East) because they have the most occurrences of "Oceania"
+        + "Asia" + "war" individually.
+        phrase MATCH '"always been at war"' surfaces
+        Nineteen_Eighty-Four because the phrase is verbatim Orwell.
+
+    The verbatim phrase route doesn't dominate: in ``_search_corpus``
+    its score is 70 (between core-keyword and title-LIKE ranks), and
+    the existing rerank pipeline still gates by title relevance.
+    Phrase matches just get a seat at the table.
+
+    Defensive: silently skip phrases containing double-quotes
+    (adversarial / malformed input). Wraps the FTS5 query in a
+    try/except so a malformed MATCH doesn't crash the search;
+    upstream callers see an empty result set.
+    """
+    if not phrases:
+        return []
+    quoted = [f'"{p}"' for p in phrases if '"' not in p]
+    if not quoted:
+        return []
+    fts_query = " OR ".join(quoted)
+    try:
+        rows = conn.execute(
+            """
+            SELECT
+                c.document_root,
+                c.idx,
+                d.document_uri,
+                d.title
+            FROM chunks_fts AS f
+            JOIN chunks    AS c ON c.chunk_id = f.rowid
+            JOIN documents AS d ON d.document_root = c.document_root
+            WHERE chunks_fts MATCH ?
+            ORDER BY bm25(chunks_fts) ASC
+            LIMIT ?
+            """,
+            (fts_query, limit),
+        ).fetchall()
+    except Exception:  # noqa: BLE001 — search must fail soft
+        rows = []
+    return rows
+
+
+def _docs_with_core_keyword_match(
+    conn, qtokens: list[str], limit: int
+) -> list[tuple]:
+    """Find SOURCE docs whose TF-IDF core keywords contain a query token.
+
+    TF-IDF cores act as enriched titles: a doc's distinctive low-frequency
+    terms get distilled into the core's content as comma-separated keywords.
+    A query for "permacomputer" (a neologism that never makes it into a
+    title) can match the TF-IDF core of a Grok conversation that mentioned
+    it, because permacomputer is a rare term that ranks high under TF-IDF.
+
+    Returns rows of (document_root, document_uri, title) for the SOURCE docs
+    (not the cores) — the source is what gets fed to the LLM as context.
+    """
+    if not qtokens:
+        return []
+    # Word-boundary match against the comma-separated TF-IDF keyword list.
+    # Prepending/appending ", " lets one LIKE pattern (`%, token, %`) check
+    # for the token regardless of its position in the keyword string.
+    # Without this, naive `LIKE '%intel%'` would match "intelligence",
+    # "intellectual", "intellivision" — drowning real hits like Pentium_4
+    # (whose TF-IDF core has "intel" as an exact keyword) in noise.
+    #
+    # Per-row `match_count` tallies how many distinct query tokens hit
+    # this doc's TF-IDF core. Multi-token coverage is a strong relevance
+    # signal — a doc whose core has "intel" + "cpu" + "faster" beats a
+    # doc whose only signal is "intel" appearing in its TITLE. The
+    # caller boosts the score by match_count.
+    case_clauses = " + ".join(
+        [
+            "(CASE WHEN LOWER(', ' || c.content || ', ') LIKE ? THEN 1 ELSE 0 END)"
+        ]
+        * len(qtokens)
+    )
+    where_clauses = " OR ".join(
+        ["LOWER(', ' || c.content || ', ') LIKE ?"] * len(qtokens)
+    )
+    patterns = [f"%, {t.lower()}, %" for t in qtokens]
+    params = patterns + patterns + [limit]
+    rows = conn.execute(
+        f"""
+        SELECT
+            src.document_root,
+            src.document_uri,
+            src.title,
+            MAX({case_clauses}) AS match_count
+        FROM chunks c
+        JOIN documents core ON core.document_root = c.document_root
+        JOIN derivations der ON der.core_root = core.document_root
+        JOIN documents src ON src.document_root = der.src_root
+        WHERE core.source_type LIKE 'core:tfidf-%'
+          AND ({where_clauses})
+        GROUP BY src.document_root, src.document_uri, src.title
+        ORDER BY match_count DESC
+        LIMIT ?
+        """,
+        params,
+    ).fetchall()
+    return rows
+
+
+def _stem_token_for_match(t: str) -> str:
+    """Light suffix-strip for query-token vs body matching.
+
+    Two normalizations:
+        possessive  ``"superman's" -> "supermans" -> "superman"`` (the apostrophe
+                    is already gone via _TITLE_TOKEN_RE; we drop the trailing
+                    ``s`` here so the lookup matches plain ``superman`` in body).
+        plural      ``"powers" -> "power"``, ``"girlfriends" -> "girlfriend"``
+                    so plural questions match singular source mentions.
+
+    Both are the same operation: strip trailing ``s`` for tokens > 4 chars.
+    Conservative on short tokens (``"is"``, ``"as"``, ``"us"`` would lose
+    meaning) and on tokens that don't end in ``s`` (no-op).
+    """
+    if len(t) > 4 and t.endswith("s") and not t.endswith("ss"):
+        return t[:-1]
+    return t
+
+
+def _body_count_with_stem(body: str, t: str) -> int:
+    """Count mentions of ``t`` in ``body``, falling back to the lite-stemmed
+    form if the literal didn't match. Returns the LARGER of the two counts
+    so a query token that appears under both forms (rare) still scores."""
+    n_literal = body.count(t)
+    if n_literal:
+        return n_literal
+    stem = _stem_token_for_match(t)
+    if stem != t:
+        return body.count(stem)
+    return 0
+
+
+def _chunk_query_relevance(
+    span: str, qtokens_stem: set[str]
+) -> tuple[int, int]:
+    """Rank one chunk by query-token overlap. Returns
+    ``(distinct_present, total_mentions)``.
+
+    Stem-aware: same ``_body_count_with_stem`` we use elsewhere, so
+    ``"supermans"`` matches ``"superman"`` in the chunk text. Soft
+    signal — the score never enters the proof path; it only orders
+    chunks within a source so the most-relevant chunk gets the lowest
+    pointer id (and the model's lazy-anchor habit lands on a useful
+    chunk by accident).
+
+    Sort callers should walk ``(-distinct, -total, chunk_idx_asc)``
+    to break ties stably toward document order.
+    """
+    if not qtokens_stem:
+        return (0, 0)
+    body = span.lower()
+    counts = {t: _body_count_with_stem(body, t) for t in qtokens_stem}
+    distinct_present = sum(1 for n in counts.values() if n > 0)
+    total = sum(counts.values())
+    return (distinct_present, total)
+
+
+def _body_density_passes(
+    conn, document_root: str, qtokens: set[str], min_mentions: int = 3
+) -> bool:
+    """Body-token CO-OCCURRENCE relevance.
+
+    Pre-2026-04-27 this counted any token's mentions in the body (so
+    Intel_8086, with 30 "Intel" mentions, would pass for a query of
+    {intel, fastest, cpu} despite never mentioning "fastest"). Pre-2026-
+    04-29 the threshold was "at least HALF" — too lenient for 2-token
+    queries: ``"supermans girlfriend"`` admitted ``Girlfriends`` (TV
+    show) which had ``girlfriend`` in body but no ``superman`` whatsoever.
+
+    Current rules — breadth scales with query length:
+
+        ≤ 2 tokens   require ALL of them present in body
+        3+ tokens    require N - 1 (allow one weak signal token to miss)
+
+    Plus depth (``total_mentions >= min_mentions``) is still enforced.
+
+    Token matching is stem-tolerant (``_stem_token_for_match``): so a query
+    token ``"supermans"`` matches body ``"superman"`` and ``"girlfriends"``
+    matches body ``"girlfriend"``.
+    """
+    if not qtokens:
+        return False
+    rows = conn.execute(
+        "SELECT content FROM chunks WHERE document_root = ? AND content IS NOT NULL",
+        (document_root,),
+    ).fetchall()
+    if not rows:
+        return False
+    body = " ".join(unpack_chunk(r["content"]) or "" for r in rows).lower()
+    counts = {t: _body_count_with_stem(body, t.lower()) for t in qtokens}
+    distinct_present = sum(1 for n in counts.values() if n > 0)
+    total_mentions = sum(counts.values())
+    if len(qtokens) <= 2:
+        breadth_threshold = len(qtokens)
+    else:
+        breadth_threshold = len(qtokens) - 1
+    return distinct_present >= breadth_threshold and total_mentions >= min_mentions
+
+
+def _search_corpus(
+    shards_dir: Path | None,
+    single_db: Path | None,
+    question: str,
+    over_fetch: int,
+) -> list[_Hit]:
+    """Two parallel searches across shards, merged:
+
+    - FTS5 body search (BM25 over chunk content).
+    - SQL title-LIKE search (catches articles whose body is short on the
+      query terms but whose title is the literal topic — e.g., the HTTP
+      protocol article doesn't out-frequency-score URL-heavy list pages
+      but it IS the topic).
+
+    Dedupe by document_root. Title hits get a baseline score that
+    out-ranks FTS5 body hits so the actual topic article rises to the top.
+    """
+    qtokens = _title_query_tokens(question)
+    # Title-LIKE backup uses ORIGINAL qtokens only (LIKE %tok% can't
+    # use any index — adding synonyms makes it O(corpus × |accept|)).
+    # Synonym expansion stays useful in two places: (1) the FTS5
+    # OR-mode fallback (top-5 longest pool merged with synonyms — long
+    # topical synonyms like "neurotechnology" surface relevant titles
+    # without paying for full-scan), and (2) `_filter_by_title_relevance`
+    # post-retrieval filtering (in-memory, cheap).
+    accept_tokens = set(qtokens)
+    or_synonym_pool = synonym_expand(qtokens, shards_dir=shards_dir)
+    paths: list[Path]
+    if shards_dir is not None:
+        paths = discover_shards(shards_dir)
+    elif single_db is not None:
+        paths = [Path(single_db)]
+    else:
+        paths = []
+
+    raw: list[tuple] = []
+    # Roots whose TF-IDF cores contain a query token — collected across
+    # shards. Used downstream by _filter_by_title_relevance as accept-path 2.
+    core_match_roots: set[str] = set()
+    # Roots that matched a verbatim 4+ token phrase from the question.
+    # Used downstream by _filter_by_title_relevance as accept-path 4 so
+    # an allusion-shape hit (e.g. Nineteen_Eighty-Four for "always been
+    # at war") survives the title-token-overlap filter even when its
+    # title shares no tokens with the question.
+    phrase_match_roots: set[str] = set()
+    # Per-shard mapping of doc root -> shard path, for body-density lookups
+    # in accept-path 3. Lets us reach back to the source shard cheaply.
+    root_to_shard: dict[str, str] = {}
+
+    for p in paths:
+        conn = connect(p)
+        try:
+            backend = FTS5Backend(conn)
+            for h in backend.search(
+                question, limit=over_fetch, extra_or_tokens=or_synonym_pool
+            ):
+                raw.append(
+                    (
+                        h.score,
+                        h.document_root,
+                        h.document_uri,
+                        h.title,
+                        h.chunk_idx,
+                        str(p.resolve()),
+                    )
+                )
+                root_to_shard[h.document_root] = str(p.resolve())
+            # Parallel title search using synonym-expanded tokens.
+            # The score starts deliberately low so this signal can't drown
+            # FTS5 BM25 + body relevance. _rerank_by_title later adds
+            # `overlap*10` to every hit (FTS5 and title-search alike) that
+            # has title-token overlap, so a doc whose title genuinely IS
+            # the topic ends up rewarded twice (once here, once in rerank).
+            # Single-token title matches against generic terms ("intel",
+            # "cpu") used to score 60+ standalone, dominating top-K with
+            # legacy 80486-era articles for queries like "fastest intel
+            # CPU?". Now that contribution is the same scale as FTS5 body
+            # BM25, which lets Pentium_4 (high body relevance, no title
+            # overlap) win on its actual topical fit.
+            # Word-boundary overlap check (was substring-match,
+            # which falsely passed `"out" in "south"`,
+            # `"date" in "candidate"`, `"come" in "outcome"`, etc.).
+            # 2026-05-02 case fox surfaced: "what date did back to
+            # the future come out?" — the substring check admitted
+            # "Aberdeen, South Dakota" (south contains "out"),
+            # "Backplane" (back), "Outline of biology" (out), and
+            # consumed the over_fetch budget so the legitimate
+            # `Back to the Future` film article never made the
+            # rerank cut. Tokenizing the title via
+            # `_title_query_tokens` + stem-aware comparison keeps
+            # the title-LIKE pass returning only docs whose title
+            # has an actual matching word.
+            accept_stems = {
+                _stem_token_for_match(t) for t in accept_tokens
+            }
+            # Title search now uses documents_fts FTS5 index (~0.05s/shard
+            # regardless of token count) — the prior >5-token bypass
+            # existed because LIKE '%tok%' was O(corpus × |tokens|).
+            # FTS5 MATCH makes this an O(K) hash lookup, so synonym-
+            # expanded title search is affordable at any query length.
+            title_search_rows = _search_titles(
+                conn, list(accept_tokens), over_fetch
+            )
+            for r in title_search_rows:
+                title_norm = (r["title"] or "").replace("_", " ")
+                title_stems = {
+                    _stem_token_for_match(t)
+                    for t in _title_query_tokens(title_norm)
+                }
+                overlap = len(accept_stems & title_stems)
+                if overlap == 0:
+                    continue
+                title_score = overlap * 10.0
+                raw.append(
+                    (
+                        title_score,
+                        r["document_root"],
+                        r["document_uri"],
+                        r["title"],
+                        0,
+                        str(p.resolve()),
+                    )
+                )
+                root_to_shard[r["document_root"]] = str(p.resolve())
+            # Phrase-pattern search — verbatim multi-token sequences
+            # from the question. Catches allusions / idioms / fictional-
+            # world references whose diagnostic signal is the exact
+            # sequence including function words. Two passes for layered
+            # specificity:
+            #   - n=6 (highest specificity): "oceania always been at
+            #     war with" is essentially unique to Orwell. Score 100.
+            #   - n=5 (high specificity): "oceania always been at war"
+            #     still strongly Orwell-anchored. Score 90.
+            # 4-grams were tried (2026-05-01) and dropped: "always been
+            # at war" matches generic war-history articles too often,
+            # creating retrieval noise that the rerank pipeline can't
+            # cleanly separate from the actual allusion. Empirically
+            # 5+ grams trade recall for precision — most allusions
+            # ("may the force be with you", "to be or not to be",
+            # "winter is coming") survive at length 5 or 3-with-light-
+            # tokens, but the 4-gram floor is where diagnostic-ness
+            # collapses.
+            for n in (6, 5):
+                phrase_score = 100.0 if n == 6 else 90.0
+                for r in _search_phrases(
+                    conn, _question_phrases(question, n=n), over_fetch
+                ):
+                    raw.append(
+                        (
+                            phrase_score,
+                            r["document_root"],
+                            r["document_uri"],
+                            r["title"],
+                            r["idx"],
+                            str(p.resolve()),
+                        )
+                    )
+                    root_to_shard[r["document_root"]] = str(p.resolve())
+                    phrase_match_roots.add(r["document_root"])
+            # Core-keyword search: docs whose TF-IDF core keywords match.
+            # The query token doesn't need to be in title or even in body —
+            # being a TF-IDF keyword of the doc's core is enough signal.
+            for r in _docs_with_core_keyword_match(
+                conn, list(accept_tokens), over_fetch
+            ):
+                core_match_roots.add(r["document_root"])
+                # Score scales with how many query tokens hit this doc's
+                # TF-IDF core. A 3-token coverage (e.g. Pentium_4's core
+                # carries "intel", "cpu", "faster" for the query "fastest
+                # intel CPU?") beats single-token title boosts (~80) that
+                # otherwise saturate the top with Intel_80486DX,
+                # Intel_8086, etc. — articles that share *one* word with
+                # the query but aren't the topical answer.
+                match_count = r["match_count"] or 1
+                kw_score = 40.0 + 25.0 * match_count
+                raw.append(
+                    (
+                        kw_score,
+                        r["document_root"],
+                        r["document_uri"],
+                        r["title"],
+                        0,
+                        str(p.resolve()),
+                    )
+                )
+                root_to_shard[r["document_root"]] = str(p.resolve())
+        finally:
+            conn.close()
+
+    raw.sort(key=lambda r: -r[0])
+    seen: set[str] = set()
+    out: list[_Hit] = []
+    for score, root, uri, title, idx, sp in raw:
+        if root in seen:
+            continue
+        seen.add(root)
+        out.append(
+            _Hit(
+                document_root=root,
+                document_uri=uri,
+                title=title,
+                score=score,
+                shard_path=sp,
+                chunk_idx=idx,
+            )
+        )
+    # Sidecar sets returned alongside the hit list. Pre-2026-05-01
+    # the function returned a bare list and the caller used
+    # `getattr(hits, "_core_match_roots", set())` to fish out the
+    # sets — but the sidecar was never attached, so the
+    # `core_match_roots` accept-path in _filter_by_title_relevance
+    # silently received an empty set. Returning a tuple corrects
+    # the plumbing AND threads the new `phrase_match_roots` for
+    # accept-path 4.
+    return out, core_match_roots, phrase_match_roots, root_to_shard
+
+
+def _rerank(
+    hits: list[_Hit],
+    question: str,
+    *,
+    core_match_roots: set[str] | None = None,
+    body_density_check: callable | None = None,
+    phrase_match_roots: set[str] | None = None,
+    hyphen_fold_anchors: set[str] | None = None,
+    shards_dir=None,
+) -> list[_Hit]:
+    """Filter off-topic, then layer in body-coverage, title-overlap, and
+    source-role rank boosts.
+
+    Order matters: filter first (drops noise), body-coverage rerank next
+    (counters BM25's short-doc bias by rewarding topical density across
+    distinct query tokens), title-overlap rerank (breaks ties when a doc
+    IS the named topic), source-role rank-boost last so a primary
+    answer source outranks a list-page even when the list-page won on
+    BM25 + title-overlap (caught the "where is florida" defect:
+    ``List_of_places_in_Florida`` and ``List_of_State_Roads_in_Florida``
+    each contain "Florida" hundreds of times in row markup, scoring
+    above the actual ``Florida`` article on body density).
+    """
+    hits = _filter_by_title_relevance(
+        hits,
+        question,
+        core_match_roots=core_match_roots,
+        body_density_check=body_density_check,
+        phrase_match_roots=phrase_match_roots,
+        hyphen_fold_anchors=hyphen_fold_anchors,
+        shards_dir=shards_dir,
+    )
+    hits = _rerank_by_body_coverage(hits, question)
+    hits = _rerank_by_title(hits, question)
+    hits = _rerank_by_source_role(hits, question)
+    hits = _rerank_by_title_purity(hits, question)
+    return _rerank_by_ordered_token_match(hits, question)
+
+
+# Per-role rank multiplier. Affects sort order, NOT just per-source
+# context cap (the latter is SOURCE_ROLE_BUDGET_WEIGHTS, applied later).
+# Defaults skew strongly toward primary so a real topic article beats
+# list-pages and franchise/sequel siblings even when the list-page wins
+# on body-density. Tuned against the JP-dinosaurs and "where is florida"
+# defects.
+SOURCE_ROLE_RANK_WEIGHTS = {
+    "primary_answer_source": 2.0,
+    "secondary_context_source": 0.7,
+    "background_source": 0.9,
+    "noisy_background_source": 0.3,
+    "sequel_background_source": 0.3,
+    "unclassified": 1.0,
+    # Self-reference: STRICT live providence records past kindergarten
+    # window. Treated like background — Wikipedia stays canonical
+    # primary; self-reference is supplementary anchoring trusted as
+    # fact unless the verifier falsifies the underlying record.
+    "self_reference_source": 0.9,
+}
+
+
+def _rerank_by_source_role(hits: list[_Hit], question: str) -> list[_Hit]:
+    """Classify each hit by source role and rescale score by role weight.
+
+    Mutates ``h.source_role`` so the classification happens once and
+    downstream context-build code can reuse the value (instead of
+    re-classifying at cap time). Stable sort by score desc.
+    """
+    qtokens_stem = {
+        _stem_token_for_match(t.lower())
+        for t in _title_query_tokens(question)
+    }
+    for h in hits:
+        h.source_role = _classify_source_role(
+            h.title, qtokens_stem, document_uri=h.document_uri
+        )
+        weight = SOURCE_ROLE_RANK_WEIGHTS.get(h.source_role, 1.0)
+        h.score = h.score * weight
+    hits.sort(key=lambda h: -h.score)
+    return hits
+
+
+def _rerank_by_title_purity(hits: list[_Hit], question: str) -> list[_Hit]:
+    """Boost titles by both purity AND multi-token-match breadth.
+
+    Two signals combine here:
+
+    - **Purity** = ``|title_tokens ∩ query_tokens| / |title_tokens|``.
+      Rewards titles that ARE the topic without off-topic suffix tokens.
+      ``Jurassic Park (film)`` (purity 1.0) beats ``Jurassic Park
+      (NES game)`` (purity 0.5).
+    - **Overlap count** = ``|title_tokens ∩ query_tokens|``. Rewards
+      titles that match more of the query's content tokens. For a
+      query ``{dawson, creek}``: ``List of Dawson's Creek episodes``
+      (overlap 2) beats ``Clinton Creek, Yukon`` (overlap 1) even
+      when both have similar purity.
+
+    Multiplier: ``(1 + overlap_count) * (1 + purity)``:
+
+        overlap=2, purity=0.5 (e.g. ``Dawson's Creek episodes``)  → 4.5×
+        overlap=1, purity=1.0 (bare-token-title match)            → 4.0×
+        overlap=2, purity=0.4 (e.g. ``List of ... Dawson Creek``) → 4.2×
+        overlap=1, purity=0.5 (e.g. ``Dawson Leery``)             → 3.0×
+        overlap=1, purity=0.33 (e.g. ``Clinton Creek, Yukon``)    → 2.67×
+        overlap=0                                                 → 1.0× (no change)
+
+    Pre-2026-05-01 the multiplier was ``1 + 2 * purity`` — purity
+    only, indifferent to overlap-count. That let ``Clinton Creek,
+    Yukon`` (purity 0.33 → 1.67× boost) outrank ``List of Dawson's
+    Creek episodes`` (purity 0.5 → 2.0×) on a query like "in
+    dawsons creek who is the girl across the creek?" once BM25's
+    short-title bias is folded in. The 2-token-match should beat
+    the 1-token-match cleanly.
+
+    Original use case (JP-dinosaurs lazy-anchor) still served:
+    ``Jurassic Park (film)`` (overlap 2, purity 1.0) → 6.0× sits
+    well above ``Jurassic Park (NES game)`` (overlap 2, purity 0.5)
+    → 4.5×, and far above ``Jurassic Park (franchise)`` (overlap 2,
+    purity 0.67) → 5.0×.
+    """
+    qtokens = _title_query_tokens(question)
+    if not qtokens:
+        return hits
+    # Stem-aware matching so possessive / plural variants match. The
+    # 2026-05-01 Dawson's Creek defect: question "dawsons creek" with
+    # title "List of Dawson's Creek episodes" — raw set intersection
+    # treated `dawsons` and `dawson` as distinct → overlap=1 (only
+    # `creek`) and the multi-token title bonus didn't fire. Stemming
+    # both sides via `_stem_token_for_match` (trailing-s strip on
+    # tokens >4 chars, skipping ss-enders) collapses both forms onto
+    # `dawson`, the overlap goes to 2, and the title beats single-
+    # token ``Clinton Creek, Yukon`` matches.
+    qstems = {_stem_token_for_match(t) for t in qtokens}
+    for h in hits:
+        if not h.title:
+            continue
+        ttokens = _title_query_tokens(h.title.replace("_", " "))
+        if not ttokens:
+            continue
+        tstems = {_stem_token_for_match(t) for t in ttokens}
+        overlap = tstems & qstems
+        if not overlap:
+            continue
+        purity = len(overlap) / len(tstems)
+        overlap_count = len(overlap)
+        h.score = h.score * (1.0 + overlap_count) * (1.0 + purity)
+    hits.sort(key=lambda h: -h.score)
+    return hits
+
+
+def _ordered_match_length(query_tokens: list[str], title_tokens: list[str]) -> int:
+    """Longest contiguous-or-subsequence match of query tokens (in
+    query order) inside title tokens (in title order).
+
+    Implementation: longest common subsequence over the two stem-
+    aware token lists. Returns the LCS length. The function is
+    O(N*M) where N = len(query_tokens), M = len(title_tokens). Both
+    are typically <10 in practice (titles short, query content
+    tokens few), so the cost is negligible.
+
+    Examples (query "red fish blue fish"):
+        title "Red Fish Blue Fish"          → 4 (all four in order)
+        title "One Fish Two Fish Red Fish"  → 4 (red-fish-blue? — no
+                                                 'blue' in title, so 3
+                                                 actually: red-fish-fish...
+                                                 LCS counts longest common
+                                                 SUBSEQUENCE not contiguous)
+        title "Red Dwarf"                   → 1 (red only)
+        title "Toronto Blue Jays"           → 1 (blue only)
+        title "Blue Velvet (film)"          → 1 (blue only)
+    """
+    if not query_tokens or not title_tokens:
+        return 0
+    n = len(query_tokens)
+    m = len(title_tokens)
+    # 1D rolling dp
+    prev = [0] * (m + 1)
+    for i in range(1, n + 1):
+        cur = [0] * (m + 1)
+        for j in range(1, m + 1):
+            if query_tokens[i - 1] == title_tokens[j - 1]:
+                cur[j] = prev[j - 1] + 1
+            else:
+                cur[j] = max(cur[j - 1], prev[j])
+        prev = cur
+    return prev[m]
+
+
+def _rerank_by_ordered_token_match(hits: list[_Hit], question: str) -> list[_Hit]:
+    """Boost titles whose tokens appear in the same order as the query.
+
+    Multi-token queries like "red fish blue fish" should rank a
+    "Red Fish Blue Fish"-shaped title above a "Red Dwarf"-shaped
+    one, even when both pass the existing title-purity check. The
+    ordered-token-match length distinguishes them: 4 vs 1.
+
+    Multiplier: ``1 + 0.5 * (match_length - 1)`` for match_length ≥ 2.
+    Single-token matches get no boost (already covered by purity).
+
+        match_length 1 → 1.0× (no change; single-token rewarded by purity)
+        match_length 2 → 1.5×
+        match_length 3 → 2.0×
+        match_length 4 → 2.5×
+
+    Stem-aware (uses ``_stem_token_for_match``) so possessive /
+    plural / "dawsons" vs "dawson" variants collapse onto the same
+    stem before LCS.
+
+    Caught the 2026-05-01 "plot of red fish blue fish?" defect:
+    pre-fix, body BM25 + title purity tied across multiple
+    color/animal-titled docs; with the ordered-match boost, the
+    Dr. Seuss book's title (purity 1.0, ordered-match 4) sits
+    cleanly above Red Dwarf, Toronto Blue Jays, etc. (purity 0.5,
+    ordered-match 1).
+    """
+    qtokens = _title_query_tokens(question)
+    if len(qtokens) < 2:
+        # No order to match on a single-token query.
+        return hits
+    # Preserve query token ORDER (not the set ordering from
+    # _title_query_tokens which de-dupes via set comprehension).
+    # Walk the question text, applying the same regex + filter, so
+    # the resulting list reflects natural reading order.
+    qtokens_ordered: list[str] = []
+    seen: set[str] = set()
+    for tok in _TITLE_TOKEN_RE.findall(question):
+        t = tok.lower()
+        if (
+            t in _TITLE_STOPWORDS
+            or len(t) <= 1
+            or t in seen
+        ):
+            continue
+        seen.add(t)
+        qtokens_ordered.append(_stem_token_for_match(t))
+    if len(qtokens_ordered) < 2:
+        return hits
+    for h in hits:
+        if not h.title:
+            continue
+        # Title tokens in TITLE order, deduped on first occurrence.
+        ttokens_ordered: list[str] = []
+        title_seen: set[str] = set()
+        for tok in _TITLE_TOKEN_RE.findall(h.title.replace("_", " ")):
+            t = tok.lower()
+            if (
+                t in _TITLE_STOPWORDS
+                or len(t) <= 1
+                or t in title_seen
+            ):
+                continue
+            title_seen.add(t)
+            ttokens_ordered.append(_stem_token_for_match(t))
+        if not ttokens_ordered:
+            continue
+        match_len = _ordered_match_length(qtokens_ordered, ttokens_ordered)
+        if match_len >= 2:
+            h.score = h.score * (1.0 + 0.5 * (match_len - 1))
+    hits.sort(key=lambda h: -h.score)
+    return hits
+
+
+def _rerank_by_body_coverage(
+    hits: list,
+    question: str,
+    *,
+    weight: float = 0.6,
+) -> list:
+    """Boost score by per-token body coverage to counteract BM25's short-doc bias.
+
+    BM25 normalizes by document length, but its `b` parameter under-penalizes
+    *very* short docs that happen to mention every query token. Result: a
+    1-paragraph stub on Intel 4040 (1974 microcontroller) outscored a 30-page
+    Intel Core i7 article for "what is the fastest intel CPU?".
+
+    Approach: for each hit's full body, sum sqrt(count) per query token.
+    Sqrt scaling lets long topical articles meaningfully out-score short
+    tangential ones without runaway domination by enumerative list pages.
+
+      stub article: 5 intel + 1 fastest + 4 cpu  ->  sqrt(5)+sqrt(1)+sqrt(4) ~  5.2
+      long topical: 200 + 10 + 80               ->  sqrt(200)+sqrt(10)+sqrt(80) ~  26.2
+      enumeration: 1000 + 50 + 300              ->  sqrt(1000)+sqrt(50)+sqrt(300) ~  55.8
+
+    Multiply by `weight` (default 0.6) and add to the existing score. The
+    differentiation in the example above is decisive but bounded: long topical
+    articles get +15-16, enumerations get +33, stubs get +3. Combined with
+    FTS5 base scores in the 40s, the long topical article wins comfortably.
+
+    Cost: one body fetch per surviving candidate (typically 24-32). Bounded.
+    """
+    import math
+
+    qtokens_lower = {t.lower() for t in _title_query_tokens(question)}
+    if not qtokens_lower:
+        return hits
+    for h in hits:
+        body = _load_doc_text(h.shard_path, h.document_root)
+        if body is None:
+            continue
+        body_lower = body.lower()
+        coverage = sum(
+            math.sqrt(body_lower.count(t)) for t in qtokens_lower
+        )
+        h.score += coverage * weight
+    hits.sort(key=lambda h: -h.score)
+    return hits
+
+
+def _load_doc_text(shard_path: str, document_root: str) -> str | None:
+    """Concatenate all hot chunks of a document. Returns None if cold or missing."""
+    conn = connect(shard_path)
+    try:
+        rows = conn.execute(
+            "SELECT content FROM chunks "
+            "WHERE document_root = ? AND content IS NOT NULL "
+            "ORDER BY idx ASC",
+            (document_root,),
+        ).fetchall()
+    finally:
+        conn.close()
+    if not rows:
+        return None
+    return "\n\n".join(unpack_chunk(r["content"]) or "" for r in rows)
+
+
+def _load_doc_chunks(
+    shard_path: str, document_root: str
+) -> list[tuple[int, str, str]] | None:
+    """Per-chunk hot rows of a document.
+
+    Returns ``[(chunk_idx, leaf_hash, span), ...]`` in chunk order, or
+    ``None`` if the document is cold or absent. Cold individual chunks
+    are skipped (the WHERE clause filters NULL content). Used by the
+    claim-lattice-pointer evidence-map builder to emit one
+    ``EvidenceObject`` per chunk so the model can cite the specific
+    paragraph that supports a claim instead of lazy-anchoring the
+    whole article.
+    """
+    conn = connect(shard_path)
+    try:
+        rows = conn.execute(
+            "SELECT idx, leaf_hash, content FROM chunks "
+            "WHERE document_root = ? AND content IS NOT NULL "
+            "ORDER BY idx ASC",
+            (document_root,),
+        ).fetchall()
+    finally:
+        conn.close()
+    if not rows:
+        return None
+    out: list[tuple[int, str, str]] = []
+    for r in rows:
+        span = unpack_chunk(r["content"]) or ""
+        if not span:
+            continue
+        out.append((r["idx"], r["leaf_hash"], span))
+    return out or None
+
+
+def _extract_preflight_hash_from_blob(blob: str | None) -> str | None:
+    """Pull the ``preflight`` stage hash out of a persisted
+    ``run_dag_blob`` (Ticket #000009 §7.2). Returns ``None`` when
+    the blob is absent / unparseable / lacks a preflight stage —
+    legacy rows written before #000009 fall through this path
+    cleanly without raising.
+    """
+    if not blob:
+        return None
+    try:
+        parsed = json.loads(blob) if isinstance(blob, str) else blob
+    except (json.JSONDecodeError, TypeError):
+        return None
+    if not isinstance(parsed, dict):
+        return None
+    for node in parsed.get("nodes") or []:
+        if isinstance(node, dict) and node.get("stage") == "preflight":
+            return node.get("hash")
+    return None
+
+
+def _context_root(source_roots: list[str]) -> str:
+    """Merkle root over sorted source document_roots — the v9.8 'source' dim
+    for multi-source answers. Sorting makes the root deterministic regardless
+    of search ranking order."""
+    if not source_roots:
+        return "00" * 32
+    sorted_roots = sorted(source_roots)
+    if len(sorted_roots) == 1:
+        return sorted_roots[0]
+    leaves = [bytes.fromhex(r) for r in sorted_roots]
+    return MerkleTree.build(leaves).root.hex()
+
+
+
+[docs] +def query( + *, + question: str, + qa_db: Path, + chat_client: ChatClient, + model_id: str, + revision: str = "", + quantization: str = "", + shards_dir: Path | None = None, + single_db: Path | None = None, + top_k: int = 8, + over_fetch: int = 32, + max_context_chars: int | None = None, + policy: dict | None = None, + chain: str = "private", + fidelity: str | None = None, + burn_existing: bool = False, + retrieval_keywords: str | None = None, +) -> dict: + """Answer `question` using the corpus. Cache to qa_db. Returns a result dict. + + `fidelity` controls lookup tolerance — see ``FIDELITY_MODES`` in + ``aborist.qa.keys``. ``"strict"`` checks only the cache_key + matching this call's ``policy["question_dedup"]``. ``"equivalence_class"`` + (default) tries the primary cache_key first, then the alternate + dedup-mode cache_key as a fallback so a fast-cache agent can reuse + a record written under either mode. Result includes ``lookup_path`` + naming which key matched (or ``"miss"`` when the LLM ran). + + `burn_existing=True` deletes the matching live providence_cache row + (under the primary dedup-mode cache_key) BEFORE the cache lookup, + forcing a fresh inference. Each burn writes a ``providence_burn`` + audit event. Test-ergonomic: run `make query Q=... BURN=1` after + tweaking a knob to see the new behavior without finding cache_keys + by hand. Result includes ``burned_existing`` reporting how many + rows were deleted (0 or 1 for the primary key; the equivalence- + class fallback key is left alone so prior alt-mode records stay + historic). + + `retrieval_keywords` augments the FTS5 search and title-filter + token set with operator-supplied keywords WITHOUT changing what + the LLM sees as its question or what the verifier checks. + Empirically observed 2026-05-01: long discursive questions like + 'what technology is currently or soon available which may enable + one person to reconstruct another person's thoughts...' under- + retrieve because their content tokens get diluted by template + phrasing. Appending domain keywords ('transcranial knowledge + acquisition') narrows OR-mode FTS5 to the topical article + (Neurotechnology) and lifts the verdict from HYBRID to STRICT. + + Keywords do NOT enter ``question_hash`` directly, but they DO + change which sources get chosen — and that re-routes the + ``context_root`` and ``conversation_hash`` components of + ``cache_key``. Two calls with the same question and different + keywords therefore land under different cache_keys (different + contexts, different cached records — correctly so). Pair with + ``burn_existing=True`` to force fresh inference when iterating + on keyword sets. + """ + policy = policy or DEFAULT_QUERY_POLICY + if fidelity is None: + fidelity = policy.get("fidelity", DEFAULT_FIDELITY) + if fidelity not in FIDELITY_MODES: + raise ValueError( + f"fidelity must be one of {FIDELITY_MODES}, got {fidelity!r}" + ) + answer_mode = policy.get("answer_mode", DEFAULT_ANSWER_MODE) + if answer_mode not in ANSWER_MODES: + raise ValueError( + f"policy['answer_mode'] must be one of {ANSWER_MODES}, got {answer_mode!r}" + ) + # Resolve per-mode context budget when the caller didn't pass one + # explicitly. Sprint 1b (2026-05-02) — different answer modes peak + # at different budgets; quote/pointer at 24 KB, JSON at 48 KB. + # Explicit caller value always wins (backward-compatible). + if max_context_chars is None: + by_mode = policy.get("max_context_chars_by_mode") or {} + max_context_chars = int( + by_mode.get(answer_mode, policy.get("max_context_chars", 60000)) + ) + # Quantifier preflight (Ticket #000008 Phase 1+2). Phase 1 runs + # the lexical classifier; Phase 2 looks up the per-model cap. + # The cap is REPORTED on the result dict (claim_cap_applied) but + # only applied to the verifier when quantifier_guard_apply_caps + # is True (default False through dry-run rollout per §10.11.3). + # Six-level disable hierarchy gates each step: master + # (quantifier_guard_enabled), per-mode (quantifier_guard_modes), + # per-call (quantifier_caps_by_intensity overrides), per-test + # (policy={"quantifier_guard_enabled": False}). + from aborist.qa.model_profiles import cap_for_intensity + from aborist.qa.quantifier import classify_question_quantifier + quantifier_guard_on = bool(policy.get("quantifier_guard_enabled", True)) + quantifier_guard_modes = policy.get( + "quantifier_guard_modes", + ["claim_lattice_pointer", "claim_lattice"], + ) + quantifier_mode_gated = answer_mode in (quantifier_guard_modes or []) + if quantifier_guard_on: + quantifier = classify_question_quantifier(question) + else: + # Master kill — emit a stub so the result schema stays + # consistent. Bench rows can still distinguish "guard off" + # from "no classification" via quantifier_intensity=None. + quantifier = { + "intensity": None, + "matched_token": None, + "explicit_count": None, + "is_broad": False, + "operational_shape": None, + "scope_bound_hint": "unknown", + "classifier_version": None, + } + if quantifier_guard_on and quantifier_mode_gated and quantifier["intensity"]: + claim_cap_lookup = cap_for_intensity( + model_profile_id=model_id, + intensity=quantifier["intensity"], + explicit_count=quantifier["explicit_count"], + policy_overrides=policy.get("quantifier_caps_by_intensity") or None, + ) + else: + claim_cap_lookup = None + # Effective cap that the verifier will see. Dry-run mode + # (apply_caps=False) preserves the policy default; once an + # operator flips apply_caps=True, the looked-up cap shadows the + # default for this call only — but ONLY for modes in + # quantifier_apply_caps_modes. n=5 bench (#000008 §12.10) found + # cap-on-pointer fires TOO_MANY_CLAIMS 20× without moving the + # 0/45 STRICT floor, while cap-on-JSON wins +14pp. Default + # allowlist is ["claim_lattice"] (JSON only); empty/None falls + # back to all guard_modes. + # The full cap fallback chain reads: + # 1. quantifier-guard cap (when apply_caps=True AND mode allowed) + # 2. claim_lattice_max_claims_per_answer policy field + # 3. hard-coded default 12 + quantifier_apply_caps = bool(policy.get("quantifier_guard_apply_caps", False)) + quantifier_apply_caps_modes = policy.get( + "quantifier_apply_caps_modes", + quantifier_guard_modes, # legacy fallback + ) or quantifier_guard_modes + quantifier_caps_mode_gated = answer_mode in (quantifier_apply_caps_modes or []) + _policy_max_claims = int(policy.get("claim_lattice_max_claims_per_answer", 12)) + if ( + quantifier_apply_caps + and quantifier_caps_mode_gated + and claim_cap_lookup is not None + ): + effective_max_claims = int(claim_cap_lookup) + else: + effective_max_claims = _policy_max_claims + # Ticket #000010 — meta-cognition preflight. Pure deterministic + # classifier wraps the quantifier output plus four new detectors + # (temporal, contradiction, false-premise-lite, out-of-corpus). + # Surfaces a QuestionState on the result dict; first pass does + # NOT bind into run_dag_root (deferred to Phase 5 / ticket #000009 + # where the quantifier_preflight node lands too — both nodes can + # land together to keep the run-DAG schema atomic). Reference + # frames not yet plumbed through (frame_detection runs after + # retrieval, and preflight here is pre-retrieval — frame info + # lives on the result dict separately, not on QuestionState + # in this pass). + from aborist.qa.metacognition import preflight_question + _t_preflight = time.monotonic() + question_state = preflight_question( + question, + model_profile_id=model_id, + reference_frames=(), + policy=policy, + ) + preflight_ms = _ms_since(_t_preflight) + # Ticket #000011 — optional soft preflight sidecar. Default OFF; + # one short LLM round-trip when policy["soft_preflight_enabled"] + # is True. Returns a stub hint (SOFT_DISABLED) when off so the + # result-dict / run-DAG schema stays consistent. NEVER enters + # the verifier proof path; advisory only. + from aborist.qa.soft_preflight import soft_preflight_question + _t_soft_preflight = time.monotonic() + soft_hint = soft_preflight_question( + question, + chat_client=chat_client, + model_id=model_id, + policy=policy, + ) + soft_preflight_ms = _ms_since(_t_soft_preflight) + # Ticket #000008 Phase 4 — strict reject for broad-unbounded. + # When opt-in via policy / --reject-broad CLI flag, return + # UNGROUNDED before the LLM call for ALL/COMPREHENSIVE/ + # OPEN_REQUEST + scope_bound_hint==unbounded shapes. Bounded + # universals (scope_bound_hint==bounded) are NOT rejected per + # §10.1 — those are answerable. Saves the ~10-15s LLM call on + # rejected runs. + quantifier_reject_broad = bool(policy.get("quantifier_reject_broad", False)) + quantifier_should_reject = ( + quantifier_guard_on + and quantifier_mode_gated + and quantifier_reject_broad + and quantifier.get("is_broad") + and quantifier.get("scope_bound_hint") == "unbounded" + ) + if quantifier_should_reject: + # Early-return without an LLM call. Skips retrieval cost too — + # we already know the answer set is undefined. Result schema + # mirrors a normal UNGROUNDED row so bench/CLI rendering + # stays consistent. + # Ticket #000009 §8.2 / feedback §6.2: build a 3-stage + # reject-broad DAG so the rejection is Merkle-auditable. + # Without this, two rejections under different policy state + # would be indistinguishable in audit replay. + from aborist.qa.dag import ( + build_reject_run_dag, + preflight_node_hash as _pre_hash, + ) + # question_hash / verifier_policy_hash / model_profile_hash + # already imported at module top; do NOT re-import locally + # (would shadow free-variable uses elsewhere in this function). + _reject_qhash = question_hash( + question, + mode=policy.get("question_dedup", "equivalence_class"), + ) + _reject_ghash = verifier_policy_hash(policy) + _reject_mhash = model_profile_hash(model_id, revision, quantization) + _reject_rationale = ( + "preflight rejection — broad-quantifier query with " + "unbounded scope. Operator opted in via " + "quantifier_reject_broad policy." + ) + _reject_violations = [{ + "kind": "BROAD_QUANTIFIER_REJECTED", + "intensity": quantifier["intensity"], + "matched_token": quantifier["matched_token"], + "scope_bound_hint": quantifier["scope_bound_hint"], + "reason": _reject_rationale, + }] + _reject_answer_text = ( + "BROAD-QUANTIFIER PREFLIGHT REJECTED · scope unbounded\n\n" + f"Question matched {quantifier['intensity']} intensity " + f"(\"{quantifier['matched_token']}\") with an under-" + "specified universe. Narrow the question (e.g. add a " + "year, league, country, or category) or run with " + "--allow-broad for exploratory enumeration." + ) + # Same payload-then-hash pattern as the miss path so + # `--show-preflight` can render the full clause set on + # reject rows too. + from aborist.qa.dag import ( + _canonical_json as _reject_canon, + _sha256_hex as _reject_sha, + build_preflight_node_payload as _reject_build_payload, + ) + _reject_preflight_payload = _reject_build_payload( + question_state=question_state.to_dict(), + quantifier=quantifier, + answer_contract={ + "guard_enabled": quantifier_guard_on, + "mode_gated": quantifier_mode_gated, + "apply_caps_active": quantifier_apply_caps, + "apply_caps_mode_gated": quantifier_caps_mode_gated, + "claim_cap_resolved": claim_cap_lookup, + "claim_cap_applied": None, # cap never reaches verifier on reject + "manual_quotes_allowed": False, + "evidence_pointer_required": True, + "allow_unbounded_enumeration": False, + "reject_broad_active": True, + "metacognition_enabled": bool( + policy.get("metacognition_enabled", True) + ), + "block_on_contradiction": bool( + policy.get("metacognition_block_on_contradiction", False) + ), + }, + prompt_contract={ + # Rejection skips the LLM, so no reminder ever fires. + "reminder_enabled": bool( + policy.get("quantifier_reminder_enabled", False) + ), + "reminder_injected": False, + "reminder_template_id": None, + }, + evidence_contract={ + "max_evidence_ids_exposed": int(policy.get( + "claim_lattice_max_pointers_per_claim", 2 + )), + "one_claim_per_line": True, + }, + policy_refs={ + "governance_policy_hash": _reject_ghash, + "model_profile_hash": _reject_mhash, + "answer_mode": answer_mode, + }, + ) + _reject_preflight_hash = _reject_sha( + _reject_canon(_reject_preflight_payload) + ) + _reject_run_dag = build_reject_run_dag( + question_hash=_reject_qhash, + preflight_hash=_reject_preflight_hash, + preflight_payload=_reject_preflight_payload, + rejection_reason=_reject_rationale, + answer_text=_reject_answer_text, + audit_mode="UNGROUNDED", + verifier_method=( + "claim_lattice_pointer" + if answer_mode == "claim_lattice_pointer" + else "claim_lattice" + if answer_mode == "claim_lattice" + else "quote" + ), + violations=_reject_violations, + ) + return { + "status": "broad_quantifier_rejected", + "audit_mode": "UNGROUNDED", + "cache_key": None, + "lookup_path": "preflight", + # Audit binding: reject path now carries its own + # 3-stage run_dag (question → preflight → final_label) + # so audit replay can read the rejection from + # run_dag_blob the same way it reads any other row. + "run_dag_root": _reject_run_dag["root"], + "run_dag_blob": json.dumps(_reject_run_dag, separators=(",", ":")), + "preflight_hash": _reject_preflight_hash, + "answer_text": _reject_answer_text, + "sources": [], + "n_quotes": 0, + "n_verified": 0, + "verifier_method": "claim_lattice_pointer" + if answer_mode == "claim_lattice_pointer" + else "claim_lattice" + if answer_mode == "claim_lattice" + else "quote", + "unverified_quotes": [], + "partially_verified_quotes": [], + "violations": _reject_violations, + "format_collapsed": None, + "raw_answer": None, + "quantifier_intensity": quantifier["intensity"], + "quantifier_matched_token": quantifier["matched_token"], + "scope_bound_hint": quantifier["scope_bound_hint"], + "quantifier_explicit_count": quantifier["explicit_count"], + "claim_cap_applied": claim_cap_lookup, + # Ticket #000010 — meta-cognition QuestionState surfaced + # for bench / CLI render. Preflight rejection path still + # returns its own status; this is the upstream classifier + # output regardless of guard outcome. + "question_state": question_state.to_dict(), + "pointer_id_distribution": None, + "lazy_anchor_ratio": None, + "retrieval_purity": None, + "prompt_chars": { + "system_prompt": 0, + "grounding_reminder": 0, + "user_question": len(question), + "evidence_or_context": 0, + "messages_total": 0, + }, + "answer_chars": 0, + "frame_detection": None, + "burned_existing": 0, + "context_root": None, + "timings": { + "search_ms": 0.0, + "context_ms": 0.0, + "cache_lookup_ms": 0.0, + "llm_ms": None, + "persist_ms": None, + # Preflight rejection runs in <1ms — record 0.0 + # rather than re-fetching wall-time. The point of + # the path is to NOT spend wall time. + "total_ms": 0.0, + }, + } + t_start = time.monotonic() + + # 1. Search. + # + # Retrieval-only query string: question + operator-supplied + # ``retrieval_keywords`` (a hint, never part of the cache_key / + # LLM prompt / verifier surface). When the user passes + # `--retrieval-keywords "transcranial knowledge acquisition"`, + # only the FTS5 MATCH and title-filter token set see those + # tokens; the question text fed to the LLM and to question_hash + # stays untouched. + retrieval_query = question + if retrieval_keywords and retrieval_keywords.strip(): + retrieval_query = f"{question} {retrieval_keywords.strip()}" + t_phase = time.monotonic() + hits, core_match_roots, phrase_match_roots, root_to_shard = _search_corpus( + shards_dir, single_db, retrieval_query, over_fetch + ) + if not hits: + return { + "status": "no_sources", + "msg": "FTS5 search returned no hits", + "timings": { + "search_ms": _ms_since(t_phase), + "total_ms": _ms_since(t_start), + }, + } + qtokens_lower = {t.lower() for t in _title_query_tokens(retrieval_query)} + + def _body_density_check(h) -> bool: + # Lazy per-hit check: open the shard, count token mentions in this doc. + sp = root_to_shard.get(h.document_root) or h.shard_path + if not sp: + return False + c = connect(sp) + try: + return _body_density_passes(c, h.document_root, qtokens_lower) + finally: + c.close() + + hits = _rerank( + hits, + retrieval_query, + core_match_roots=core_match_roots, + body_density_check=_body_density_check, + phrase_match_roots=phrase_match_roots, + hyphen_fold_anchors=_hyphen_fold_variants(retrieval_query), + shards_dir=shards_dir, + ) + search_ms = _ms_since(t_phase) + + # 2. Pull doc texts within budget. + # + # Per-source cap so a single huge document can't monopolize the + # context window. Without this, a top-ranked bibliography page + # (e.g. List_of_Batman_comics at 80 KB) consumes the entire 60 KB + # budget at hit #1 and every subsequent doc is dropped with + # char_budget <= 0 — even when the bio article is hit #2 with + # the actual answer. By default we cap each source at + # `max_context_chars // top_k` so all top_k hits land in context. + # Total context ≤ max_context_chars by construction. + t_phase = time.monotonic() + chosen: list[_Hit] = [] + context_parts: list[str] = [] + per_source_cap = max(1, max_context_chars // max(1, top_k)) + char_budget = max_context_chars + for h in hits[:top_k]: + text = _load_doc_text(h.shard_path, h.document_root) + if not text: + continue + # source_role is already set by _rerank_by_source_role; reuse + # it for the per-source cap. Primary answer source gets 2× the + # baseline cap, noisy/sequel get 0.5×, secondary & background + # get 1×. Total context still bounded by `char_budget`. + weight = SOURCE_ROLE_BUDGET_WEIGHTS.get(h.source_role, 1.0) + hit_cap = max(1, int(per_source_cap * weight)) + if len(text) > hit_cap: + text = text[:hit_cap] + if len(text) > char_budget: + text = text[:char_budget] + if not text: + continue + context_parts.append( + f"=== Source: {h.document_uri} ===\n{text}" + ) + chosen.append(h) + char_budget -= len(text) + if char_budget <= 0: + break + + if not chosen: + return { + "status": "no_sources", + "msg": "top-k hits had cold or empty content", + "timings": { + "search_ms": search_ms, + "context_ms": _ms_since(t_phase), + "total_ms": _ms_since(t_start), + }, + } + context = "\n\n".join(context_parts) + + # Wikitext → prose before the LLM sees it. The model can then quote + # verbatim against the prose form; the verifier compares like-against- + # like. Idempotent if context is already plain prose. Gated on + # policy["base_version"] so this is part of governance_policy_hash. + if policy.get("base_version") and _wikitext_to_base is not None: + context = _wikitext_to_base(context) + context_ms = _ms_since(t_phase) + + # 3. Build messages + hashes. Branch on answer_mode: + # "quote" (default) raw sources block + verbatim-quote rules. + # "claim_lattice" one evidence object per chosen source, each + # labeled with a content-addressed evidence_id; + # model emits JSON referencing IDs. + evidence_map = [] + if answer_mode == "claim_lattice_pointer": + # G0.1 — per-chunk evidence granularity. Each retrieved source + # contributes ONE evidence object per chunk (up to the + # role-weighted per_source_cap budget) instead of one + # whole-doc span. + # + # G0.3 — query-relevance chunk ordering. Within each source, + # chunks are ranked by (distinct_query_tokens_present, + # total_mentions, chunk_idx_asc) so the chunk that most + # textually supports the question gets the lowest pointer id + # and lands at the top of the per-source evidence stack. Without + # this re-rank, Hermes-3-8B lazy-anchors on the first few + # chunks regardless of relevance — burying the actual answer + # paragraph behind irrelevant article-header text. Soft signal + # only (token overlap; no embeddings) so it stays out of the + # proof path; the verifier still runs the same hard checks + # against whatever the model picked. + qtokens_stem_for_chunks = { + _stem_token_for_match(t.lower()) + for t in _title_query_tokens(question) + } + chunks_for_map: list[dict] = [] + for h in chosen: + doc_chunks = _load_doc_chunks(h.shard_path, h.document_root) + if not doc_chunks: + continue + weight = SOURCE_ROLE_BUDGET_WEIGHTS.get(h.source_role, 1.0) + hit_cap = max(1, int(per_source_cap * weight)) + # Score each chunk by query-token overlap. Tuple sort: + # distinct present DESC, total mentions DESC, doc order ASC. + scored = [] + for chunk_idx, leaf_hash, span in doc_chunks: + distinct, total = _chunk_query_relevance( + span, qtokens_stem_for_chunks + ) + scored.append( + (chunk_idx, leaf_hash, span, distinct, total) + ) + scored.sort(key=lambda r: (-r[3], -r[4], r[0])) + # Greedy: fill the per-source budget with relevance-ranked + # chunks. If a single chunk exceeds what's left, truncate + # that one chunk and stop. Total context across the source + # stays bounded by hit_cap, same shape as the prose path. + # Per-source chunk cap also bounds chunk COUNT (in addition + # to char budget) so an encyclopedic article doesn't inflate + # the evidence catalog into E1-E26 territory and induce + # mega-claim failures. + max_chunks = max(1, int(policy.get( + "claim_lattice_max_chunks_per_source", 2 + ))) + spent = 0 + chunks_used = 0 + for chunk_idx, leaf_hash, span, _d, _t in scored: + if spent >= hit_cap or chunks_used >= max_chunks: + break + if policy.get("base_version") and _wikitext_to_base is not None: + span = _wikitext_to_base(span) + remaining = hit_cap - spent + if len(span) > remaining: + span = span[:remaining] + if not span: + break + chunks_for_map.append({ + "source_root": h.document_root, + "document_uri": h.document_uri, + "title": h.title, + "chunk_idx": chunk_idx, + "chunk_root": leaf_hash, + "span": span, + "source_role": h.source_role, + }) + spent += len(span) + chunks_used += 1 + evidence_map = build_evidence_map(chunks_for_map) + sys_prompt = policy["claim_lattice_system_prompt"] + grounding_reminder = policy.get("claim_lattice_grounding_reminder") + rendered_evidence = render_evidence_map(evidence_map) + + def _user_payload(q: str) -> str: + return f"EVIDENCE:\n\n{rendered_evidence}\n\n---\n\nQUESTION: {q}" + elif answer_mode == "claim_lattice": + # JSON variant — same evidence-map construction as the pointer + # path, but blocks are labeled with content-addressed + # ``evidence_id`` (long hex) since the model emits IDs in JSON. + # The lenient pre-parser in verify_claim_lattice_json keeps the + # path survivable on inference paths without grammar guidance; + # vLLM ``guided_json`` (passed via extra_body below) eliminates + # SCHEMA_INVALID failures at sampling time when available. + qtokens_stem_for_chunks = { + _stem_token_for_match(t.lower()) + for t in _title_query_tokens(question) + } + chunks_for_map: list[dict] = [] + for h in chosen: + doc_chunks = _load_doc_chunks(h.shard_path, h.document_root) + if not doc_chunks: + continue + weight = SOURCE_ROLE_BUDGET_WEIGHTS.get(h.source_role, 1.0) + hit_cap = max(1, int(per_source_cap * weight)) + scored = [] + for chunk_idx, leaf_hash, span in doc_chunks: + distinct, total = _chunk_query_relevance( + span, qtokens_stem_for_chunks + ) + scored.append((chunk_idx, leaf_hash, span, distinct, total)) + scored.sort(key=lambda r: (-r[3], -r[4], r[0])) + max_chunks = max(1, int(policy.get( + "claim_lattice_max_chunks_per_source", 2 + ))) + spent = 0 + chunks_used = 0 + for chunk_idx, leaf_hash, span, _d, _t in scored: + if spent >= hit_cap or chunks_used >= max_chunks: + break + if policy.get("base_version") and _wikitext_to_base is not None: + span = _wikitext_to_base(span) + remaining = hit_cap - spent + if len(span) > remaining: + span = span[:remaining] + if not span: + break + chunks_for_map.append({ + "source_root": h.document_root, + "document_uri": h.document_uri, + "title": h.title, + "chunk_idx": chunk_idx, + "chunk_root": leaf_hash, + "span": span, + "source_role": h.source_role, + }) + spent += len(span) + chunks_used += 1 + evidence_map = build_evidence_map(chunks_for_map) + sys_prompt = policy.get( + "claim_lattice_json_system_prompt", + policy["claim_lattice_system_prompt"], + ) + grounding_reminder = policy.get( + "claim_lattice_json_grounding_reminder", + policy.get("claim_lattice_grounding_reminder"), + ) + rendered_evidence = render_evidence_map_for_json(evidence_map) + + def _user_payload(q: str) -> str: + return f"EVIDENCE:\n\n{rendered_evidence}\n\n---\n\nQUESTION: {q}" + else: + sys_prompt = policy["system_prompt"] + grounding_reminder = policy.get("grounding_reminder") + + def _user_payload(q: str) -> str: + return f"Sources:\n\n{context}\n\n---\n\nQuestion: {q}" + + # Frame detection (Ticket #000002 / Module L). Lattice-mode only. + # Surfaces whether the query is allusion-shape AND the phrase + # route surfaced a reference-work source. When `reference`, the + # polarity preamble below nudges the model toward multi-frame + # answers. + # + # Body sample for the fiction-marker density check uses the + # ARTICLE LEAD (chunk_idx=0) — that's where fiction markers + # cluster on Wikipedia ("is a dystopian science fiction novel + # by..."). Reusing chunks_for_map would give us the query- + # relevant chunks (e.g. the plot section containing 'always been + # at war') which may have fewer fiction markers. + frame_detection: FrameDetection | None = None + if answer_mode in ("claim_lattice_pointer", "claim_lattice"): + sources_for_frame: list[dict] = [] + seen_roots: set[str] = set() + for h in chosen: + if h.document_root in seen_roots: + continue + seen_roots.add(h.document_root) + doc_chunks = _load_doc_chunks(h.shard_path, h.document_root) + # First chunk by idx ASC — the article lead. + body_sample = doc_chunks[0][2] if doc_chunks else "" + # Wikitext-strip so fiction markers buried under + # `[[wikilinks]]` and `{{templates}}` surface in the + # density check. Bench-time policy gates the strip; we + # apply it unconditionally here since a no-op fallback + # leaves raw wikitext (and the markers still match the + # `\bnovel\b` regex even with surrounding markup). + if ( + policy.get("base_version") + and _wikitext_to_base is not None + and body_sample + ): + body_sample = _wikitext_to_base(body_sample) + sources_for_frame.append({ + "document_root": h.document_root, + "document_uri": h.document_uri, + "title": h.title, + "body_sample": body_sample, + }) + frame_detection = _detect_frame( + question, sources_for_frame, phrase_match_roots=phrase_match_roots + ) + + # Ticket #000010 §12.6 — refine QuestionState with frame data + # post-retrieval. Pre-retrieval preflight ran with empty + # reference_frames=() (frame detection needs source titles + # which only exist after retrieval). Now that frames are + # known, re-run the classifier so the run-DAG and result-dict + # QuestionState carry the frame-aware logical_statuses + # (specifically `reference_frame_ambiguous` when 2+ frames + # match). Pure function; cheap to re-call. + refined_frames: tuple[str, ...] = () + if frame_detection is not None: + if frame_detection.frame_kind == "reference": + refined_frames = ( + ("literal_geography", frame_detection.reference_title or "reference") + if frame_detection.confidence < 1.0 + else (frame_detection.reference_title or "reference",) + ) + elif frame_detection.frame_kind == "ambiguous": + refined_frames = ("literal", "ambiguous_reference") + if refined_frames: + question_state = preflight_question( + question, + model_profile_id=model_id, + reference_frames=refined_frames, + policy=policy, + ) + + messages = [{"role": "system", "content": sys_prompt}] + # Polarity preamble for reference-frame queries (Ticket #000002). + # Injected as a user-role message BEFORE the grounding_reminder + # so the model sees the frame hint first, then the + # always-applicable structural reminder, then the actual + # evidence + question. + polarity_template = policy.get("claim_lattice_polarity_preamble", "") + if ( + frame_detection is not None + and frame_detection.frame_kind == "reference" + and polarity_template + ): + polarity_msg = polarity_template.format( + reference_title=frame_detection.reference_title or "" + ) + messages.append({"role": "user", "content": polarity_msg}) + if grounding_reminder: + messages.append({"role": "user", "content": grounding_reminder}) + # Quantifier-specific reminder (Ticket #000008 Phase 3, default + # off). Same mechanism as runner.ask — see runner.py for + # rationale. Mode-gated and master-killable. + if ( + quantifier_guard_on + and quantifier_mode_gated + and quantifier.get("is_broad") + and bool(policy.get("quantifier_reminder_enabled", False)) + ): + from aborist.qa.quantifier_reminder import broad_quantifier_reminder + broad = broad_quantifier_reminder( + intensity=quantifier["intensity"], + cap=effective_max_claims, + scope_bound_hint=quantifier["scope_bound_hint"], + ) + if broad: + messages.append({"role": "user", "content": broad}) + messages.append({"role": "user", "content": _user_payload(question)}) + + # Capacity metrics. Char-level for now — a fast model-agnostic proxy + # for prompt size (rule of thumb: ~4 chars/token for English prose, + # ~2.5 for JSON-evidence-block heavy contexts). Surfaced in the + # result dict so the bench can correlate strict-rate with input + # size and the operator can tell at a glance whether a STRICT + # verdict came from a tight 5KB prompt or a 50KB context-stuffed + # one. Never enters cache_key — these are runtime measurements, + # not policy. + if answer_mode in ("claim_lattice_pointer", "claim_lattice"): + evidence_or_context_chars = len(rendered_evidence) + else: + evidence_or_context_chars = len(context) + prompt_chars = { + "system_prompt": len(sys_prompt or ""), + "grounding_reminder": len(grounding_reminder or ""), + "user_question": len(question or ""), + "evidence_or_context": evidence_or_context_chars, + "messages_total": sum(len(m["content"]) for m in messages), + } + + context_root = _context_root([h.document_root for h in chosen]) + mhash = model_profile_hash(model_id, revision, quantization) + + # Dedup-mode-aware cache_key build. For each mode we substitute the + # mode's canonical question form into the user message used for + # `conversation_hash` (LLM still sees the verbatim question), AND we + # vary `policy["question_dedup"]` to match the mode so + # `governance_policy_hash` matches what an agent under that mode + # would have written. This makes cross-silo fallback work: a + # strict-policy agent looking up with equivalence_class fidelity can + # find a record written by an equivalence_class-policy agent. + def _ckey_for_mode(mode: str) -> str: + canon_q = canonical_question(question, mode=mode) + canon_msgs = list(messages[:-1]) + [ + {"role": "user", "content": _user_payload(canon_q)}, + ] + policy_variant = dict(policy, question_dedup=mode) + return cache_key( + context_root, + question_hash(question, mode=mode), + mhash, + conversation_hash(canon_msgs), + governance_policy_hash(policy_variant), + SCHEMA_VERSION, + CANONICALIZATION_VERSION, + CHUNKING_VERSION, + verifier_policy_hash(policy_variant), + ) + + ghash = governance_policy_hash(policy) # for the legacy INSERT below + + primary_dedup = policy.get("question_dedup", DEFAULT_QUESTION_DEDUP) + if primary_dedup not in QUESTION_DEDUP_MODES: + raise ValueError( + f"policy['question_dedup'] must be one of {QUESTION_DEDUP_MODES}, " + f"got {primary_dedup!r}" + ) + # Re-derive the per-mode hashes for use in the INSERT below. The legacy + # INSERT references qhash/chash by name; _ckey_for_mode already builds + # them but doesn't expose the intermediates. + qhash = question_hash(question, mode=primary_dedup) + canonical_q_primary = canonical_question(question, mode=primary_dedup) + canonical_messages_primary = list(messages[:-1]) + [ + {"role": "user", "content": _user_payload(canonical_q_primary)}, + ] + chash = conversation_hash(canonical_messages_primary) + primary_ckey = _ckey_for_mode(primary_dedup) + ckey = primary_ckey # keep the legacy name in the rest of the function + + qa_conn = connect(qa_db) + burned_existing = 0 + try: + # 3.5. Optional pre-lookup burn: deletes the matching live row + # under the primary cache_key so the lookup misses & a fresh + # inference runs. Test-ergonomic — pass `burn_existing=True` + # (or `make query Q=... BURN=1`) after tweaking a knob to see + # the new behavior. The equivalence-class fallback key is + # deliberately NOT touched: prior alt-mode records stay as + # historic witnesses. + if burn_existing: + existing = qa_conn.execute( + "SELECT cache_key, audit_mode, n_verified, " + " falsification_state, question_text " + "FROM providence_cache WHERE cache_key = ?", + (primary_ckey,), + ).fetchone() + if existing is not None: + with transaction(qa_conn): + qa_conn.execute( + "DELETE FROM providence_cache WHERE cache_key = ?", + (primary_ckey,), + ) + append_audit( + qa_conn, + event_type="providence_burn", + subject_root=primary_ckey, + body={ + "cache_key": primary_ckey, + "burned_audit_mode": existing["audit_mode"], + "burned_n_verified": int(existing["n_verified"] or 0), + "burned_state": existing["falsification_state"], + "question_text": existing["question_text"], + "reason": "query --burn (test-ergonomic mid-query bust)", + }, + ) + burned_existing = 1 + + # 4. Cache lookup. Try the primary dedup-mode cache_key first. + # If fidelity allows fallback AND the alternate dedup mode + # produces a different cache_key, try that too — lets a + # fast-cache agent reuse a record written under either mode. + t_phase = time.monotonic() + cached = qa_conn.execute( + "SELECT * FROM providence_cache " + "WHERE cache_key = ? AND falsification_state = 'live'", + (primary_ckey,), + ).fetchone() + hit_ckey = primary_ckey + lookup_path = primary_dedup if cached is not None else None + if cached is None and fidelity == "equivalence_class": + other_mode = ( + "equivalence_class" if primary_dedup == "strict" else "strict" + ) + other_ckey = _ckey_for_mode(other_mode) + if other_ckey != primary_ckey: + cached = qa_conn.execute( + "SELECT * FROM providence_cache " + "WHERE cache_key = ? AND falsification_state = 'live'", + (other_ckey,), + ).fetchone() + if cached is not None: + hit_ckey = other_ckey + lookup_path = f"{other_mode}_fallback" + cache_lookup_ms = _ms_since(t_phase) + if cached is not None: + now = int(time.time()) + with transaction(qa_conn): + qa_conn.execute( + "UPDATE providence_cache " + "SET hit_count = hit_count + 1, last_hit_at = ? " + "WHERE cache_key = ?", + (now, hit_ckey), + ) + return { + "status": "cache_hit", + "audit_mode": cached["audit_mode"], + "cache_key": hit_ckey, + "lookup_path": lookup_path, + "burned_existing": burned_existing, + "context_root": context_root, + "answer_text": cached["answer_text"], + "sources": json.loads(cached["merkle_proof"])["sources"], + "n_quotes": cached["n_quotes"], + "n_verified": cached["n_verified"], + "verifier_method": cached["verifier_method"], + "unverified_quotes": ( + json.loads(cached["unverified_quotes"]) + if cached["unverified_quotes"] + else [] + ), + # Cache schema doesn't carry the partially-verified split — + # those claims were folded into unverified_quotes pre-2026- + # 04-30. Cache hits surface an empty partial list; new + # writes populate it correctly. Acceptable degradation + # since governance_policy_hash invalidated prior records. + "partially_verified_quotes": [], + # Quantifier preflight (Ticket #000008 Phase 1) — the + # classifier is pure on the question string, so cache + # hits can re-classify cheaply and carry the same + # schema as miss-path rows. Bench rows stay + # column-aligned across hit/miss. + "quantifier_intensity": quantifier["intensity"], + "quantifier_matched_token": quantifier["matched_token"], + "scope_bound_hint": quantifier["scope_bound_hint"], + "quantifier_explicit_count": quantifier["explicit_count"], + "claim_cap_applied": claim_cap_lookup, + # Ticket #000010 — meta-cognition QuestionState. Pure + # function, cache hits re-classify cheaply. + "question_state": question_state.to_dict(), + # Ticket #000009 §7.2 — pull preflight_hash out of + # the persisted run_dag_blob. Cache hits don't + # rebuild the DAG; the blob carries the original + # preflight stage hash from the write-time policy. + # None when the cached row predates #000009. + # cached is a sqlite3.Row; column access via + # subscript, not .get(); guard with `keys()` since + # legacy rows may lack the run_dag_blob column. + "preflight_hash": _extract_preflight_hash_from_blob( + cached["run_dag_blob"] if "run_dag_blob" in cached.keys() else None + ), + "prompt_chars": prompt_chars, + "answer_chars": len(cached["answer_text"] or ""), + "timings": { + "search_ms": search_ms, + "context_ms": context_ms, + "cache_lookup_ms": cache_lookup_ms, + "llm_ms": None, + "persist_ms": None, + "total_ms": _ms_since(t_start), + }, + } + + # 5. Cache miss — call LLM. JSON mode passes guided_json via + # extra_body so vLLM constrains output to the schema at sampling + # time. Non-vLLM endpoints silently drop the field; the lenient + # pre-parser in the verifier handles whatever drift remains. + extra_body: dict | None = None + stop_seqs: list[str] | None = None + if answer_mode == "claim_lattice" and policy.get( + "claim_lattice_use_guided_json", True + ): + extra_body = {"guided_json": CLAIM_LATTICE_JSON_SCHEMA} + if answer_mode == "claim_lattice": + # JSON-mode token-runaway guard — see runner.py for the + # full rationale. Stops generation on a blank line so + # post-JSON whitespace spam doesn't blow max_tokens. + stop_seqs = list(policy.get( + "claim_lattice_json_stop_sequences", ["\n\n"] + )) + t_phase = time.monotonic() + raw_answer = chat_client.chat_completion( + messages, + model=model_id, + temperature=policy["temperature"], + max_tokens=policy["max_tokens"], + top_p=policy.get("top_p", 1.0), + extra_body=extra_body, + stop=stop_seqs, + ) + llm_ms = _ms_since(t_phase) + + # 5b. Faithfulness check. Branch on answer_mode: + # "quote" substring-verify quoted spans against context. + # Optional repair loop (mechanical + reprompt). + # "claim_lattice" deterministic checks on the JSON output: + # evidence_id resolution, source_role allowlist, + # manual-quote prohibition. NO repair loop — + # one-shot benchmark discipline. + repair_changes: list[dict] = [] + pre_repair_verdict: dict | None = None + + if answer_mode == "claim_lattice_pointer": + verdict = verify_claim_lattice( + raw_answer, + evidence_map, + allowed_source_roles=tuple( + policy.get( + "claim_lattice_allowed_source_roles", + [ + "primary_answer_source", + "secondary_context_source", + "background_source", + "unclassified", + ], + ) + ), + max_pointers_per_claim=int(policy.get( + "claim_lattice_max_pointers_per_claim", 2 + )), + min_citation_coverage=float(policy.get( + "claim_lattice_min_citation_coverage", 0.30 + )), + min_claim_content_tokens=int(policy.get( + "claim_lattice_min_claim_content_tokens", 3 + )), + lazy_anchor_demote_threshold=float(policy.get( + "claim_lattice_lazy_anchor_demote_threshold", 0.5 + )), + lazy_anchor_demote_min_pairs=int(policy.get( + "claim_lattice_lazy_anchor_demote_min_pairs", 3 + )), + max_claims_per_answer=effective_max_claims, + subject_tokens_absent_threshold=int(policy.get( + "claim_lattice_subject_tokens_absent_threshold", 3 + )), + question=question, + warrant_check_enabled=bool(policy.get( + "claim_lattice_warrant_check_enabled", True + )), + deflection_check_enabled=bool(policy.get( + "claim_lattice_deflection_check_enabled", True + )), + format_collapse_check_enabled=bool(policy.get( + "claim_lattice_format_collapse_check_enabled", True + )), + ) + rendered = verdict["rendered_text"] + answer_text = rendered if rendered else raw_answer + elif answer_mode == "claim_lattice": + verdict = verify_claim_lattice_json( + raw_answer, + evidence_map, + allowed_source_roles=tuple( + policy.get( + "claim_lattice_allowed_source_roles", + [ + "primary_answer_source", + "secondary_context_source", + "background_source", + "unclassified", + ], + ) + ), + max_evidence_per_claim=int(policy.get( + "claim_lattice_max_pointers_per_claim", 2 + )), + min_citation_coverage=float(policy.get( + "claim_lattice_min_citation_coverage", 0.30 + )), + max_claims_per_answer=effective_max_claims, + subject_tokens_absent_threshold=int(policy.get( + "claim_lattice_subject_tokens_absent_threshold", 3 + )), + question=question, + warrant_check_enabled=bool(policy.get( + "claim_lattice_warrant_check_enabled", True + )), + deflection_check_enabled=bool(policy.get( + "claim_lattice_deflection_check_enabled", True + )), + ) + rendered = verdict["rendered_text"] + answer_text = rendered if rendered else raw_answer + else: + answer_text = raw_answer + verdict = verify_quotes( + answer_text, + context, + entity_policy=policy.get("entity_policy", "hybrid"), + proximity_n=policy.get("entity_proximity_n", 3), + proximity_window=policy.get("entity_proximity_window", 300), + ) + + def _verify(text: str) -> dict: + return verify_quotes( + text, + context, + entity_policy=policy.get("entity_policy", "hybrid"), + proximity_n=policy.get("entity_proximity_n", 3), + proximity_window=policy.get("entity_proximity_window", 300), + ) + + if ( + policy.get("repair_enabled") + and verdict["audit_mode"] != "STRICT" + and verdict.get("unverified_quotes") + ): + # Tier 1: mechanical (deterministic, no extra LLM call). + repair_result = mechanical_repair( + answer_text, verdict["unverified_quotes"], context + ) + if repair_result["changes"]: + new_verdict = _verify(repair_result["repaired_text"]) + if new_verdict["n_verified"] >= verdict["n_verified"]: + pre_repair_verdict = verdict + answer_text = repair_result["repaired_text"] + verdict = new_verdict + repair_changes = list(repair_result["changes"]) + + # Tier 2: re-prompt feedback (one extra LLM call max). + max_reprompts = int(policy.get("repair_max_reprompts", 0)) + for _ in range(max_reprompts): + if ( + verdict["audit_mode"] == "STRICT" + or not verdict.get("unverified_quotes") + ): + break + new_text = reprompt_repair( + chat_client=chat_client, + model_id=model_id, + original_messages=messages, + original_answer=answer_text, + failed_quotes=verdict["unverified_quotes"], + policy=policy, + ) + if not new_text: + break + new_verdict = _verify(new_text) + if new_verdict["n_verified"] > verdict["n_verified"]: + if pre_repair_verdict is None: + pre_repair_verdict = verdict + answer_text = new_text + verdict = new_verdict + repair_changes.append({ + "action": "reprompt_rewrite", + "diagnosis": "model_feedback_loop", + }) + else: + break + + unverified_blob = ( + json.dumps(verdict["unverified_quotes"], separators=(",", ":")) + if verdict["unverified_quotes"] + else None + ) + + # 6. Persist record + audit event. + t_phase = time.monotonic() + proof_obj = { + "context_root": context_root, + "sources": [ + { + "document_root": h.document_root, + "document_uri": h.document_uri, + "title": h.title, + "score": h.score, + "chunk_idx": h.chunk_idx, + "shard": Path(h.shard_path).name, + "source_role": h.source_role, + } + for h in chosen + ], + } + proof_blob = json.dumps(proof_obj, separators=(",", ":")) + + # Per-run Merkle-DAG. Quote mode base shape: 7 stages + # (question / retrieval / context / prompt / answer / verify + # / final_label); becomes 8 when preflight_hash is supplied + # (#000009 inserts ``preflight`` between question & retrieval). + # Pointer mode base shape: 9 stages (question / retrieval / + # evidence_map / prompt / raw_answer / parsed_claim_lattice / + # verify / render / final_label); becomes 10 with preflight. + # Reject-broad early-return path uses a 3-stage minimal DAG + # (question / preflight / final_label) via build_reject_run_dag(). + ev_root = evidence_map_root(evidence_map) if evidence_map else None + parsed_lattice = None + is_lattice_mode = answer_mode in ("claim_lattice_pointer", "claim_lattice") + if is_lattice_mode: + evidence_id_pairs = verdict.get("evidence_id_pairs") or [] + parsed_lattice = [ + { + "claim_text": cs.get("text", ""), + "evidence_ids": evidence_id_pairs[i] if i < len(evidence_id_pairs) else [], + } + for i, cs in enumerate(verdict.get("claim_statuses") or []) + ] + # Render-layer source-role display + used/unused. Compute + # which document_roots the verified evidence_ids point at + # (each EvidenceObject's source_root field carries the + # document_root the chunk came from), then annotate each + # source dict in proof_obj. Lets the CLI show per-source + # `primary_answer_source — used (E1)` style annotations. + used_doc_roots: set[str] = set() + evidence_id_to_source_root = { + e.evidence_id: e.source_root for e in (evidence_map or []) + } + for pair in evidence_id_pairs: + for eid in (pair or []): + sroot = evidence_id_to_source_root.get(eid) + if sroot: + used_doc_roots.add(sroot) + # Also map document_root → list of pointer_ids that cited it, + # so the renderer can show the actual pointer tags + # ("used (E1)" not just "used"). + evidence_id_to_pointer = { + e.evidence_id: e.pointer_id for e in (evidence_map or []) + } + doc_root_to_pointers: dict[str, list[str]] = {} + for pair in evidence_id_pairs: + for eid in (pair or []): + sroot = evidence_id_to_source_root.get(eid) + pid = evidence_id_to_pointer.get(eid) + if sroot and pid and pid not in doc_root_to_pointers.get(sroot, []): + doc_root_to_pointers.setdefault(sroot, []).append(pid) + for s in proof_obj["sources"]: + droot = s.get("document_root") + s["used"] = droot in used_doc_roots + s["used_pointer_ids"] = doc_root_to_pointers.get(droot, []) + + # Retrieval purity metrics — sidecar signals for the bench + # ("did the model ignore noise? did retrieval over-fetch?"). + # Pure observation, no decision. Useful for the + # noise-resistance bench fixture and for spotting a + # creeping retrieval-quality regression at aggregate scale. + primary_rank = next( + (i for i, s in enumerate(proof_obj["sources"], start=1) + if s.get("source_role") == "primary_answer_source"), + 0, # 0 = no primary in top-K + ) + noisy_roles = { + "noisy_background_source", "sequel_background_source", + } + noise_sources = [ + s for s in proof_obj["sources"] + if s.get("source_role") in noisy_roles + ] + noise_used = [s for s in noise_sources if s.get("used")] + retrieval_purity = { + "primary_rank": primary_rank, + "primary_used": ( + primary_rank > 0 + and proof_obj["sources"][primary_rank - 1].get("used") is True + ), + "noise_sources_count": len(noise_sources), + "noise_sources_used": len(noise_used), + "total_sources": len(proof_obj["sources"]), + "used_sources": sum( + 1 for s in proof_obj["sources"] if s.get("used") + ), + } + proof_obj["retrieval_purity"] = retrieval_purity + # Retrieval-plan hash (Ticket #000001 / Directive D4): + # capture the operator-influenceable retrieval inputs so the + # run-DAG's retrieval stage binds BOTH plan (what guided the + # search) and result (what got chosen). Folds shard ids when + # available so an audit can reproduce which shards the search + # ran against. Question text intentionally NOT included here + # — already covered by question_hash. + plan = RetrievalPlan( + retrieval_keywords=retrieval_keywords or "", + top_k=int(top_k), + over_fetch=int(over_fetch), + max_context_chars=int(max_context_chars), + shard_ids=tuple( + sorted( + {root_to_shard.get(h.document_root, h.shard_path or "") + for h in chosen if (root_to_shard.get(h.document_root) + or h.shard_path)} + ) + ), + ) + plan_hash = retrieval_plan_hash(plan) + # Ticket #000009 — preflight node binding (nested CTI clauses + # per ticket §8 / 2026-05-04 feedback). Single DAG stage with + # five nested clauses (classifier, answer_contract, + # prompt_contract, evidence_contract, policy_refs) + the + # metacognition QuestionState. + from aborist.qa.dag import preflight_node_hash + # verifier_policy_hash + model_profile_hash already imported + # at module top; reusing the existing names. Local re-imports + # would shadow earlier free-variable uses. + ghash_for_dag = verifier_policy_hash(policy) + claim_cap_actually_applied = ( + claim_cap_lookup + if (quantifier_apply_caps + and quantifier_caps_mode_gated + and claim_cap_lookup is not None) + else None + ) + # Reminder injection actually fires when guard is on AND + # mode-gated AND quantifier is broad AND policy enables it. + # Mirrors the gate in the runner.ask() / query() reminder + # block above. + reminder_eligible = ( + quantifier_guard_on + and quantifier_mode_gated + and quantifier.get("is_broad", False) + ) + reminder_enabled = bool(policy.get("quantifier_reminder_enabled", False)) + reminder_injected = reminder_eligible and reminder_enabled + reminder_template_id = None + if reminder_injected: + reminder_template_id = ( + "broad-quantifier-bounded-v1" + if quantifier.get("scope_bound_hint") == "bounded" + else "broad-quantifier-unbounded-v1" + ) + # Build the canonical payload once; hash it AND persist it + # alongside the DAG nodes so audit replay can render the + # full 5-clause CTI contract via `aborist providence + # --show-preflight`. Hash is deterministic from payload, so + # an auditor can re-verify: + # _sha256_hex(_canonical_json(preflight_payload)) + # == nodes[preflight_idx]["hash"] + from aborist.qa.dag import build_preflight_node_payload + _preflight_payload = build_preflight_node_payload( + question_state=question_state.to_dict(), + quantifier=quantifier, + answer_contract={ + "guard_enabled": quantifier_guard_on, + "mode_gated": quantifier_mode_gated, + "apply_caps_active": quantifier_apply_caps, + "apply_caps_mode_gated": quantifier_caps_mode_gated, + "claim_cap_resolved": claim_cap_lookup, + "claim_cap_applied": claim_cap_actually_applied, + "manual_quotes_allowed": False, + "evidence_pointer_required": is_lattice_mode, + "allow_unbounded_enumeration": False, + "reject_broad_active": bool( + policy.get("quantifier_reject_broad", False) + ), + "metacognition_enabled": bool( + policy.get("metacognition_enabled", True) + ), + "block_on_contradiction": bool( + policy.get( + "metacognition_block_on_contradiction", False + ) + ), + }, + prompt_contract={ + "reminder_enabled": reminder_enabled, + "reminder_injected": reminder_injected, + "reminder_template_id": reminder_template_id, + }, + evidence_contract={ + "max_evidence_ids_exposed": int(policy.get( + "claim_lattice_max_pointers_per_claim", 2 + )), + "one_claim_per_line": is_lattice_mode, + }, + policy_refs={ + "governance_policy_hash": ghash_for_dag, + "model_profile_hash": mhash, + "answer_mode": answer_mode, + }, + ) + from aborist.qa.dag import _sha256_hex, _canonical_json + preflight_hash = _sha256_hex(_canonical_json(_preflight_payload)) + run_dag = build_run_dag( + question_hash=qhash, + sources=proof_obj["sources"], + context_root=context_root, + conversation_hash=chash, + answer_text=answer_text, + audit_mode=verdict["audit_mode"], + verifier_method=verdict["verifier_method"], + n_quotes=verdict["n_quotes"], + n_verified=verdict["n_verified"], + claim_statuses=verdict.get("claim_statuses", []), + lookup_path="miss", + evidence_map_root=ev_root, + answer_mode=answer_mode if answer_mode != "quote" else None, + violations=verdict.get("violations"), + raw_answer_text=raw_answer if is_lattice_mode else None, + parsed_lattice=parsed_lattice, + rendered_text=answer_text if is_lattice_mode else None, + retrieval_plan_hash=plan_hash, + preflight_hash=preflight_hash, + preflight_payload=_preflight_payload, + ) + run_dag_blob = json.dumps(run_dag, separators=(",", ":")) + + now = int(time.time()) + with transaction(qa_conn): + # Record the repair event BEFORE the providence_query event so + # the audit chain shows: repair-happened, THEN we wrote the + # final record. Repair body links pre→post verdicts so an + # auditor can reconstruct what changed. + if repair_changes and pre_repair_verdict is not None: + append_audit( + qa_conn, + event_type="providence_repair", + subject_root=ckey, + body={ + "kind": "mechanical", + "n_changes": len(repair_changes), + "changes": repair_changes, + "pre_audit_mode": pre_repair_verdict["audit_mode"], + "post_audit_mode": verdict["audit_mode"], + "pre_n_verified": pre_repair_verdict["n_verified"], + "post_n_verified": verdict["n_verified"], + }, + ts=now, + ) + event_hash = append_audit( + qa_conn, + event_type="providence_query", + subject_root=ckey, + body={ + "context_root": context_root, + "n_sources": len(chosen), + "model_id": model_id, + "revision": revision, + "quantization": quantization, + "answer_chars": len(answer_text), + "context_chars": len(context), + "audit_mode": verdict["audit_mode"], + "n_quotes": verdict["n_quotes"], + "n_verified": verdict["n_verified"], + "verifier_method": verdict["verifier_method"], + }, + ts=now, + ) + qa_conn.execute( + "INSERT INTO providence_cache " + "(cache_key, source_root, document_uri, question_hash, question_text, " + " answer_text, merkle_proof, model_profile_hash, conversation_hash, " + " governance_policy_hash, schema_version, canonicalization_version, " + " chunking_version, falsification_state, chain, audit_event_hash, " + " created_at, hit_count, audit_mode, n_quotes, n_verified, " + " unverified_quotes, verifier_method, run_dag_root, run_dag_blob) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'live', ?, ?, ?, 0, " + " ?, ?, ?, ?, ?, ?, ?)", + ( + ckey, + context_root, + "corpus://multi-source", + qhash, + question, + answer_text, + proof_blob, + mhash, + chash, + ghash, + SCHEMA_VERSION, + CANONICALIZATION_VERSION, + CHUNKING_VERSION, + chain, + event_hash, + now, + verdict["audit_mode"], + verdict["n_quotes"], + verdict["n_verified"], + unverified_blob, + verdict["verifier_method"], + run_dag["root"], + run_dag_blob, + ), + ) + finally: + qa_conn.close() + + persist_ms = _ms_since(t_phase) + # Pull the verify node's failure_stage out of the run_dag for the result. + failure_stage = next( + (n.get("hash") for n in run_dag["nodes"] if n["stage"] == "verify"), + None, + ) + # The actual label is in the verify_payload, which we computed in + # localize_failure earlier — recompute for the result dict. + from aborist.qa.dag import localize_failure as _localize + failure_stage = _localize( + audit_mode=verdict["audit_mode"], + n_sources=len(chosen), + n_quotes=verdict["n_quotes"], + n_verified=verdict["n_verified"], + ) + return { + "status": "cache_miss_then_written", + "audit_mode": verdict["audit_mode"], + "cache_key": ckey, + "run_dag_root": run_dag["root"], + "lookup_path": "miss", + "failure_stage": failure_stage, + "repair_changes": repair_changes, + "pre_repair_audit_mode": ( + pre_repair_verdict["audit_mode"] if pre_repair_verdict else None + ), + "burned_existing": burned_existing, + "context_root": context_root, + "answer_text": answer_text, + "sources": proof_obj["sources"], + "n_quotes": verdict["n_quotes"], + "n_verified": verdict["n_verified"], + "verifier_method": verdict["verifier_method"], + "unverified_quotes": verdict["unverified_quotes"], + "partially_verified_quotes": verdict.get("partially_verified_quotes") or [], + # Violations (per-claim hard-check failures + soft demotes + # like LAZY_ANCHOR_DEMOTE / WARRANT_MISSING). Surfaced so the + # CLI render can name WHY a verdict capped at HYBRID instead + # of reaching STRICT. Persisted into run_dag_blob via the + # verify stage's payload. + "violations": verdict.get("violations") or [], + # Format-collapse signal (pointer-mode only — None elsewhere). + # True when the model emitted ≥5 meaningful prose lines with + # zero `[E\d+]` pointer tags, i.e. abandoned the + # claim_lattice_pointer protocol entirely. Surfaced on the + # result dict so bench harness can measure FC rate without + # re-deriving it from raw_answer (which is lattice-only). + # See verify.py:format_collapsed and ticket #000008. + "format_collapsed": verdict.get("format_collapsed"), + # Model's raw output before the renderer interpolates literal + # spans. Lattice modes only — quote/span/entity/paraphrase + # rows have answer_text == raw_answer so this stays None to + # avoid duplication. Bench reads it for bracket-count + # diagnostics; never persisted in providence_cache. + "raw_answer": raw_answer if is_lattice_mode else None, + # Quantifier preflight result (Ticket #000008 Phase 1). + # Surfaced on the result so bench rows pick it up. Phase 1 + # is dry-run only — caps not applied; Phase 2 wires + # `claim_cap_applied`. None semantics: classifier always + # returns a dict so these are never None on the miss path, + # but the cache-hit path (line ~2080) doesn't carry them + # since the cached record predates the classifier. + "quantifier_intensity": quantifier["intensity"], + "quantifier_matched_token": quantifier["matched_token"], + "scope_bound_hint": quantifier["scope_bound_hint"], + "quantifier_explicit_count": quantifier["explicit_count"], + # Cap that was looked up for this run. NULL semantics: + # None → guard off, mode opted out, or classifier + # returned no intensity (no cap recorded). + # int → cap that WOULD apply to the verifier when + # quantifier_guard_apply_caps=True. Reported on + # dry-run rows so bench can chart cap distribution + # before the gate flips. + # Phase 2 dry-run: cap is REPORTED but not APPLIED to the + # verifier — claim_lattice_max_claims_per_answer (default + # 12) still drives TOO_MANY_CLAIMS demote until + # quantifier_guard_apply_caps flips True. + "claim_cap_applied": claim_cap_lookup, + # Ticket #000010 — meta-cognition QuestionState. Surfaces + # the full preflight classification (logical_statuses, + # question_shape, contradiction_pairs, false_premise_hints, + # temporal_sensitivity, etc.) for bench / CLI render. First + # pass does NOT bind into run_dag_root (deferred to ticket + # #000009 Phase 5). + "question_state": question_state.to_dict(), + # Ticket #000009 §7.2 — preflight stage hash. Surfaced + # for cross-row preflight-policy comparison in bench / + # operator tools. Same hash that's bound into the + # `preflight` stage of run_dag_root. + "preflight_hash": preflight_hash, + # Ticket #000011 — soft preflight sidecar hint. Advisory + # only; NEVER enters the verifier proof path. Renderer + # surfaces as `· soft: <label>` on the audit-line tail. + "soft_preflight_hint": soft_hint.to_dict(), + # Sidecar smell signals (claim_lattice mode only) — surfaced + # for the renderer; never persisted in providence_cache and + # never threaded into run_dag_root. + "pointer_id_distribution": verdict.get("pointer_id_distribution"), + "lazy_anchor_ratio": verdict.get("lazy_anchor_ratio"), + # Retrieval-purity sidecar (claim_lattice modes; None for + # quote/span/entity/paraphrase). Render-layer + bench-tracking + # metric: surfaces "model ignored N noisy sources, used the + # primary at rank R" without folding into the proof path. + "retrieval_purity": proof_obj.get("retrieval_purity"), + "prompt_chars": prompt_chars, + "answer_chars": len(answer_text or ""), + # Frame detection (Ticket #000002 / Module L). Sidecar signal + # for renderer / bench; never persisted in providence_cache, + # never enters governance_policy_hash. None for quote-mode + # rows; populated for lattice-mode rows. + "frame_detection": ( + { + "kind": frame_detection.frame_kind, + "reference_title": frame_detection.reference_title, + "reference_uri": frame_detection.reference_uri, + "confidence": frame_detection.confidence, + } + if frame_detection is not None + else None + ), + "timings": { + "preflight_ms": preflight_ms, + "soft_preflight_ms": soft_preflight_ms, + "search_ms": search_ms, + "context_ms": context_ms, + "cache_lookup_ms": cache_lookup_ms, + "llm_ms": llm_ms, + "persist_ms": persist_ms, + "total_ms": _ms_since(t_start), + }, + }
+ + + +def _ms_since(t: float) -> float: + """Wall-time elapsed in milliseconds, rounded to 1 decimal.""" + return round((time.monotonic() - t) * 1000, 1) +
+
+
+
+ + +
+
+ + Made with Sphinx and @pradyunsg's + + Furo + +
+
+ +
+
+ +
+
+ +
+
+ + + + + \ No newline at end of file diff --git a/docs/_source/_build/html/_modules/aborist/qa/runner.html b/docs/_source/_build/html/_modules/aborist/qa/runner.html new file mode 100644 index 0000000..d77cdb6 --- /dev/null +++ b/docs/_source/_build/html/_modules/aborist/qa/runner.html @@ -0,0 +1,1348 @@ + + + + + + + + aborist.qa.runner - Aborist API Reference + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + + + +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for aborist.qa.runner

+"""Q&A runner: cache-first lookup -> inference fallback -> provable record.
+
+Implements the v9.8 admissibility invariant:
+    No record reused unless all 8 cache_key dimensions match AND state
+    is 'live' (not failed/stale/quarantined).
+
+Cache hit  -> persisted audit_mode (STRICT/HYBRID/UNGROUNDED).
+Cache miss -> call ChatClient, run faithfulness check, classify, store
+              record, audit event.
+"""
+
+from __future__ import annotations
+
+import json
+import sqlite3
+import time
+
+from aborist import (
+    CANONICALIZATION_VERSION,
+    SCHEMA_VERSION,
+)
+from aborist.compress import unpack_chunk
+from aborist.merkle import MerkleTree, proof_to_dict
+from aborist.qa.client import ChatClient
+from aborist.qa.prompts import (
+    CLAIM_LATTICE_GROUNDING_REMINDER,
+    CLAIM_LATTICE_JSON_GROUNDING_REMINDER,
+    CLAIM_LATTICE_JSON_SYSTEM_PROMPT,
+    CLAIM_LATTICE_SYSTEM_PROMPT,
+)
+from aborist.qa.keys import (
+    DEFAULT_FIDELITY,
+    DEFAULT_QUESTION_DEDUP,
+    FIDELITY_MODES,
+    QUESTION_DEDUP_MODES,
+    cache_key,
+    canonical_question,
+    conversation_hash,
+    governance_policy_hash,
+    model_profile_hash,
+    question_hash,
+    verifier_policy_hash,
+)
+from aborist.qa.dag import build_run_dag
+from aborist.qa.evidence import (
+    build_evidence_map,
+    evidence_map_root,
+    render_evidence_map,
+    render_evidence_map_for_json,
+)
+from aborist.qa.repair import mechanical_repair, reprompt_repair
+from aborist.qa.verify import (
+    ANSWER_MODES,
+    CLAIM_LATTICE_JSON_SCHEMA,
+    DEFAULT_ANSWER_MODE,
+    verify_claim_lattice,
+    verify_claim_lattice_json,
+    verify_quotes,
+)
+from aborist.store import append_audit, transaction
+
+try:
+    from aborist.wikitext import BASE_VERSION as _WIKITEXT_BASE_VERSION
+    from aborist.wikitext import to_base as _wikitext_to_base
+except ImportError:  # pragma: no cover
+    _WIKITEXT_BASE_VERSION = None
+    _wikitext_to_base = None
+
+
+DEFAULT_POLICY = {
+    # Ticket #000007 — query-layer hyphen-fold marker. See
+    # aborist/qa/query.py:DEFAULT_QUERY_POLICY for rationale.
+    "hyphen_fold_v1": True,
+    # Ticket #000006 amend 2026-05-02b (Rule 9). See
+    # aborist/qa/query.py:DEFAULT_QUERY_POLICY for full rationale.
+    "claim_lattice_subject_tokens_absent_threshold": 3,
+    "system_prompt": (
+        "Answer the user's question based ONLY on the document below. "
+        "For EVERY factual claim, include a verbatim quote from the "
+        "document enclosed in double quotes (\"...\"). The quoted span "
+        "must appear word-for-word. Make a claim only when a verbatim "
+        "quote in the document directly supports it. "
+        "If the answer is in the document, write it. "
+        "If the answer is absent from the document, say 'I don't know "
+        "based on the provided document.' and stop there. "
+        "Stay inside the document at all times."
+    ),
+    # Restated rule fired as a user message right before the document +
+    # question arrive. See aborist/qa/query.py for the rationale (recent
+    # user-turn instructions outweigh decayed system-turn rules in 8B
+    # instruction-tuned models).
+    "grounding_reminder": (
+        "REMINDER: wrap every factual claim in double quotes (\"...\") "
+        "and the quoted span must appear word-for-word in the "
+        "document. Each claim earns a verbatim quote. "
+        "Now answer the question on the next message."
+    ),
+    "temperature": 0.1,
+    "top_p": 1.0,
+    "max_tokens": 512,
+    "entity_policy": "proximity",
+    "entity_proximity_n": 3,
+    "entity_proximity_window": 300,
+    # Mechanical answer repair after first verify. Off by default; see
+    # aborist/qa/query.py for semantics.
+    "repair_enabled": False,
+    "repair_max_reprompts": 0,
+    # Strip wikitext markup before the LLM ever sees the context. Lets
+    # Hermes quote prose verbatim and shrinks token bills (~43% on
+    # Wikipedia chunks). Bumps governance_policy_hash so prior cached
+    # answers under raw-wikitext policy stay distinct on lookup. Set
+    # via the wikitext extras; no-op if mwparserfromhell isn't installed.
+    "base_version": _WIKITEXT_BASE_VERSION,
+    # G0 / CTI — claim-lattice-pointer answer mode. "quote" (default):
+    # existing behavior, model writes prose with verbatim quotes inline.
+    # "claim_lattice_pointer": runtime builds an evidence map and shows
+    # the model short pointer ids (E1, E2, …); model writes natural
+    # prose with bracket pointer tags ("Claim. [E12]") instead of
+    # quoting source text. Renderer interpolates literal spans at
+    # display time. Synthetic-elision-by-construction-impossible: the
+    # model never types the quote string. Two-layer id discipline keeps
+    # the cache & run-DAG keyed on content-addressed evidence_ids.
+    # Folds into governance_policy_hash so two modes write under
+    # different cache_keys and never alias. No iterative repair in
+    # pointer mode (one-shot benchmark discipline).
+    "answer_mode": DEFAULT_ANSWER_MODE,
+    "claim_lattice_system_prompt": CLAIM_LATTICE_SYSTEM_PROMPT,
+    "claim_lattice_grounding_reminder": CLAIM_LATTICE_GROUNDING_REMINDER,
+    # Allowed source roles for claim-lattice verification. Roles outside
+    # this set get classified SOURCE_ROLE_BLOCKED and downgrade the
+    # verdict. Mirrors aborist.qa.verify.DEFAULT_ALLOWED_SOURCE_ROLES;
+    # noisy_background_source / sequel_background_source are excluded by
+    # default. Folds into governance_policy_hash on change.
+    "claim_lattice_allowed_source_roles": [
+        "primary_answer_source",
+        "secondary_context_source",
+        "background_source",
+        "unclassified",
+        # Self-promoted providence records (`aborist://providence/`
+        # URI scheme). Trusted-as-fact substrate per the
+        # self-reference design — STRICT live records past the
+        # kindergarten window. See
+        # docs/self-reference-design.md for the
+        # falsification trust model.
+        "self_reference_source",
+    ],
+    # Hard cap on pointer ids per claim line — mirrors prompt Rule 9.
+    # Lines exceeding this cap classify as SCHEMA_INVALID and the
+    # verdict can no longer reach STRICT. Folds into
+    # governance_policy_hash so changing the cap invalidates prior
+    # cached records.
+    "claim_lattice_max_pointers_per_claim": 2,
+    # Minimum claim-token coverage required for the citation-overlap
+    # check (Rule 6) to pass. Pre-2026-04-30 the threshold was implicit
+    # at "≥1 shared token", which let through lazy-anchored claims
+    # whose only overlap was a single topical word (e.g. "Yale
+    # University... [E9]" cited to a highway-data span containing only
+    # "Connecticut"). 0.30 means a 10-token claim needs ≥3 of its
+    # content tokens to appear in the cited span. Short claims (≤3
+    # content tokens) keep the old ≥1-token floor so narrow factoids
+    # like "Steve Jobs co-founded Apple" still pass. Folds into
+    # governance_policy_hash on change.
+    "claim_lattice_min_citation_coverage": 0.30,
+    # Bare-name claim guard. A claim with fewer than this many content
+    # tokens (>=4 chars, post-spotlight-stopword) is rejected as
+    # SCHEMA_INVALID. Catches the JP-dinosaurs lazy-anchor where
+    # "Triceratops. [E16]" passes lexical overlap on a single token
+    # even when E16 is a video-game tie-in chunk rather than the film
+    # article. Default 2: bare-entity-name claims (one content token
+    # after stopword strip) fail; sentence-shape claims pass. Note
+    # ``_content_tokens`` already filters "appears", "shown", etc. so
+    # "Trex appears" → 1 content token (filtered), "Trex appears in
+    # the film" → 2 content tokens (passes). Folds into
+    # governance_policy_hash on change.
+    "claim_lattice_min_claim_content_tokens": 2,
+    # Lazy-anchor smell auto-demote. When >= threshold of verified
+    # pointer-pairs cite a single pointer AND there are >= min_pairs
+    # total, cap audit_mode at HYBRID. The smell sidecar was advisory
+    # only pre-2026-04-30; now it's load-bearing. STRICT requires
+    # diverse anchoring across pointers.
+    "claim_lattice_lazy_anchor_demote_threshold": 0.5,
+    "claim_lattice_lazy_anchor_demote_min_pairs": 3,
+    # Warrant-lite — relation-question hard check (Ticket H from
+    # feedback-3, 2026-05-01). Detects relation-shape questions
+    # ("who is X's boss?", "who founded Y?") and requires the cited
+    # span to contain at least one named answer entity (proper-noun
+    # phrase) from the claim. Catches the Homer-Simpson lazy-anchor
+    # case where claim asserts "Mr. Burns" but cited span is the
+    # voice-actor bio. WARRANT_MISSING violations cap audit_mode
+    # at HYBRID. See aborist/qa/warrant.py.
+    "claim_lattice_warrant_check_enabled": True,
+    "claim_lattice_deflection_check_enabled": True,
+    # Format-collapse check (pointer-mode only): when the model emits
+    # ≥5 meaningful prose lines with zero `[E\d+]` pointer tags, it
+    # abandoned the claim_lattice_pointer protocol entirely. Soft-demote
+    # so audit display surfaces "format collapsed" vs "graceful per-
+    # claim refusal" — different failure shapes, same UNGROUNDED rung.
+    # JSON-mode collapse already shows up as SCHEMA_INVALID so this
+    # check is redundant there. Surfaced 2026-05-02 by fox's "Winners
+    # of all major sports?" case where Hermes dumped 50+ free-form
+    # sentences.
+    "claim_lattice_format_collapse_check_enabled": True,
+    # Quantifier preflight guard (Ticket #000008 Phase 2). Per-call
+    # claim cap derived from the question's quantifier intensity and
+    # the configured model profile (aborist/qa/model_profiles.py).
+    # Phase 2 lands the lookup wiring with apply_caps=False per
+    # §10.11.3 dry-run discipline — claim_cap_applied is computed
+    # and reported on the result dict, but the verifier still uses
+    # claim_lattice_max_claims_per_answer as the actual cap.
+    # Operator flips quantifier_guard_apply_caps=True after dry-run
+    # bench review confirms classifier output across the full
+    # question set.
+    #
+    # Six-level disable hierarchy (§10.11.2):
+    #   - quantifier_guard_enabled: master kill (False = no
+    #     classifier output, no cap lookup, no telemetry).
+    #   - quantifier_guard_apply_caps: dry-run gate (True = cap
+    #     applied; False = cap reported but not applied).
+    #   - quantifier_caps_by_intensity: per-call override dict;
+    #     wins over the model_profiles.py table when present.
+    #   - quantifier_guard_modes: list of answer_modes the guard
+    #     applies to. Quote mode opts out by default — already
+    #     stable HYBRID 0.455 on baseline, different failure shape.
+    "quantifier_guard_enabled": True,
+    "quantifier_guard_apply_caps": False,
+    # When apply_caps flips True, this allowlist gates which modes
+    # actually have caps applied. n=5 verification 2026-05-03 (#000008
+    # §12.10): cap on claim_lattice (JSON) wins +14pp on STRICT-rate;
+    # cap on claim_lattice_pointer fires TOO_MANY_CLAIMS 20× without
+    # moving the verdict floor (still 0 STRICT). Default to JSON only
+    # so flipping the master switch doesn't add wasted cap-noise on
+    # pointer mode. Empty list / None = honor quantifier_guard_modes
+    # (legacy fallback).
+    "quantifier_apply_caps_modes": ["claim_lattice"],
+    "quantifier_caps_by_intensity": {},
+    "quantifier_guard_modes": ["claim_lattice_pointer", "claim_lattice"],
+    # Phase 3 — broad-quantifier reminder injection. Default ON for
+    # lattice modes (gated via quantifier_guard_modes) per the
+    # 2026-05-03 bench A/B (#000008 §12). Reminder eliminates
+    # FORMAT_COLLAPSED (2→0), reduces NO_EVIDENCE_POINTER 33%,
+    # boosts mean ratio +17pp on pointer / +21pp on JSON, and
+    # rescues JSON UNGROUNDED 7→1. n=5 verification 2026-05-03
+    # confirms the compound effect with cap survives at higher
+    # sample size. Quote mode is mode-gated off (different failure
+    # shape; paraphrase verifier doesn't need pointer-tag reminders).
+    "quantifier_reminder_enabled": True,
+    # Phase 4 — strict reject for broad-unbounded queries. When True
+    # AND intensity ∈ {ALL, COMPREHENSIVE, OPEN_REQUEST} AND
+    # scope_bound_hint == "unbounded", query()/ask() return UNGROUNDED
+    # before the LLM call with a BROAD_QUANTIFIER_REJECTED violation.
+    # Saves the ~10-15s LLM call on rejected runs. Default OFF — opt-in
+    # via --reject-broad CLI flag or per-call policy override.
+    # Bounded universals (e.g. all members of the Beatles, year-anchored
+    # questions) are NOT rejected per §10.1.
+    "quantifier_reject_broad": False,
+    # Ticket #000010 — Meta-Cognition Preflight Guard (M0 / MCTL).
+    # Pure deterministic detectors (temporal, contradiction,
+    # false-premise-lite, out-of-corpus) wrap the #000008 quantifier
+    # classifier and surface a QuestionState on the result dict.
+    # Master switch ON by default — detectors are pure-on-question
+    # so cost is negligible. Each sub-detector has its own enable
+    # switch for granular A/B. block_on_contradiction defaults False
+    # (label-only by default; opt-in to hard-block — false-positive
+    # rate not yet bench-validated).
+    "metacognition_enabled": True,
+    "metacognition_temporal_check": True,
+    "metacognition_contradiction_check": True,
+    "metacognition_false_premise_check": True,
+    "metacognition_out_of_corpus_check": True,
+    "metacognition_block_on_contradiction": False,
+    # Ticket #000011 — soft preflight sidecar. Default OFF —
+    # adds one short LLM round-trip (~200ms median) so cost is
+    # operator-opt-in only. NEVER enters the verifier proof path
+    # (D1); produces only SOFT_* labels that surface as advisory
+    # hints alongside the deterministic detector output.
+    "soft_preflight_enabled": False,
+    # Claim-count ceiling. Bench finding (2026-04-30 york-england):
+    # "tell me all there is to know about X" prompted Hermes to spam
+    # 26-59 encyclopedic claims sourced from training, only 2-4 of
+    # which grounded in retrieval. Atomic-claim prompt rule (b5925c8)
+    # cut this to ~10, but a hard structural cap is defense in depth.
+    # Cap of 12 admits typical entity-list questions (5-7 dinosaurs,
+    # Simpsons + pets) while flagging the runaway shape. Folds into
+    # governance_policy_hash on change.
+    "claim_lattice_max_claims_per_answer": 12,
+    # JSON variant — `answer_mode="claim_lattice"`. Mirrors the
+    # multi-source query path. Pairs with grammar-constrained inference
+    # (vLLM guided_json, Claude/GPT-4 native JSON, Qwen 3.6 reasoner).
+    # Lenient pre-parser in verify_claim_lattice_json keeps the path
+    # survivable on inference paths without grammar guidance.
+    "claim_lattice_json_system_prompt": CLAIM_LATTICE_JSON_SYSTEM_PROMPT,
+    "claim_lattice_json_grounding_reminder": CLAIM_LATTICE_JSON_GROUNDING_REMINDER,
+    "claim_lattice_use_guided_json": True,
+    # JSON-mode stop sequences. Hermes-3-8B sometimes spams whitespace
+    # / newlines after the closing brace on broad-descriptive shapes
+    # ("plot of X", "tell me about Y") — the response runs out the
+    # max_tokens budget and the lenient parser sees truncated JSON.
+    # Stopping on a blank line cuts the runaway. JSON-mode output
+    # never legitimately contains a blank line (single object, single
+    # line) so this is a safe filter. Folds into
+    # governance_policy_hash on change.
+    "claim_lattice_json_stop_sequences": ["\n\n"],
+}
+
+
+def _ms_since(t: float) -> float:
+    return round((time.monotonic() - t) * 1000, 1)
+
+
+
+[docs] +def ask( + conn: sqlite3.Connection, + *, + document_root: str, + question: str, + client: ChatClient, + model_id: str, + revision: str = "", + quantization: str = "", + policy: dict | None = None, + chain: str = "private", + fidelity: str | None = None, +) -> dict: + """Look up cached answer or run inference. Returns a result dict. + + See ``aborist.qa.query.query`` for `fidelity` semantics — it + controls lookup tolerance: ``"strict"`` only checks the cache_key + matching the call's ``policy["question_dedup"]``; the default + ``"equivalence_class"`` falls back to the alternate dedup mode's + cache_key on miss so a fast-cache agent can reuse records written + under either mode. Result includes ``lookup_path``. + """ + policy = policy or DEFAULT_POLICY + if fidelity is None: + fidelity = policy.get("fidelity", DEFAULT_FIDELITY) + if fidelity not in FIDELITY_MODES: + raise ValueError( + f"fidelity must be one of {FIDELITY_MODES}, got {fidelity!r}" + ) + # Quantifier preflight (Ticket #000008 Phase 1+2). Same wiring + # as query() — see aborist/qa/query.py for the rationale and + # disable hierarchy. + from aborist.qa.model_profiles import cap_for_intensity + from aborist.qa.quantifier import classify_question_quantifier + answer_mode_for_guard = policy.get("answer_mode", "quote") + quantifier_guard_on = bool(policy.get("quantifier_guard_enabled", True)) + quantifier_guard_modes = policy.get( + "quantifier_guard_modes", + ["claim_lattice_pointer", "claim_lattice"], + ) + quantifier_mode_gated = answer_mode_for_guard in (quantifier_guard_modes or []) + if quantifier_guard_on: + quantifier = classify_question_quantifier(question) + else: + quantifier = { + "intensity": None, + "matched_token": None, + "explicit_count": None, + "is_broad": False, + "operational_shape": None, + "scope_bound_hint": "unknown", + "classifier_version": None, + } + if quantifier_guard_on and quantifier_mode_gated and quantifier["intensity"]: + claim_cap_lookup = cap_for_intensity( + model_profile_id=model_id, + intensity=quantifier["intensity"], + explicit_count=quantifier["explicit_count"], + policy_overrides=policy.get("quantifier_caps_by_intensity") or None, + ) + else: + claim_cap_lookup = None + quantifier_apply_caps = bool(policy.get("quantifier_guard_apply_caps", False)) + quantifier_apply_caps_modes = policy.get( + "quantifier_apply_caps_modes", + quantifier_guard_modes, # legacy fallback + ) or quantifier_guard_modes + quantifier_caps_mode_gated = answer_mode_for_guard in ( + quantifier_apply_caps_modes or [] + ) + _policy_max_claims = int(policy.get("claim_lattice_max_claims_per_answer", 12)) + if ( + quantifier_apply_caps + and quantifier_caps_mode_gated + and claim_cap_lookup is not None + ): + effective_max_claims = int(claim_cap_lookup) + else: + effective_max_claims = _policy_max_claims + # Ticket #000010 — meta-cognition preflight (mirror of query()). + from aborist.qa.metacognition import preflight_question + question_state = preflight_question( + question, + model_profile_id=model_id, + reference_frames=(), + policy=policy, + ) + t_start = time.monotonic() + + doc = conn.execute( + "SELECT document_uri, chunking_version FROM documents " + "WHERE document_root = ?", + (document_root,), + ).fetchone() + if doc is None: + return {"status": "unknown_document"} + + chunk_rows = conn.execute( + "SELECT idx, leaf_hash, content FROM chunks " + "WHERE document_root = ? ORDER BY idx ASC", + (document_root,), + ).fetchall() + if not chunk_rows: + return {"status": "unknown_document"} + if any(r["content"] is None for r in chunk_rows): + return {"status": "source_cold", "msg": "rehydrate before asking"} + + answer_mode = policy.get("answer_mode", DEFAULT_ANSWER_MODE) + if answer_mode not in ANSWER_MODES: + raise ValueError( + f"policy['answer_mode'] must be one of {ANSWER_MODES}, got {answer_mode!r}" + ) + + chunk_texts = [unpack_chunk(r["content"]) for r in chunk_rows] + document_text = "\n\n".join(chunk_texts) + + # Wikitext → prose before the LLM sees it. The model can then quote + # verbatim against the prose form; the verifier compares like-against- + # like. Idempotent if context is already plain prose. Gated on + # policy["base_version"] so this is part of governance_policy_hash. + if policy.get("base_version") and _wikitext_to_base is not None: + document_text = _wikitext_to_base(document_text) + chunk_texts = [_wikitext_to_base(t) for t in chunk_texts] + + evidence_map = [] + if answer_mode == "claim_lattice_pointer": + # Quote-by-pointer: one evidence object per chunk. The model sees + # the literal spans labeled with content-addressed IDs and is + # instructed to reference IDs, not type quote text. Synthetic + # elision is impossible by construction — the model never produces + # the quote string. + chunks_for_map = [ + { + "source_root": document_root, + "document_uri": doc["document_uri"], + "title": None, + "chunk_idx": r["idx"], + "chunk_root": r["leaf_hash"], + "span": chunk_texts[i], + "source_role": "primary_answer_source", + } + for i, r in enumerate(chunk_rows) + ] + evidence_map = build_evidence_map(chunks_for_map) + sys_prompt = policy["claim_lattice_system_prompt"] + grounding_reminder = policy.get("claim_lattice_grounding_reminder") + rendered_evidence = render_evidence_map(evidence_map) + + def _user_payload(q: str) -> str: + return f"EVIDENCE:\n\n{rendered_evidence}\n\n---\n\nQUESTION: {q}" + elif answer_mode == "claim_lattice": + # JSON variant — same per-chunk evidence map as pointer mode, + # blocks labeled with content-addressed evidence_id (long hex) + # since the model emits IDs in JSON. Pairs with grammar- + # constrained inference; lenient pre-parser handles drift. + chunks_for_map = [ + { + "source_root": document_root, + "document_uri": doc["document_uri"], + "title": None, + "chunk_idx": r["idx"], + "chunk_root": r["leaf_hash"], + "span": chunk_texts[i], + "source_role": "primary_answer_source", + } + for i, r in enumerate(chunk_rows) + ] + evidence_map = build_evidence_map(chunks_for_map) + sys_prompt = policy.get( + "claim_lattice_json_system_prompt", + policy["claim_lattice_system_prompt"], + ) + grounding_reminder = policy.get( + "claim_lattice_json_grounding_reminder", + policy.get("claim_lattice_grounding_reminder"), + ) + rendered_evidence = render_evidence_map_for_json(evidence_map) + + def _user_payload(q: str) -> str: + return f"EVIDENCE:\n\n{rendered_evidence}\n\n---\n\nQUESTION: {q}" + else: + sys_prompt = policy["system_prompt"] + grounding_reminder = policy.get("grounding_reminder") + + def _user_payload(q: str) -> str: + return f"Document:\n\n{document_text}\n\n---\n\nQuestion: {q}" + + # System sets the policy; a user-turn reminder restates the rule one + # message before the payload arrives. Payload (document or evidence + # map + question) lands last as the most-recent tokens before + # generation. + messages = [{"role": "system", "content": sys_prompt}] + if grounding_reminder: + messages.append({"role": "user", "content": grounding_reminder}) + # Quantifier-specific reminder (Ticket #000008 Phase 3, default + # off). When the question is broad (ALL/COMPREHENSIVE/OPEN_ + # REQUEST) and the operator opted in via + # quantifier_reminder_enabled=True, append a one-line reminder + # restating the cap and the no-prior-enumeration rule. + # quantifier_reminder_enabled defaults to False because Hermes-3-8B + # already ignores parts of the existing reminder under enumeration + # pressure (§3 Option B con); empirical effect requires bench + # measurement before flipping default-on (§10.8 decision tree). + if ( + quantifier_guard_on + and quantifier_mode_gated + and quantifier.get("is_broad") + and bool(policy.get("quantifier_reminder_enabled", False)) + ): + from aborist.qa.quantifier_reminder import broad_quantifier_reminder + broad = broad_quantifier_reminder( + intensity=quantifier["intensity"], + cap=effective_max_claims, + scope_bound_hint=quantifier["scope_bound_hint"], + ) + if broad: + messages.append({"role": "user", "content": broad}) + messages.append({"role": "user", "content": _user_payload(question)}) + + mhash = model_profile_hash(model_id, revision, quantization) + + # Dedup-mode-aware cache_key. See aborist/qa/query.py for rationale — + # policy_variant matches the alternate mode so governance_policy_hash + # agrees with what an agent under that mode would have written, + # enabling cross-silo fallback. + def _ckey_for_mode(mode: str) -> str: + canon_q = canonical_question(question, mode=mode) + canon_msgs = list(messages[:-1]) + [ + {"role": "user", "content": _user_payload(canon_q)}, + ] + policy_variant = dict(policy, question_dedup=mode) + return cache_key( + document_root, + question_hash(question, mode=mode), + mhash, + conversation_hash(canon_msgs), + governance_policy_hash(policy_variant), + SCHEMA_VERSION, + CANONICALIZATION_VERSION, + doc["chunking_version"], + verifier_policy_hash(policy_variant), + ) + + ghash = governance_policy_hash(policy) # for the legacy INSERT below + + primary_dedup = policy.get("question_dedup", DEFAULT_QUESTION_DEDUP) + if primary_dedup not in QUESTION_DEDUP_MODES: + raise ValueError( + f"policy['question_dedup'] must be one of {QUESTION_DEDUP_MODES}, " + f"got {primary_dedup!r}" + ) + # Re-derive the per-mode hashes for use in the INSERT below. _ckey_for_mode + # already builds them, but the legacy INSERT references qhash/chash by name. + qhash = question_hash(question, mode=primary_dedup) + canonical_q_primary = canonical_question(question, mode=primary_dedup) + canonical_messages_primary = list(messages[:-1]) + [ + {"role": "user", "content": _user_payload(canonical_q_primary)}, + ] + chash = conversation_hash(canonical_messages_primary) + primary_ckey = _ckey_for_mode(primary_dedup) + ckey = primary_ckey # legacy name for the rest of the function + + t_lookup = time.monotonic() + cached = conn.execute( + "SELECT * FROM providence_cache " + "WHERE cache_key = ? AND falsification_state = 'live'", + (primary_ckey,), + ).fetchone() + hit_ckey = primary_ckey + lookup_path = primary_dedup if cached is not None else None + if cached is None and fidelity == "equivalence_class": + other_mode = ( + "equivalence_class" if primary_dedup == "strict" else "strict" + ) + other_ckey = _ckey_for_mode(other_mode) + if other_ckey != primary_ckey: + cached = conn.execute( + "SELECT * FROM providence_cache " + "WHERE cache_key = ? AND falsification_state = 'live'", + (other_ckey,), + ).fetchone() + if cached is not None: + hit_ckey = other_ckey + lookup_path = f"{other_mode}_fallback" + cache_lookup_ms = _ms_since(t_lookup) + if cached is not None: + with transaction(conn): + now = int(time.time()) + conn.execute( + "UPDATE providence_cache " + "SET hit_count = hit_count + 1, last_hit_at = ? " + "WHERE cache_key = ?", + (now, hit_ckey), + ) + return { + "status": "cache_hit", + "audit_mode": cached["audit_mode"], + "cache_key": hit_ckey, + "lookup_path": lookup_path, + "source_root": document_root, + "answer_text": cached["answer_text"], + "merkle_proof": json.loads(cached["merkle_proof"]), + "n_quotes": cached["n_quotes"], + "n_verified": cached["n_verified"], + "verifier_method": cached["verifier_method"], + "unverified_quotes": ( + json.loads(cached["unverified_quotes"]) + if cached["unverified_quotes"] + else [] + ), + "partially_verified_quotes": [], + # Quantifier preflight (Ticket #000008 Phase 1+2). Pure + # on the question string, so cache hits re-classify + # cheaply and carry the same schema as miss-path rows. + "quantifier_intensity": quantifier["intensity"], + "quantifier_matched_token": quantifier["matched_token"], + "scope_bound_hint": quantifier["scope_bound_hint"], + "quantifier_explicit_count": quantifier["explicit_count"], + "claim_cap_applied": claim_cap_lookup, + # Ticket #000010 — meta-cognition QuestionState. + "question_state": question_state.to_dict(), + "timings": { + "cache_lookup_ms": cache_lookup_ms, + "llm_ms": None, + "total_ms": _ms_since(t_start), + }, + } + + t_llm = time.monotonic() + # JSON mode: pass guided_json schema so vLLM constrains output at + # sampling time. Endpoints without guided-decoding silently drop the + # field; the lenient pre-parser handles whatever drift remains. + extra_body: dict | None = None + stop_seqs: list[str] | None = None + if answer_mode == "claim_lattice" and policy.get( + "claim_lattice_use_guided_json", True + ): + extra_body = {"guided_json": CLAIM_LATTICE_JSON_SCHEMA} + if answer_mode == "claim_lattice": + # JSON-mode token-runaway guard. On broad-descriptive / + # comparison questions Hermes-3-8B sometimes spams whitespace + # / newlines after the closing brace until max_tokens + # exhausts; the resulting truncated payload won't parse and + # the run lands UNGROUNDED 0/0 at 12-15s instead of 2-4s. + # Stopping on a blank line (\n\n) cuts the runaway — + # well-formed JSON-mode output never contains a blank line + # since the model emits a single object on one line (or + # with simple internal newlines). + stop_seqs = list(policy.get( + "claim_lattice_json_stop_sequences", ["\n\n"] + )) + raw_answer = client.chat_completion( + messages, + model=model_id, + temperature=policy["temperature"], + max_tokens=policy["max_tokens"], + top_p=policy.get("top_p", 1.0), + extra_body=extra_body, + stop=stop_seqs, + ) + llm_ms = _ms_since(t_llm) + + repair_changes: list[dict] = [] + pre_repair_verdict: dict | None = None + + if answer_mode == "claim_lattice_pointer": + verdict = verify_claim_lattice( + raw_answer, + evidence_map, + allowed_source_roles=tuple( + policy.get( + "claim_lattice_allowed_source_roles", + [ + "primary_answer_source", + "secondary_context_source", + "background_source", + "unclassified", + ], + ) + ), + max_pointers_per_claim=int(policy.get( + "claim_lattice_max_pointers_per_claim", 2 + )), + min_citation_coverage=float(policy.get( + "claim_lattice_min_citation_coverage", 0.30 + )), + min_claim_content_tokens=int(policy.get( + "claim_lattice_min_claim_content_tokens", 3 + )), + lazy_anchor_demote_threshold=float(policy.get( + "claim_lattice_lazy_anchor_demote_threshold", 0.5 + )), + lazy_anchor_demote_min_pairs=int(policy.get( + "claim_lattice_lazy_anchor_demote_min_pairs", 3 + )), + max_claims_per_answer=effective_max_claims, + subject_tokens_absent_threshold=int(policy.get( + "claim_lattice_subject_tokens_absent_threshold", 3 + )), + question=question, + warrant_check_enabled=bool(policy.get( + "claim_lattice_warrant_check_enabled", True + )), + deflection_check_enabled=bool(policy.get( + "claim_lattice_deflection_check_enabled", True + )), + format_collapse_check_enabled=bool(policy.get( + "claim_lattice_format_collapse_check_enabled", True + )), + ) + # Rendered prose (literal spans interpolated) is the user-facing + # answer text — never the model's raw pointer-line output. If + # rendering produced nothing (no valid claims), persist the raw + # output so an operator can see what the model actually said. + rendered = verdict["rendered_text"] + answer_text = rendered if rendered else raw_answer + elif answer_mode == "claim_lattice": + verdict = verify_claim_lattice_json( + raw_answer, + evidence_map, + allowed_source_roles=tuple( + policy.get( + "claim_lattice_allowed_source_roles", + [ + "primary_answer_source", + "secondary_context_source", + "background_source", + "unclassified", + ], + ) + ), + max_evidence_per_claim=int(policy.get( + "claim_lattice_max_pointers_per_claim", 2 + )), + min_citation_coverage=float(policy.get( + "claim_lattice_min_citation_coverage", 0.30 + )), + max_claims_per_answer=effective_max_claims, + subject_tokens_absent_threshold=int(policy.get( + "claim_lattice_subject_tokens_absent_threshold", 3 + )), + question=question, + warrant_check_enabled=bool(policy.get( + "claim_lattice_warrant_check_enabled", True + )), + deflection_check_enabled=bool(policy.get( + "claim_lattice_deflection_check_enabled", True + )), + ) + rendered = verdict["rendered_text"] + answer_text = rendered if rendered else raw_answer + else: + answer_text = raw_answer + verdict = verify_quotes( + answer_text, + document_text, + entity_policy=policy.get("entity_policy", "hybrid"), + proximity_n=policy.get("entity_proximity_n", 3), + proximity_window=policy.get("entity_proximity_window", 300), + ) + + def _verify(text: str) -> dict: + return verify_quotes( + text, + document_text, + entity_policy=policy.get("entity_policy", "hybrid"), + proximity_n=policy.get("entity_proximity_n", 3), + proximity_window=policy.get("entity_proximity_window", 300), + ) + + if ( + policy.get("repair_enabled") + and verdict["audit_mode"] != "STRICT" + and verdict.get("unverified_quotes") + ): + repair_result = mechanical_repair( + answer_text, verdict["unverified_quotes"], document_text + ) + if repair_result["changes"]: + new_verdict = _verify(repair_result["repaired_text"]) + if new_verdict["n_verified"] >= verdict["n_verified"]: + pre_repair_verdict = verdict + answer_text = repair_result["repaired_text"] + verdict = new_verdict + repair_changes = list(repair_result["changes"]) + + max_reprompts = int(policy.get("repair_max_reprompts", 0)) + for _ in range(max_reprompts): + if ( + verdict["audit_mode"] == "STRICT" + or not verdict.get("unverified_quotes") + ): + break + new_text = reprompt_repair( + chat_client=client, + model_id=model_id, + original_messages=messages, + original_answer=answer_text, + failed_quotes=verdict["unverified_quotes"], + policy=policy, + ) + if not new_text: + break + new_verdict = _verify(new_text) + if new_verdict["n_verified"] > verdict["n_verified"]: + if pre_repair_verdict is None: + pre_repair_verdict = verdict + answer_text = new_text + verdict = new_verdict + repair_changes.append({ + "action": "reprompt_rewrite", + "diagnosis": "model_feedback_loop", + }) + else: + break + + unverified_blob = ( + json.dumps(verdict["unverified_quotes"], separators=(",", ":")) + if verdict["unverified_quotes"] + else None + ) + + leaves = [bytes.fromhex(r["leaf_hash"]) for r in chunk_rows] + tree = MerkleTree.build(leaves) + proof_obj = { + "document_root": document_root, + "chunk_0_proof": proof_to_dict(tree.proof(0)), + } + proof_blob = json.dumps(proof_obj, separators=(",", ":")) + + # Per-run Merkle-DAG (see aborist/qa/dag.py). Single-doc shape: + # the only "source" is document_root. Quote mode: 7 stages base + # (8 with #000009 preflight). Pointer mode: 9 stages base (10 with + # preflight); context drops out and answer splits into raw_answer + # / parsed_claim_lattice / render. + ev_root = evidence_map_root(evidence_map) if evidence_map else None + parsed_lattice = None + is_lattice_mode = answer_mode in ("claim_lattice_pointer", "claim_lattice") + if is_lattice_mode: + # Per-claim list of {claim_text, content-addressed evidence_ids} + # for the parsed_claim_lattice node hash. Pointer ids are + # run-dependent; evidence_ids are content-addressed → the run- + # DAG hashes the run-stable form. Same shape for JSON and + # pointer; verifier already returns evidence_id_pairs. + evidence_id_pairs = verdict.get("evidence_id_pairs") or [] + parsed_lattice = [ + { + "claim_text": cs.get("text", ""), + "evidence_ids": evidence_id_pairs[i] if i < len(evidence_id_pairs) else [], + } + for i, cs in enumerate(verdict.get("claim_statuses") or []) + ] + # Ticket #000009 — preflight node binding (mirror of query(); + # nested CTI clauses per ticket §8 / 2026-05-04 feedback). + from aborist.qa.dag import preflight_node_hash + # verifier_policy_hash + model_profile_hash imported at module + # top; do NOT re-import locally (free-variable shadowing). + ghash_for_dag = verifier_policy_hash(policy) + claim_cap_actually_applied = ( + claim_cap_lookup + if (quantifier_apply_caps + and quantifier_caps_mode_gated + and claim_cap_lookup is not None) + else None + ) + reminder_eligible = ( + quantifier_guard_on + and quantifier_mode_gated + and quantifier.get("is_broad", False) + ) + reminder_enabled = bool(policy.get("quantifier_reminder_enabled", False)) + reminder_injected = reminder_eligible and reminder_enabled + reminder_template_id = None + if reminder_injected: + reminder_template_id = ( + "broad-quantifier-bounded-v1" + if quantifier.get("scope_bound_hint") == "bounded" + else "broad-quantifier-unbounded-v1" + ) + # Build payload + hash separately so we can persist both into + # run_dag_blob (Ticket #000009 §7.2 — `aborist providence + # --show-preflight` renders the full clause set). + from aborist.qa.dag import ( + _canonical_json as _runner_canon, + _sha256_hex as _runner_sha, + build_preflight_node_payload as _runner_build_payload, + ) + _runner_preflight_payload = _runner_build_payload( + question_state=question_state.to_dict(), + quantifier=quantifier, + answer_contract={ + "guard_enabled": quantifier_guard_on, + "mode_gated": quantifier_mode_gated, + "apply_caps_active": quantifier_apply_caps, + "apply_caps_mode_gated": quantifier_caps_mode_gated, + "claim_cap_resolved": claim_cap_lookup, + "claim_cap_applied": claim_cap_actually_applied, + "manual_quotes_allowed": False, + "evidence_pointer_required": is_lattice_mode, + "allow_unbounded_enumeration": False, + "reject_broad_active": bool( + policy.get("quantifier_reject_broad", False) + ), + "metacognition_enabled": bool( + policy.get("metacognition_enabled", True) + ), + "block_on_contradiction": bool( + policy.get("metacognition_block_on_contradiction", False) + ), + }, + prompt_contract={ + "reminder_enabled": reminder_enabled, + "reminder_injected": reminder_injected, + "reminder_template_id": reminder_template_id, + }, + evidence_contract={ + "max_evidence_ids_exposed": int(policy.get( + "claim_lattice_max_pointers_per_claim", 2 + )), + "one_claim_per_line": is_lattice_mode, + }, + policy_refs={ + "governance_policy_hash": ghash_for_dag, + "model_profile_hash": mhash, + "answer_mode": answer_mode, + }, + ) + preflight_hash = _runner_sha(_runner_canon(_runner_preflight_payload)) + run_dag = build_run_dag( + question_hash=qhash, + sources=[{ + "document_root": document_root, + "source_role": "primary_answer_source", + "score": None, + "chunk_idx": None, + }], + context_root=document_root, + conversation_hash=chash, + answer_text=answer_text, + audit_mode=verdict["audit_mode"], + verifier_method=verdict["verifier_method"], + n_quotes=verdict["n_quotes"], + n_verified=verdict["n_verified"], + claim_statuses=verdict.get("claim_statuses", []), + lookup_path="miss", + evidence_map_root=ev_root, + answer_mode=answer_mode if answer_mode != "quote" else None, + violations=verdict.get("violations"), + raw_answer_text=raw_answer if is_lattice_mode else None, + parsed_lattice=parsed_lattice, + rendered_text=answer_text if is_lattice_mode else None, + preflight_hash=preflight_hash, + preflight_payload=_runner_preflight_payload, + ) + run_dag_blob = json.dumps(run_dag, separators=(",", ":")) + + now = int(time.time()) + with transaction(conn): + if repair_changes and pre_repair_verdict is not None: + append_audit( + conn, + event_type="providence_repair", + subject_root=ckey, + body={ + "kind": "mechanical", + "n_changes": len(repair_changes), + "changes": repair_changes, + "pre_audit_mode": pre_repair_verdict["audit_mode"], + "post_audit_mode": verdict["audit_mode"], + "pre_n_verified": pre_repair_verdict["n_verified"], + "post_n_verified": verdict["n_verified"], + }, + ts=now, + ) + event_hash = append_audit( + conn, + event_type="providence_write", + subject_root=ckey, + body={ + "source_root": document_root, + "model_id": model_id, + "revision": revision, + "quantization": quantization, + "chunks_in_context": len(chunk_rows), + "answer_chars": len(answer_text), + "audit_mode": verdict["audit_mode"], + "n_quotes": verdict["n_quotes"], + "n_verified": verdict["n_verified"], + "verifier_method": verdict["verifier_method"], + }, + ts=now, + ) + conn.execute( + "INSERT INTO providence_cache " + "(cache_key, source_root, document_uri, question_hash, question_text, " + " answer_text, merkle_proof, model_profile_hash, conversation_hash, " + " governance_policy_hash, schema_version, canonicalization_version, " + " chunking_version, falsification_state, chain, audit_event_hash, " + " created_at, hit_count, audit_mode, n_quotes, n_verified, " + " unverified_quotes, verifier_method, run_dag_root, run_dag_blob) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'live', ?, ?, ?, 0, " + " ?, ?, ?, ?, ?, ?, ?)", + ( + ckey, + document_root, + doc["document_uri"], + qhash, + question, + answer_text, + proof_blob, + mhash, + chash, + ghash, + SCHEMA_VERSION, + CANONICALIZATION_VERSION, + doc["chunking_version"], + chain, + event_hash, + now, + verdict["audit_mode"], + verdict["n_quotes"], + verdict["n_verified"], + unverified_blob, + verdict["verifier_method"], + run_dag["root"], + run_dag_blob, + ), + ) + + from aborist.qa.dag import localize_failure as _localize + failure_stage = _localize( + audit_mode=verdict["audit_mode"], + n_sources=1, # ask() runs against one document + n_quotes=verdict["n_quotes"], + n_verified=verdict["n_verified"], + ) + return { + "status": "cache_miss_then_written", + "audit_mode": verdict["audit_mode"], + "cache_key": ckey, + "run_dag_root": run_dag["root"], + "lookup_path": "miss", + "failure_stage": failure_stage, + "repair_changes": repair_changes, + "pre_repair_audit_mode": ( + pre_repair_verdict["audit_mode"] if pre_repair_verdict else None + ), + "source_root": document_root, + "answer_text": answer_text, + "merkle_proof": proof_obj, + "n_quotes": verdict["n_quotes"], + "n_verified": verdict["n_verified"], + "verifier_method": verdict["verifier_method"], + "unverified_quotes": verdict["unverified_quotes"], + "partially_verified_quotes": verdict.get("partially_verified_quotes") or [], + # Quantifier preflight (Ticket #000008 Phase 1+2). See query.py + # for full rationale; runner.ask carries the same schema for + # CLI-side `aborist ask` parity with `aborist query`. + "quantifier_intensity": quantifier["intensity"], + "quantifier_matched_token": quantifier["matched_token"], + "scope_bound_hint": quantifier["scope_bound_hint"], + "quantifier_explicit_count": quantifier["explicit_count"], + "claim_cap_applied": claim_cap_lookup, + # Ticket #000010 — meta-cognition QuestionState. + "question_state": question_state.to_dict(), + # Sidecar smell signals (claim_lattice mode only) — render- + # layer; never persisted, never in run_dag_root. + "pointer_id_distribution": verdict.get("pointer_id_distribution"), + "lazy_anchor_ratio": verdict.get("lazy_anchor_ratio"), + "timings": { + "cache_lookup_ms": cache_lookup_ms, + "llm_ms": llm_ms, + "total_ms": _ms_since(t_start), + }, + }
+ +
+
+
+
+ + +
+
+ + Made with Sphinx and @pradyunsg's + + Furo + +
+
+ +
+
+ +
+
+ +
+
+ + + + + \ No newline at end of file diff --git a/docs/_source/_build/html/_modules/aborist/qa/verify.html b/docs/_source/_build/html/_modules/aborist/qa/verify.html new file mode 100644 index 0000000..41a92b3 --- /dev/null +++ b/docs/_source/_build/html/_modules/aborist/qa/verify.html @@ -0,0 +1,2434 @@ + + + + + + + + aborist.qa.verify - Aborist API Reference + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + + + +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for aborist.qa.verify

+"""Post-LLM faithfulness check: did the answer ground its claims in context?
+
+Three layered strategies, tried in order. The first one that finds evidence
+classifies the answer. `verifier_method` on the result records which path
+fired so the audit chain stays diagnostic.
+
+    1. quote     model wrapped claims in double quotes per system prompt.
+                 Strongest signal — explicit, verbatim, model-asserted.
+    2. span      no quotes, but bullet/sentence-level lines from the answer
+                 appear verbatim in context. Catches models that quote
+                 inline without "..." marks.
+    3. entity    no quotes and no span match, but multi-word proper-noun
+                 phrases from the answer appear verbatim in context.
+                 Catches the Wikipedia-infobox-to-prose case: the model
+                 paraphrases structure so spans diverge, but every named
+                 entity is intact and grounded.
+
+Each strategy classifies into v9.8's audit-mode trichotomy (RAG-adapted
+vocabulary; substrate calls UNGROUNDED "VISUAL"):
+
+    STRICT      every evidence unit (>=1) verifies verbatim against context
+    HYBRID      some verify, others do not  (mixed source / emergent)
+    UNGROUNDED  no evidence, or none verify (purely emergent)
+
+`unverified_quotes` (kept under that name for schema continuity) collects
+spans the model produced that don't appear in any source — the
+corpus-growth signal mined by `aborist emergent`.
+
+Hard rule (CLAUDE.md "soft hash vs hard hash"): every check is a lexical
+substring test under norm-v1 + lowercase canonicalization. No embeddings,
+no semantic similarity, no fuzzy alignment. The contract is "this token
+sequence either is or isn't in the context."
+
+Wikitext context is run through ``aborist.wikitext.to_base`` before the
+substring test. The corpus stores raw wikitext (so the link graph is
+recoverable from any page), but the LLM produces clean prose. Without
+the strip, every wikilink-carrying source paragraph compares as
+"different surface form" and the verifier wrongly reports UNGROUNDED on
+genuine source-grounded quotes. With the strip, paraphrases of *markup*
+(``[[Cloud]]`` vs ``Cloud``) verify, while paraphrases of *prose* still
+flag honestly. mwparserfromhell is an optional dep; if absent, the
+strip is a no-op and verification falls back to today's behavior.
+"""
+
+from __future__ import annotations
+
+import re
+import unicodedata
+
+from aborist.qa.warrant import warrant_check
+
+# Deferred import: aborist.qa.inspect imports aborist.compress &
+# aborist.store at module load. The verifier doesn't need either
+# until deflection actually runs, so defer to call-site to keep
+# import order clean if anything else imports verify.py.
+try:
+    from aborist.wikitext import to_base as _wikitext_to_base
+except ImportError:  # pragma: no cover
+    _wikitext_to_base = None
+
+
+# Locate every double-quote character (ASCII or curly). Sequential
+# pairing in extract_quotes() turns these into intentional (open, close)
+# pairs: 1st & 2nd char, 3rd & 4th, etc. Pure-regex pairing fails on
+# adjacent quote pairs like `"title" prose "quote"` — the regex captures
+# `prose` as a "quoted span" because every `"` looks like both an opener
+# and a closer to it.
+_QUOTE_CHAR_RE = re.compile(r'["“”]')
+
+# Bullet markers at line start: -, *, +, •, 1., 2), etc.
+_BULLET_RE = re.compile(r'^\s*(?:[-*+•]|\d+[.)])\s+')
+
+# Sentence boundary: punctuation + whitespace + capital letter.
+_SENT_RE = re.compile(r'(?<=[.!?])\s+(?=[A-Z])')
+
+# Multi-word capitalized phrase. Two or more whitespace-separated tokens,
+# each starting with a capital letter (allowing initials like "A.", hyphens
+# like "Carrie-Anne", and trailing lowercase like "Smith"). Matches "Keanu
+# Reeves", "Thomas A. Anderson", "Carrie-Anne Moss", "Agent Smith". Skips
+# single capitalized words to avoid sentence-starter false positives.
+_PROPER_NOUN_RE = re.compile(
+    # Token gap is non-newline whitespace so a phrase never crosses a
+    # paragraph break. Caught a real case where "Joe Pantoliano\n\nThe
+    # sources" matched as one phrase via \s+.
+    r"\b[A-Z][A-Za-z'’\-]*(?:[ \t]+(?:[A-Z]\.|[A-Z][A-Za-z'’\-]+))+\b"
+)
+
+MIN_QUOTE_CHARS = 8
+MIN_SPAN_CHARS = 12
+
+# Trailing parenthetical the model often appends to verbatim source
+# prose, defeating substring match even when the prose itself IS in
+# the corpus. Examples we strip:
+#
+#     "...lightning-based attacks. (Source: https://...)"
+#     "...invented in 1976. (citing Wikipedia)"
+#     "...protagonist of the game. (see Pikachu_(character))"
+#     "...released in 1997 (https://en.wikipedia.org/...)"
+#
+# Conservative regex: only strips a SINGLE trailing parenthetical at
+# end-of-string after optional whitespace, and only when the contents
+# either start with a recognized citation cue OR contain a URL. Refuses
+# to strip parentheticals that look like genuine prose (e.g.
+# "Pikachu (a Pokémon species)").
+_TRAILING_CITATION_RE = re.compile(
+    r"""
+    \s*\(\s*
+    (?:                                           # one of:
+        (?:source|src|citing|see|ref|reference|from)  # citation cue word
+        \s*:?\s*[^()]*                            #   optional content
+      |                                           # OR
+        https?://\S+\s*[^()]*                     # URL-led parenthetical
+    )
+    \s*\)\s*$
+    """,
+    re.IGNORECASE | re.VERBOSE,
+)
+
+
+def _strip_trailing_citation(text: str) -> str:
+    """Drop a trailing `(Source: ...)`-like parenthetical, if any.
+
+    Idempotent: applies once. The model rarely chains citations, so we
+    don't loop. Returns the input unchanged if no match.
+    """
+    return _TRAILING_CITATION_RE.sub("", text).rstrip()
+
+
+# Paraphrase strategy thresholds. The 4th verifier method runs only
+# when quote/span/entity have all failed to classify; it accepts a
+# span as "paraphrase-verified" when token coverage in the source
+# context crosses ``DEFAULT_PARAPHRASE_COVERAGE`` and the span has at
+# least ``DEFAULT_PARAPHRASE_MIN_TOKENS`` content tokens (>4 chars).
+#
+# Soft-signal note: token overlap is a heuristic, NOT byte-equivalence.
+# The hard chain still records `verifier_method = 'paraphrase'` so an
+# auditor can distinguish lexical-verbatim from paraphrase-overlap.
+# A span verified via paraphrase contributes to `n_verified` but the
+# audit_mode trichotomy stays unchanged: STRICT requires all units
+# (quote/span/entity/paraphrase) to verify; HYBRID = some verify some
+# don't; UNGROUNDED = none.
+DEFAULT_PARAPHRASE_COVERAGE = 0.85
+DEFAULT_PARAPHRASE_MIN_TOKENS = 4
+
+# Entity-path policies. Entity-existence in source is circumstantial, not
+# claim-level proof — the model could correctly name entities while making
+# claims around them that came from training. These four policies span the
+# trade-off: max-trust to no-trust.
+#
+#   strict     all entities verify → STRICT (legacy behavior; overclaims)
+#   hybrid     any entity verifies → HYBRID (honest cap; safe default)
+#   drop       skip entity path entirely → UNGROUNDED (most conservative)
+#   proximity  STRICT only if N verified entities cluster within W chars
+#              of each other in context (e.g. an infobox cast list).
+#              Otherwise demotes to HYBRID/UNGROUNDED based on partial match.
+ENTITY_POLICIES = ("strict", "hybrid", "drop", "proximity")
+DEFAULT_ENTITY_POLICY = "proximity"
+
+# Proximity tuning. N entities within W chars of each other.
+DEFAULT_PROXIMITY_N = 3
+DEFAULT_PROXIMITY_WINDOW = 300
+
+# Framing phrases that aren't real claims even when they're in source. We
+# strip these from the answer before span/entity extraction so they don't
+# become noise in unverified_quotes.
+_FRAMING_PREFIXES = (
+    "based on the provided sources",
+    "based on the provided source",
+    "based on the source",
+    "based on the document",
+    "the sources do not",
+    "the source does not",
+    "the document does not",
+    "according to the sources",
+    "according to the source",
+)
+
+
+def _normalize(s: str) -> str:
+    """norm-v1 + lowercase. Same canonicalization as chunk leaf hashing,
+    plus case-folding so the verifier doesn't fail on capitalization drift
+    between source prose and the model's quoted span."""
+    s = unicodedata.normalize("NFC", s)
+    s = " ".join(s.split())
+    return s.lower()
+
+
+def _is_framing(span: str) -> bool:
+    n = _normalize(span)
+    return any(n.startswith(p) for p in _FRAMING_PREFIXES)
+
+
+
+[docs] +def extract_quotes(answer_text: str) -> list[str]: + """Pull double-quoted spans of length >= MIN_QUOTE_CHARS from `answer_text`. + + Sequential pairing: locate every double-quote character, then pair + them as (1st, 2nd), (3rd, 4th), .... Each pair brackets one quoted + span; text between consecutive pairs is the model's own framing + prose (not captured). This is the correct model for adjacent quote + pairs like `"title" prose "quote"` — naive regex matching paired + the close of "title" with the open of "quote" and captured `prose` + as a phantom quote, dragging classifications down to HYBRID + incorrectly. + """ + positions = [m.start() for m in _QUOTE_CHAR_RE.finditer(answer_text)] + quotes: list[str] = [] + for i in range(0, len(positions) - 1, 2): + span = answer_text[positions[i] + 1:positions[i + 1]] + # Strip model-appended citations BEFORE the length gate so a + # `verbatim... (Source: https://...)` span that's >MIN long with + # the citation but short without it doesn't bypass the test. + span = _strip_trailing_citation(span) + if len(span) >= MIN_QUOTE_CHARS: + quotes.append(span) + return quotes
+ + + +
+[docs] +def extract_claim_spans(answer_text: str) -> list[str]: + """Strip bullet markers, split into sentences, drop framing prefixes. + + Returns each non-empty span of length >= MIN_SPAN_CHARS. These are the + "claim units" the model wrote — each one we'll substring-test against + context. + """ + spans: list[str] = [] + for line in answer_text.splitlines(): + stripped = _BULLET_RE.sub("", line).strip() + if not stripped: + continue + for sent in _SENT_RE.split(stripped): + sent = sent.strip().rstrip(".:,;") + # Drop model-appended `(Source: ...)` parentheticals so a + # verbatim sentence doesn't fail substring match because of + # an inserted citation. + sent = _strip_trailing_citation(sent) + if len(sent) < MIN_SPAN_CHARS: + continue + if _is_framing(sent): + continue + spans.append(sent) + return spans
+ + + +
+[docs] +def extract_proper_nouns(answer_text: str) -> list[str]: + """Pull multi-word capitalized phrases. Deduplicated, order preserved. + + Multi-word only — single capitalized words at sentence start are too + noisy ("Based", "Now", "However"). Multi-word phrases like + "Keanu Reeves" or "Thomas A. Anderson" are reliable proper-noun + candidates and substring-test cleanly against source prose or + structured wikitext. + """ + seen: set[str] = set() + out: list[str] = [] + for m in _PROPER_NOUN_RE.findall(answer_text): + if m in seen: + continue + seen.add(m) + out.append(m) + return out
+ + + +def _classify(verified: list[str], unverified: list[str]) -> str: + if verified and not unverified: + return "STRICT" + if verified: + return "HYBRID" + return "UNGROUNDED" + + +def _has_entity_cluster( + verified_entities: list[str], + norm_ctx: str, + n: int, + window: int, +) -> bool: + """Do at least `n` distinct verified entities appear within `window` + chars of each other in `norm_ctx`? Crude proxy for "the source has a + section about these entities" (cast list, infobox, roster) vs + "the source incidentally mentions them in scattered prose." + """ + if len(verified_entities) < n: + return False + positions: list[int] = [] + for e in verified_entities: + idx = norm_ctx.find(_normalize(e)) + if idx >= 0: + positions.append(idx) + if len(positions) < n: + return False + positions.sort() + for i in range(len(positions) - n + 1): + if positions[i + n - 1] - positions[i] <= window: + return True + return False + + +def _check_each(items: list[str], norm_ctx: str) -> tuple[list[str], list[str]]: + verified: list[str] = [] + unverified: list[str] = [] + for it in items: + if _normalize(it) in norm_ctx: + verified.append(it) + else: + unverified.append(it) + return verified, unverified + + +def _token_coverage( + span: str, + norm_ctx: str, + *, + min_token_len: int = 4, +) -> tuple[float, int]: + """Fraction of meaningful tokens from `span` that appear anywhere + in `norm_ctx`. Returns ``(coverage_fraction, content_token_count)``. + + Two filters narrow the token set to topical content: + + 1. ``len(t) >= min_token_len`` (default 4). Excludes ``the / a / an + / of / to / is / in / on / at`` etc. — short function words. + 2. Stopword exclusion. Common English filler 4+ chars long + (``from``, ``with``, ``have``, ``this``, ``that``, ``which``, + ``where``, ``their``, ``would``, ``could``, etc.) match almost + any English text & inflate coverage scores for cases where the + *topical* content is missing. Filtering them tightens the + signal: a span that paraphrases stylistic choice (replaces + ``with`` → ``from``) still scores 1.0 if topical tokens match; + a span where topical tokens are missing scores lower because + the denominator dropped. + + The 0.85 paraphrase-coverage threshold is calibrated for THIS + cleaner signal — lowering the threshold would promote fabrications + (Q1 Batman case: "wealthy/businessman/resides" missing, model- + invented). Tightening the denominator instead keeps the threshold + stable and the discrimination crisp. + + Strip from numerator AND denominator so a span composed entirely + of stopwords (e.g. ``"have been there"``) returns coverage 0.0 + rather than dividing by zero. + """ + nspan = _normalize(span) + # Strip per-token leading/trailing punctuation BEFORE the length + # gate and the stopword filter so `batman,` and `wayne.` line up + # with bare `batman`/`wayne` in context. We don't apply this in + # _normalize() because the substring path needs punctuation- + # preserving canonicalization (a span ending in `."` is a + # different surface from one ending without). + tokens: list[str] = [] + for raw in nspan.split(): + t = raw.strip(_TOKEN_PUNCT_STRIP) + if len(t) < min_token_len: + continue + if t in _ENGLISH_STOPWORDS: + continue + tokens.append(t) + if not tokens: + return (0.0, 0) + present = sum(1 for t in tokens if t in norm_ctx) + return (present / len(tokens), len(tokens)) + + +# Per-token punctuation stripped before the coverage check. Tokens like +# `wayne,` and `bruce!` line up with bare `wayne` / `bruce` in context. +# We don't strip apostrophes (``'``) so possessives stay distinct: +# ``batman's`` is a different content token from ``batman``. +_TOKEN_PUNCT_STRIP = ".,;:!?\"()[]{}" + + +# Common English stopwords of length >= 4 chars. Hand-curated rather +# than imported from NLTK to keep aborist dependency-light and the +# behavior pinned to a known set. Tokens are normalized form +# (lowercase, NFC). Includes auxiliaries, prepositions, pronouns, +# wh-words, conjunctions, and high-frequency adverbs/quantifiers that +# carry little topical signal. +_ENGLISH_STOPWORDS = frozenset({ + # auxiliaries / be-forms (>= 4) + "have", "been", "being", "were", "will", "would", "could", "should", + "might", "must", "shall", + # prepositions / particles (>= 4) + "from", "with", "into", "onto", "upon", "over", "under", "after", + "before", "between", "through", "across", "above", "below", "behind", + "beside", "beyond", "during", "without", "within", "until", "since", + "about", "around", "along", "among", + # demonstratives / pronouns (>= 4) + "this", "that", "these", "those", "their", "them", "they", "there", + "here", "your", "yours", "ours", "mine", + # wh-words (>= 4) + "what", "when", "where", "which", "while", "whom", "whose", + "whoever", "whatever", "wherever", "whenever", + # conjunctions (>= 4) + "because", "although", "however", "therefore", "though", "unless", + # adverbs / quantifiers (>= 4) + "also", "very", "much", "many", "more", "most", "less", "some", + "such", "even", "ever", "just", "only", "than", "then", "still", + "again", "always", "never", "often", "rather", "really", "quite", + "very", "well", "back", "next", + # auxiliaries / discourse (>= 4) + "also", "both", "each", "every", "into", "like", +}) + + +def _is_prose_span(span: str, *, min_lowercase_content: int = 2) -> bool: + """Heuristic: does this span look like prose (eligible for paraphrase + matching) vs. a list of proper nouns (better matched by entity)? + + Counts tokens in the ORIGINAL (pre-normalize) span whose first + letter is lowercase and whose length is >= 4 chars. A pure list + like ``"Keanu Reeves, Laurence Fishburne"`` has zero such tokens — + those go through the entity path. A real sentence like ``"Pikachu + is a species of Pokémon creatures..."`` has multiple ("species", + "creatures", "from", etc.) and qualifies for paraphrase. + + Threshold ``min_lowercase_content=2`` lets through "the cast..." + (1 lowercase content token: "cast") only if more lowercase prose + is present. Lists with a leading "The" don't sneak through. + """ + n_lower = 0 + for tok in span.split(): + # Strip leading/trailing punctuation for the case check (so + # "cast:" still counts as a lowercase token). + core = tok.strip(".,;:!?\"'()[]{}") + if len(core) < 4: + continue + if core[0].islower(): + n_lower += 1 + if n_lower >= min_lowercase_content: + return True + return False + + +def _build_claim_statuses( + *, + verified: list[str] | None = None, + paraphrase: list[str] | None = None, + unverified: list[str] | None = None, + method: str, +) -> list[dict]: + """Per-evidence-unit status list. Three labels drawn from the toy- + Hermes taxonomy: + + VERIFIED_QUOTE unit substring-matched in normalized context + (any of quote/span/entity strategies) + SUPPORTED_PARAPHRASE unit cleared the paraphrase token-coverage + threshold (>=85% topical tokens present) + UNSUPPORTED unit didn't match anything + + Soft-signal labels (QUOTE_INTEGRITY_FAILED, SOURCE_MISMATCH, + FALSIFIED) live in the sidecar / falsification machinery, not on + the binary verifier output — see the + ``feedback_verifier_no_diagnostics`` discipline. Order preserved so + callers can map back to the model's original answer ordering. + """ + out: list[dict] = [] + for t in verified or []: + out.append({"text": t, "status": "VERIFIED_QUOTE", "method": method}) + for t in paraphrase or []: + out.append( + { + "text": t, + "status": "SUPPORTED_PARAPHRASE", + "method": "paraphrase", + } + ) + for t in unverified or []: + out.append({"text": t, "status": "UNSUPPORTED", "method": method}) + return out + + +def _check_each_with_paraphrase( + items: list[str], + norm_ctx: str, + *, + paraphrase_coverage: float = DEFAULT_PARAPHRASE_COVERAGE, + paraphrase_min_tokens: int = DEFAULT_PARAPHRASE_MIN_TOKENS, +) -> tuple[list[str], list[str], list[str]]: + """Three-bucket variant of ``_check_each``. + + For each item: + - if its normalized form is a substring of ``norm_ctx``: STRICT + (verbatim verified) + - else if its meaningful-token coverage in ``norm_ctx`` is + ``>= paraphrase_coverage`` and it has at least + ``paraphrase_min_tokens`` content tokens: PARAPHRASE + (token-overlap verified) + - else: UNVERIFIED + + Returns ``(strict_verified, paraphrase_verified, unverified)``. + Order within each bucket preserves input order. + + Soft-signal note: paraphrase verification is heuristic. Callers + that need byte-equivalence (proof export to other peers, audit + chain claims) must use ``_check_each`` directly. + """ + strict: list[str] = [] + paraphrase: list[str] = [] + unverified: list[str] = [] + for it in items: + if _normalize(it) in norm_ctx: + strict.append(it) + continue + # Paraphrase only fires for prose-shaped spans. Lists of proper + # nouns (e.g. "Keanu Reeves, Laurence Fishburne") fall through + # to the entity strategy where proximity policy can disambiguate + # tight clusters from scattered mentions. + if not _is_prose_span(it): + unverified.append(it) + continue + cov, n_tok = _token_coverage(it, norm_ctx) + if n_tok >= paraphrase_min_tokens and cov >= paraphrase_coverage: + paraphrase.append(it) + else: + unverified.append(it) + return strict, paraphrase, unverified + + +
+[docs] +def verify_quotes( + answer_text: str, + context: str, + *, + entity_policy: str = DEFAULT_ENTITY_POLICY, + proximity_n: int = DEFAULT_PROXIMITY_N, + proximity_window: int = DEFAULT_PROXIMITY_WINDOW, +) -> dict: + """Classify an answer's grounding against its retrieved context. + + Tries quote → span → entity verification in sequence. The first + strategy that finds evidence classifies the answer; later strategies + don't run. + + `entity_policy` controls how the entity path classifies — see + ENTITY_POLICIES. The quote and span paths are unaffected; they are + explicit-claim evidence and always classify per the trichotomy. + + Returns: + { + "n_quotes": int, # evidence units extracted (any path) + "n_verified": int, # of those, how many appear verbatim + "audit_mode": str, # STRICT | HYBRID | UNGROUNDED + "unverified_quotes": [str], # spans we couldn't ground in context + "verifier_method": str, # 'quote' | 'span' | 'entity' | 'none' + } + """ + if entity_policy not in ENTITY_POLICIES: + raise ValueError( + f"entity_policy must be one of {ENTITY_POLICIES}, got {entity_policy!r}" + ) + + # Wikitext markup → plain prose. Identity if mwparserfromhell isn't + # installed (extras: pip install 'aborist[wikitext]'). + if _wikitext_to_base is not None: + context = _wikitext_to_base(context) + + norm_ctx = _normalize(context) + + # Strategy 1: explicit double-quoted spans. + quotes = extract_quotes(answer_text) + if quotes: + verified, unverified = _check_each(quotes, norm_ctx) + return { + "n_quotes": len(quotes), + "n_verified": len(verified), + "audit_mode": _classify(verified, unverified), + "unverified_quotes": unverified, + "verifier_method": "quote", + "claim_statuses": _build_claim_statuses( + verified=verified, unverified=unverified, method="quote" + ), + } + + # Strategy 2: bullet/sentence spans. Try verbatim substring first; + # then fall back to paraphrase (token-coverage) for items that didn't + # substring-match. Spans verified via paraphrase contribute to + # n_verified — the verifier_method label flips to "paraphrase" when + # any soft-verified items are present so an auditor can tell. + spans = extract_claim_spans(answer_text) + if spans: + strict, paraphrase, unverified = _check_each_with_paraphrase( + spans, norm_ctx + ) + verified = strict + paraphrase + if verified: + method = "paraphrase" if paraphrase else "span" + return { + "n_quotes": len(spans), + "n_verified": len(verified), + "audit_mode": _classify(verified, unverified), + "unverified_quotes": unverified, + "verifier_method": method, + "claim_statuses": _build_claim_statuses( + verified=strict, + paraphrase=paraphrase, + unverified=unverified, + method="span", + ), + } + + # Strategy 3: multi-word proper nouns (entity grounding) — gated by + # `entity_policy`. Entity-existence is weaker proof than quote or span; + # the operator chooses how much weight to give it. + if entity_policy == "drop": + # Skip entity path entirely. Falls through to UNGROUNDED/none. + pass + else: + entities = extract_proper_nouns(answer_text) + if entities: + verified, unverified = _check_each(entities, norm_ctx) + if verified: + if entity_policy == "strict": + # Legacy behavior: all match → STRICT. Overclaims. + mode = _classify(verified, unverified) + elif entity_policy == "hybrid": + # Cap at HYBRID. Honest middle: evidence exists, but + # entity-existence ≠ claim-existence. + mode = "HYBRID" + else: # proximity + cluster = _has_entity_cluster( + verified, norm_ctx, proximity_n, proximity_window + ) + if cluster and not unverified: + mode = "STRICT" + elif cluster: + mode = "HYBRID" + elif verified: + mode = "HYBRID" + else: + mode = "UNGROUNDED" + return { + "n_quotes": len(entities), + "n_verified": len(verified), + "audit_mode": mode, + "unverified_quotes": unverified, + "verifier_method": "entity", + "claim_statuses": _build_claim_statuses( + verified=verified, + unverified=unverified, + method="entity", + ), + } + + # Nothing extracted, or nothing verified. Truly emergent. + return { + "n_quotes": 0, + "n_verified": 0, + "audit_mode": "UNGROUNDED", + "unverified_quotes": [], + "verifier_method": "none", + "claim_statuses": [], + }
+ + + +# --------------------------------------------------------------------------- +# Claim-lattice-pointer verifier — G0 / CTI Clause Lattice Intelligence. +# +# Companion to ``verify_quotes`` for ``policy["answer_mode"] == +# "claim_lattice_pointer"``. The model emits weak natural-language +# pointer clauses (``Claim text. [E12]``); the runtime parses them +# into structured claim nodes and runs deterministic checks against +# the runtime-built evidence map. +# +# Why pointer-line, not JSON: small instruction-tuned models follow +# citation-style prose far more reliably than free-form JSON. JSON +# discipline failures generate spurious one-shot SCHEMA_INVALID +# verdicts even when the model knew the right answer; pointer-line +# stays inside the model's prose-generation distribution. +# +# Why decimal pointer ids (E1, E2, …) not hex (E1f8e4c2a): +# - One BPE token per id in standard tokenizers (8-hex tokenizes to +# 4–6 tokens of out-of-distribution noise that nudges the model +# toward DSL/code mode). +# - Citation style (footnotes, references) is heavily represented in +# training; random hex is not. +# - The runtime maps pointer ids back to content-addressed evidence +# ids (``evidence_map_by_pointer_id``) for cache, run-DAG, audit. +# +# Hard-soft boundary: only deterministic checks. No entailment, no +# completeness, no predicate compatibility. Those stay sidecar. +# --------------------------------------------------------------------------- + +ANSWER_MODES = ("quote", "claim_lattice_pointer", "claim_lattice") +DEFAULT_ANSWER_MODE = "quote" + +# JSON-mode pre-parser. 8B and small-context models drift on JSON +# discipline (markdown fences, prose preamble, smart quotes, trailing +# commas). Larger reasoning models (Qwen 3.6 reasoner, Claude, GPT-4) +# emit valid JSON natively; the pre-parser is the defensive belt that +# keeps the JSON path survivable across the inference-quality spectrum. +# Lenient on syntax, strict on semantics: parsed JSON still has to +# pass the schema check & the same hard verifier rules as pointer mode. +_JSON_FENCE_RE = re.compile(r"^\s*```(?:json)?\s*\n(.*?)\n\s*```\s*$", re.DOTALL) +_TRAILING_COMMA_RE = re.compile(r",(\s*[}\]])") + + +def _repair_truncated_json(text: str) -> tuple[str, list[str]]: + """Best-effort completion of truncated JSON. + + Walks ``text`` once tracking string state and bracket/brace stack. + At end-of-input, if the parse is unbalanced (stuck mid-string, + open ``[`` / ``{`` without matching close) or has a dangling + structural artifact (trailing comma, partial key), repair so the + result is parseable. Returns ``(repaired, fixups)``. + + Targets the truncation pattern observed on Hermes-3-8B JSON-mode + output for broad-descriptive questions: the model writes one + long claim text and runs out of ``max_tokens`` mid-sentence, + yielding e.g.:: + + {"claims":[{"text":"The Apollo program was the United + States spaceflight effort which landed... <CUT> + + No closing ``"``, no closing ``}``, no closing ``]``, no closing + outer ``}``. The lenient parse path can't recover any structure. + Self-healing closes the open string, balances the stack, and + drops trailing commas / partial keys so the partial content is + preserved as a single claim with whatever fields survived. + + Fixups recorded: + - ``close_string`` — appended ``"`` to close an open string + - ``drop_partial_key`` — dropped a key without value + (``,"key":`` or ``,"key`` or ``"key":``) + - ``strip_trailing_comma`` — removed a comma immediately + before stack close (separate from the regex pass which + only handles structurally-correct trailing commas) + - ``close_brace`` / ``close_bracket`` — appended ``}`` / ``]`` + per open frame on the stack + + Conservative: never inserts content (no key names, no values, + no commas), only closes / drops. Worst case the repair is a + no-op or makes parsing fail in a different way; never silently + fabricates data. + """ + if not text or not text.strip(): + return text, [] + + fixups: list[str] = [] + stack: list[str] = [] + in_string = False + escaped = False + + for ch in text: + if escaped: + escaped = False + continue + if in_string: + if ch == "\\": + escaped = True + elif ch == '"': + in_string = False + else: + if ch == '"': + in_string = True + elif ch == "{" or ch == "[": + stack.append(ch) + elif ch == "}" or ch == "]": + if stack: + stack.pop() + + if not stack and not in_string: + return text, fixups + + repaired = text + + if in_string: + repaired += '"' + fixups.append("close_string") + + # Drop dangling structural fragments after the last legitimate + # value, walking back from end-of-string. Order matters: handle + # partial-key (`,"foo":` or `,"foo"` or `"foo":`) before + # trailing-comma so we don't strip the wrong comma. + while True: + rstripped = repaired.rstrip() + if not rstripped: + break + # Partial key: `..., "key": ` (colon at end after trim). + m = _PARTIAL_KEY_COLON_RE.search(rstripped) + if m and m.end() == len(rstripped): + repaired = rstripped[: m.start()] + if "drop_partial_key" not in fixups: + fixups.append("drop_partial_key") + continue + # Partial key: `..., "key"` (key without colon). + m = _PARTIAL_KEY_NO_COLON_RE.search(rstripped) + if m and m.end() == len(rstripped): + repaired = rstripped[: m.start()] + if "drop_partial_key" not in fixups: + fixups.append("drop_partial_key") + continue + # Trailing comma — strip when it would otherwise stick before close. + if rstripped.endswith(","): + repaired = rstripped[:-1] + if "strip_trailing_comma" not in fixups: + fixups.append("strip_trailing_comma") + continue + break + + # Close stack in reverse, mapping `{` → `}`, `[` → `]`. + while stack: + opener = stack.pop() + if opener == "{": + repaired += "}" + fixups.append("close_brace") + else: + repaired += "]" + fixups.append("close_bracket") + + return repaired, fixups + + +# Partial-key patterns for _repair_truncated_json. ``,"foo":`` or +# ``"foo":`` at end-of-string after rstrip = a key with no value. +_PARTIAL_KEY_COLON_RE = re.compile(r',?\s*"[^"]*"\s*:\s*$') +# ``,"foo"`` or ``"foo"`` with no following colon — also dangling. +_PARTIAL_KEY_NO_COLON_RE = re.compile(r',\s*"[^"]*"\s*$') + + +def _lenient_json_parse(raw: str) -> tuple[object, list[str]]: + """Parse ``raw`` as JSON, defensively peeling common model drift. + + Returns ``(parsed_obj, fixups_applied)`` — fixups list is empty + when strict parse succeeded, otherwise names what we had to peel + (``"fence"``, ``"prose_trim"``, ``"curly_quotes"``, + ``"trailing_comma"``) or repair (``"close_string"``, + ``"close_brace"``, ``"close_bracket"``, ``"drop_partial_key"``, + ``"strip_trailing_comma"``). + Raises ``json.JSONDecodeError`` if the lenient pass also fails. + + The fixups list lands in the verify payload so an agent can + observe model drift across runs & decide whether the inference + path is JSON-clean enough to keep using. + """ + import json as _json + fixups: list[str] = [] + try: + return _json.loads(raw), fixups + except _json.JSONDecodeError: + pass + + text = raw + + # 1. Strip markdown fence wrappers (```json\n...\n``` or ```\n...\n```). + m = _JSON_FENCE_RE.match(text) + if m: + text = m.group(1) + fixups.append("fence") + + # 2. Trim leading prose to first `{` or `[`; trailing prose past last + # matching `}`/`]`. Preserves the JSON object even when the model + # writes "Here is the JSON: {...}\n\nLet me know if you need more." + first_brace = min( + (text.find(c) for c in "{[" if text.find(c) >= 0), + default=-1, + ) + last_brace = max(text.rfind("}"), text.rfind("]")) + if first_brace > 0 or (last_brace >= 0 and last_brace < len(text) - 1): + if first_brace >= 0 and last_brace >= first_brace: + text = text[first_brace : last_brace + 1] + fixups.append("prose_trim") + + # 3. Normalize curly quotes — model-emitted “…” / ‘…’ become "…" / '…'. + if any(c in text for c in "“”‘’"): + text = ( + text.replace("“", '"').replace("”", '"') + .replace("‘", "'").replace("’", "'") + ) + fixups.append("curly_quotes") + + # 4. Fix trailing commas before `}` or `]`. Conservative: only + # comma immediately followed by whitespace + close bracket. + if "," in text: + new_text = _TRAILING_COMMA_RE.sub(r"\1", text) + if new_text != text: + text = new_text + fixups.append("trailing_comma") + + # 5. Try strict parse before invoking the truncation repair — + # peeling alone may have made it valid. + try: + return _json.loads(text), fixups + except _json.JSONDecodeError: + pass + + # 6. Self-heal truncated JSON: close open strings, drop dangling + # partial keys / trailing commas, balance the bracket stack. The + # model ran out of max_tokens mid-output; close what we can and + # parse the partial structure rather than failing the whole run. + repaired, repair_fixups = _repair_truncated_json(text) + if repair_fixups: + text = repaired + fixups.extend(repair_fixups) + + return _json.loads(text), fixups + +# Default allowed source roles for claim_lattice_pointer mode. Roles +# outside this set get classified as SOURCE_ROLE_BLOCKED. Mirrors the +# role classifications in aborist/qa/query.py:_classify_source_role; +# "noisy_background_source" and "sequel_background_source" are +# deliberately excluded by default. +DEFAULT_ALLOWED_SOURCE_ROLES = ( + "primary_answer_source", + "secondary_context_source", + "background_source", + "unclassified", +) + + +def _has_manual_quote(text: str) -> bool: + """Strict no-quote rule: ANY double-quote character in claim text + is a violation. + + The premise of claim-lattice-pointer mode is that models do not type + quote text — period. Even a 3-char quoted span (``"hi"``) is a + model-asserted verbatim citation that the runtime didn't authorize. + Catching every quote keeps the discipline honest: the model is + forbidden, not just length-discouraged. + + Covers ASCII (``"``) and curly quotes (``“`` / ``”``) — same set + ``_QUOTE_CHAR_RE`` recognizes for the legacy quote verifier. + """ + return any(ch in text for ch in ('"', '“', '”')) + + +DEFAULT_MIN_CITATION_COVERAGE = 0.30 + +# Premise-parroting / generic-vocab-ride-along threshold. When ≥ this +# many tokens shared by the question AND the claim are ABSENT from the +# union of cited evidence spans, the claim is parroting the question's +# subject without anchoring it. Surfaced by the 200-cycle bench-emergent +# delta on `steer/reply/correcter` (Ticket #000006 amend 2026-05-02b): +# claim affirmed three question-distinctive tokens (correcter, steer, +# reply) that appeared ZERO times in the cited 33.5K-char glossary. The +# generic linguistic vocabulary (language, communication, terms, +# relationships) carried Rule 5's coverage check on its own. +# +# Threshold of 3 keeps the signal unambiguous: a single absent parroted +# token is often a stem-variant near-miss; three or more is the +# parroting fingerprint. Folds into verifier_policy_hash. +DEFAULT_SUBJECT_TOKENS_ABSENT_THRESHOLD = 3 + + +def _claim_textually_overlaps_evidence( + claim_text: str, + evidence_span: str, + *, + min_coverage: float = DEFAULT_MIN_CITATION_COVERAGE, +) -> bool: + """Return True if claim's content-token coverage in ``evidence_span`` + meets ``min_coverage`` (case-insensitive substring match). + + Hard 6th check on a (claim, pointer) pair. Catches the lazy-anchor + failure where the model cites an evidence pointer whose text has + insufficient overlap with the claim's actual subject — e.g. claim + "Yale University in New Haven and the University of Connecticut..." + cited to a highway-data span containing only the token + ``connecticut`` (1/10 = 10% coverage; below the 30% default + threshold → CITATION_MISMATCH). + + Pre-2026-04-30 this function required only ≥1 shared content token, + which let through lazy-anchored claims whose only overlap was a + common topical word. Coverage-based threshold scales with claim + length: short claims (1-3 content tokens) need 1 match (same as + the old behavior), longer claims need a proportional share. + + Lexical only, no NER, no embeddings; stays inside the soft/hard + boundary. + + A pure-stopword claim (no content tokens after the spotlight token + extractor's filter) returns True vacuously — there's nothing + topical to check, and the verifier's other hard checks already + own that case (claim_text_non_empty, no_manual_quotes, etc.). + """ + from aborist.qa.evidence import _content_tokens + + tokens = _content_tokens(claim_text) + if not tokens: + return True + span_lower = evidence_span.lower() + matched = sum(1 for t in tokens if t in span_lower) + coverage = matched / len(tokens) + # Floor: a single shared content token always counts when the claim + # is itself short (≤3 content tokens) so single-fact narrow claims + # like "Steve Jobs co-founded Apple" don't fail on a coverage + # technicality. The threshold bites on prose-shaped multi-token + # claims where 1/10 token overlap is the lazy-anchor signature. + if matched >= 1 and len(tokens) <= 3: + return True + return coverage >= min_coverage + + +def _parroted_subject_tokens_absent( + question_text: str | None, + claim_text: str, + cited_spans: list[str], +) -> set[str]: + """Return claim∩question content tokens that are NOT present in + the union of cited evidence spans. + + Premise-parroting / generic-vocab-ride-along detector (Ticket + #000006 amend 2026-05-02b). The model affirms the question's + distinctive subject tokens in its claim, but those tokens are + absent from the cited evidence — the citation rode in on + overlapping generic vocabulary while the actual subject went + unverified. + + Mechanism: substring match on lowercased text, mirroring Rule 5 + (`_claim_textually_overlaps_evidence`). Stem-tolerant via the + substring rule — "polar" matches inside "bipolar", "rare" + matches "rarely", etc. + + No-question-text → empty set (skip the check). + No question∩claim overlap → empty set (claim isn't parroting). + Empty cited_spans → return the full parroted set (defensive; no + grounding at all is its own failure mode caught elsewhere). + """ + from aborist.qa.evidence import _content_tokens + + if not question_text or not claim_text: + return set() + qtok = set(_content_tokens(question_text)) + ctok = set(_content_tokens(claim_text)) + parroted = qtok & ctok + if not parroted: + return set() + union_lower = " ".join((s or "").lower() for s in cited_spans) + if not union_lower.strip(): + return parroted + return {t for t in parroted if t not in union_lower} + + +DEFAULT_MAX_POINTERS_PER_CLAIM = 2 +DEFAULT_MIN_CLAIM_CONTENT_TOKENS = 2 +DEFAULT_LAZY_ANCHOR_DEMOTE_THRESHOLD = 0.5 +DEFAULT_LAZY_ANCHOR_DEMOTE_MIN_PAIRS = 3 +# Claim-count ceiling. Bench evidence (2026-04-30 york-england run): +# pre-atomic-claim-rule, JSON mode emitted 26-59 claim-pointer pairs +# of which only 2-4 verified — the model treats "tell me all there +# is to know about X" as a license to spam encyclopedic claims from +# training. Atomic-claim prompt rule (commit b5925c8) reduced this +# to ~10 well-formed claims, but defence-in-depth: any answer with +# more than this many claims is structurally suspect regardless of +# how each claim verifies. Default 12 chosen to comfortably admit +# entity-list questions ("dinosaurs in jurassic park" → 5; "simpsons +# family + pets" → 5-7) while catching the runaway shape. +DEFAULT_MAX_CLAIMS_PER_ANSWER = 12 + + +# Title-relevance check (Rule 8). Cited evidence's source title must +# share at least one stemmed content token with the claim text. +# Catches the retrieval-driven hallucination class fox surfaced +# 2026-05-02 on "explain spin glass modeling & tensors?": claim +# tokens {spin, glass, modeling, tensor, ...} cited to a chunk from +# the *Quantum chromodynamics* article whose title tokens are +# {quantum, chromodynamics} — zero overlap. Token-coverage check +# inside the chunk passed accidentally on shared physics vocabulary; +# the SOURCE was never about the claim's subject. +def _claim_title_overlap(claim_text: str, source_title: str | None) -> bool: + """Return True iff the source title shares ≥1 stemmed content + token with the claim text. Vacuous-pass when either side has no + extractable tokens (defensive — prevents the rule from firing + on degenerate inputs).""" + if not source_title or not claim_text: + return True + from aborist.qa.evidence import _content_tokens as _ct + + claim_tokens = _ct(claim_text) + title_tokens = _ct((source_title or "").replace("_", " ")) + if not claim_tokens or not title_tokens: + return True + + # Reuse the retrieval-side stem helper so possessive / plural + # collapse the same way ('movies' vs 'movie', 'simpsons' vs + # 'simpson'). Defined in qa/query.py to avoid an import cycle: + # inline a minimal copy here instead. + def _stem(t: str) -> str: + if len(t) > 4 and t.endswith("s") and not t.endswith("ss"): + return t[:-1] + return t + + claim_stems = {_stem(t) for t in claim_tokens} + title_stems = {_stem(t) for t in title_tokens} + return bool(claim_stems & title_stems) + + +
+[docs] +def verify_claim_lattice( + answer_text: str, + evidence_map, + *, + allowed_source_roles: tuple[str, ...] = DEFAULT_ALLOWED_SOURCE_ROLES, + max_pointers_per_claim: int = DEFAULT_MAX_POINTERS_PER_CLAIM, + min_citation_coverage: float = DEFAULT_MIN_CITATION_COVERAGE, + min_claim_content_tokens: int = DEFAULT_MIN_CLAIM_CONTENT_TOKENS, + lazy_anchor_demote_threshold: float = DEFAULT_LAZY_ANCHOR_DEMOTE_THRESHOLD, + lazy_anchor_demote_min_pairs: int = DEFAULT_LAZY_ANCHOR_DEMOTE_MIN_PAIRS, + max_claims_per_answer: int = DEFAULT_MAX_CLAIMS_PER_ANSWER, + subject_tokens_absent_threshold: int = DEFAULT_SUBJECT_TOKENS_ABSENT_THRESHOLD, + question: str | None = None, + warrant_check_enabled: bool = True, + deflection_check_enabled: bool = True, + format_collapse_check_enabled: bool = True, +) -> dict: + """Deterministic verifier for ``answer_mode="claim_lattice_pointer"``. + + The model wrote pointer-line prose (``Claim text. [E12]``); the + parser pulled (claim_text, [pointer_ids]) pairs from each non-empty + line. This verifier maps each pointer id back to its + content-addressed evidence object and runs six hard checks: + + 1. Parser succeeded — ``parse_status == "PARSED"`` (line had a + bracket tag). NO_EVIDENCE_POINTER claims (prose without tag) + count toward the denominator and downgrade the verdict. + 2. Pointer id resolves to an entry in the runtime-built evidence + map. No model-invented ids. + 3. Resolved entry's ``source_role`` is in ``allowed_source_roles``. + 4. Claim text non-empty after tag strip. + 5. Claim's content tokens textually overlap the cited evidence + span at coverage ≥ ``min_citation_coverage`` (per-pair, lexical + only — see ``_claim_textually_overlaps_evidence``). Catches the + magnet-chunk lazy-anchor where the model cites an evidence + pointer whose text contains few claim-content tokens. + 6. Pointer count per claim does not exceed ``max_pointers_per_claim`` + (default 2 — matches the prompt's "1 or 2 pointers per claim" + rule). When exceeded, the claim is TRIMMED to the first N + pointers and verification proceeds normally; a + ``POINTER_OVERFLOW_TRIMMED`` violation is recorded so STRICT is + no longer reachable (audit_mode caps at HYBRID for the run). + Trim-and-verify (vs hard fail) protects correct claims that + were over-cited (e.g. "Leonardo painted the Mona Lisa. + [E2,...,E14]") while keeping the over-citation pattern + surfaced. The dropped pointers count toward ``n_quotes`` so + the denominator reflects what the model emitted. + + Removed 2026-04-30: the strict no-double-quote rule. The model + routinely paraphrases source prose but copies named-quoted phrases + verbatim (e.g. ``"Constitution State"`` from a Connecticut span). + Hard-rejecting claims that contained any ``"`` char was rejecting + factually correct, source-grounded claims for cosmetic punctuation. + The coverage threshold (Rule 5) and pointer cap (Rule 6) carry the + weight of catching synthetic-quote / mega-claim failures the old + rule was meant to catch. ``_has_manual_quote`` is still defined and + used by ``verify_claim_lattice_json``. + + Returns a verdict in the same shape as ``verify_quotes`` + extras: + + n_quotes total claim-pointer pairs (denominator) + n_verified pairs where pointer resolved AND + source_role allowed AND coverage met + AND claim text non-empty + audit_mode STRICT / HYBRID / UNGROUNDED + unverified_quotes claim texts that didn't reach + EVIDENCE_LINKED — kept under that name + for schema continuity with verify_quotes + verifier_method "claim_lattice" + claim_statuses per-claim {text, evidence_ids, + pointer_ids, status, reasons[]}; status ∈ + {EVIDENCE_LINKED, EVIDENCE_LINKED_PARTIAL, + UNKNOWN_EVIDENCE_ID, + SOURCE_ROLE_BLOCKED, + CITATION_MISMATCH, + NO_EVIDENCE_POINTER, SCHEMA_INVALID} + violations structured violation records for the + run-DAG / sidecar + rendered_text human-readable prose with literal spans + interpolated; what the runner persists + as ``answer_text`` + evidence_id_pairs per-claim list of resolved + content-addressed evidence_ids (run-stable + form). Used to thread the parsed lattice + into the run-DAG. + """ + from aborist.qa.evidence import ( + evidence_map_by_pointer_id as _by_pointer, + render_claim_lattice as _render, + ) + from aborist.qa.parse_claims import parse_pointer_claims + + by_pointer = _by_pointer(evidence_map) + violations: list[dict] = [] + claim_statuses: list[dict] = [] + # Three-bucket rendering. ``unverified`` holds claims whose + # status reached neither EVIDENCE_LINKED nor EVIDENCE_LINKED_PARTIAL + # — i.e. fully failed (no pointer verified). ``partially_verified`` + # holds EVIDENCE_LINKED_PARTIAL claims (some pointers ok, some + # failed). The renderer shows them as their own section so a + # claim never appears in BOTH a verified bullet and the unverified + # footer; that previously happened for partial-status claims and + # read as "is it grounded or not?". Per-pointer detail lives in + # ``claim_statuses`` for audit. + unverified: list[str] = [] + partially_verified: list[str] = [] + + raw_claims = parse_pointer_claims(answer_text or "") + + # Claim-count ceiling — see DEFAULT_MAX_CLAIMS_PER_ANSWER. Records + # the violation but doesn't truncate; the per-claim loop below + # still verifies every claim so the operator sees full evidence + # of the runaway. Demotes verdict via the violation list. + if len(raw_claims) > max_claims_per_answer: + violations.append({ + "kind": "TOO_MANY_CLAIMS", + "n_claims": len(raw_claims), + "max": max_claims_per_answer, + }) + + n_pairs = 0 + n_pairs_verified = 0 + # Renderer claims: pointer-id form so the human display still shows + # the short tags the model used. + valid_claims: list[dict] = [] + # Evidence-id pairs: content-addressed form for the run-DAG & + # cache. Per-claim list so the parsed_claim_lattice node hashes the + # run-stable handle, not the run-dependent pointer-id. + evidence_id_pairs: list[list[str]] = [] + + for idx, c in enumerate(raw_claims): + claim_text = c.claim_text + pointer_ids = c.pointer_ids + parse_status = c.parse_status + + if parse_status == "NO_EVIDENCE_POINTER": + violations.append({ + "kind": "NO_EVIDENCE_POINTER", + "claim_idx": idx, + "claim_text": claim_text, + }) + claim_statuses.append({ + "claim_idx": idx, + "text": claim_text, + "pointer_ids": [], + "evidence_ids": [], + "status": "NO_EVIDENCE_POINTER", + "reasons": ["no [E\\d+] tag on line"], + }) + unverified.append(claim_text) + evidence_id_pairs.append([]) + # NO_EVIDENCE_POINTER counts as one denominator pair so the + # verdict reflects the failure rate. + n_pairs += 1 + continue + + if not claim_text: + violations.append({ + "kind": "SCHEMA_INVALID", + "claim_idx": idx, + "reason": "tag with no claim text", + }) + claim_statuses.append({ + "claim_idx": idx, + "text": "", + "pointer_ids": pointer_ids, + "evidence_ids": [], + "status": "SCHEMA_INVALID", + "reasons": ["tag with no claim text"], + }) + n_pairs += len(pointer_ids) + evidence_id_pairs.append([]) + continue + + # Bare-name claim guard. A claim like "Tyrannosaurus rex. [E15]" + # has 1 content token; the citation passes any span that mentions + # T-rex anywhere, even when E15 is a video-game-behavior chunk + # rather than a film-context one. Forcing a sentence-shape claim + # ("Tyrannosaurus rex appeared in the first JP film") raises the + # token-coverage bar so an off-topic chunk can no longer satisfy + # the citation. Folds into governance_policy_hash via + # ``claim_lattice_min_claim_content_tokens``. + from aborist.qa.evidence import _content_tokens as _ct + claim_content_tokens = _ct(claim_text) + if len(claim_content_tokens) < min_claim_content_tokens: + violations.append({ + "kind": "SCHEMA_INVALID", + "claim_idx": idx, + "reason": ( + f"bare-name claim ({len(claim_content_tokens)} content " + f"tokens < {min_claim_content_tokens}); write a sentence" + ), + }) + claim_statuses.append({ + "claim_idx": idx, + "text": claim_text, + "pointer_ids": pointer_ids, + "evidence_ids": [], + "status": "SCHEMA_INVALID", + "reasons": ["bare-name claim — write a sentence with predicate"], + }) + n_pairs += len(pointer_ids) + evidence_id_pairs.append([]) + unverified.append(claim_text) + continue + + # Pointer-count cap (Rule 9). Catches the encyclopedic-mega- + # claim where the model produces one line citing every + # pointer at once. Counts every pointer toward the denominator + # so the failure is loud in n_quotes. 2026-04-30: trim-and- + # verify rather than hard-fail. A correct claim cited with too + # many pointers ("Leonardo da Vinci painted the Mona Lisa. + # [E2,...,E14]") deserves to count if its first N pointers + # actually verify; the violation still blocks STRICT (audit_mode + # caps at HYBRID) so the over-citation pattern stays surfaced. + # Hard SCHEMA_INVALID would have nuked correct answers for a + # cosmetic over-cite. The dropped pointers count toward + # n_pairs so the denominator reflects what the model emitted. + pointer_overflow_trimmed = False + if len(pointer_ids) > max_pointers_per_claim: + dropped = pointer_ids[max_pointers_per_claim:] + n_pairs += len(dropped) + pointer_ids = pointer_ids[:max_pointers_per_claim] + pointer_overflow_trimmed = True + violations.append({ + "kind": "POINTER_OVERFLOW_TRIMMED", + "claim_idx": idx, + "kept": list(pointer_ids), + "dropped": dropped, + "max_pointers_per_claim": max_pointers_per_claim, + }) + + per_id_results: list[dict] = [] + resolved_evidence_ids: list[str] = [] + for pid in pointer_ids: + n_pairs += 1 + obj = by_pointer.get(pid) + if obj is None: + violations.append({ + "kind": "UNKNOWN_EVIDENCE_ID", + "claim_idx": idx, + "pointer_id": pid, + }) + per_id_results.append({ + "pid": pid, "ok": False, "kind": "UNKNOWN_EVIDENCE_ID", + }) + continue + if obj.source_role not in allowed_source_roles: + violations.append({ + "kind": "SOURCE_ROLE_BLOCKED", + "claim_idx": idx, + "pointer_id": pid, + "evidence_id": obj.evidence_id, + "source_role": obj.source_role, + }) + per_id_results.append({ + "pid": pid, "ok": False, "kind": "SOURCE_ROLE_BLOCKED", + }) + continue + if not _claim_textually_overlaps_evidence( + claim_text, obj.span, min_coverage=min_citation_coverage + ): + # Cited evidence span has zero textual overlap with any + # content token from the claim. Strongest lazy-anchor + # signal promoted to a hard fail — the model cited a + # magnet chunk that doesn't textually support its claim. + violations.append({ + "kind": "CITATION_MISMATCH", + "claim_idx": idx, + "pointer_id": pid, + "evidence_id": obj.evidence_id, + }) + per_id_results.append({ + "pid": pid, "ok": False, "kind": "CITATION_MISMATCH", + }) + continue + per_id_results.append({"pid": pid, "ok": True, "evidence_id": obj.evidence_id}) + resolved_evidence_ids.append(obj.evidence_id) + n_pairs_verified += 1 + + ok_pids = [r["pid"] for r in per_id_results if r["ok"]] + bad_kinds = sorted({r["kind"] for r in per_id_results if not r["ok"]}) + + if ok_pids and not bad_kinds: + status = "EVIDENCE_LINKED" + elif ok_pids: + status = "EVIDENCE_LINKED_PARTIAL" + elif "CITATION_MISMATCH" in bad_kinds: + status = "CITATION_MISMATCH" + elif "UNKNOWN_EVIDENCE_ID" in bad_kinds: + status = "UNKNOWN_EVIDENCE_ID" + elif "SOURCE_ROLE_BLOCKED" in bad_kinds: + status = "SOURCE_ROLE_BLOCKED" + else: + status = "SCHEMA_INVALID" + + claim_statuses.append({ + "claim_idx": idx, + "text": claim_text, + "pointer_ids": pointer_ids, + "evidence_ids": resolved_evidence_ids, + "status": status, + "reasons": bad_kinds, + }) + evidence_id_pairs.append(resolved_evidence_ids) + if status in ("EVIDENCE_LINKED", "EVIDENCE_LINKED_PARTIAL"): + valid_claims.append({"text": claim_text, "pointer_ids": ok_pids}) + if status == "EVIDENCE_LINKED_PARTIAL": + partially_verified.append(claim_text) + elif status != "EVIDENCE_LINKED": + unverified.append(claim_text) + + rendered_text = _render(valid_claims, by_pointer) if valid_claims else "" + + # Aggregate. STRICT requires ≥1 verified pair AND zero violations of + # any kind (schema, unknown pointer, blocked role, manual quote, + # missing pointer). HYBRID = some pairs verified, some failed. + # UNGROUNDED = no verified pairs (no parseable claims, or every + # claim failed at least one check). + if n_pairs_verified > 0 and not violations: + audit_mode = "STRICT" + elif n_pairs_verified > 0: + audit_mode = "HYBRID" + else: + audit_mode = "UNGROUNDED" + + # Anchor-smell sidecar (render-layer only — never persisted as a + # v9.8 field). Counts how many distinct pointer_ids the model used + # across the verified-or-partial claims. ``lazy_anchor_ratio`` is + # the max share any single pointer claimed: 1.0 = every claim cites + # the same pointer (Hermes-3-8B's lazy-anchor habit on the JP- + # dinosaurs benchmark), 1/N = every claim cites a unique pointer. + # The distribution is recoverable from ``claim_statuses`` which + # IS persisted in run_dag_blob; we surface the derived numbers in + # the verdict for the human renderer, but they never thread back + # into ``build_run_dag``'s verify_payload, so ``run_dag_root`` + # stays clean. + pointer_distribution: dict[str, int] = {} + for cs in claim_statuses: + if cs["status"] not in ("EVIDENCE_LINKED", "EVIDENCE_LINKED_PARTIAL"): + continue + for pid in cs.get("pointer_ids") or []: + pointer_distribution[pid] = pointer_distribution.get(pid, 0) + 1 + total_pointers = sum(pointer_distribution.values()) + lazy_anchor_ratio = ( + max(pointer_distribution.values()) / total_pointers + if total_pointers + else 0.0 + ) + + # Smell → demote. Pre-2026-04-30 the lazy-anchor signal was advisory + # only; the verdict could still be STRICT while every claim cited + # the same magnet chunk. Now: when ratio ≥ threshold AND total pairs + # ≥ floor, cap audit_mode at HYBRID. STRICT becomes unreachable for + # answers where one pointer carries every claim — that pattern is + # almost never honest verbatim grounding. UNGROUNDED is left alone + # (a verdict with zero verified pairs has no smell to flag). + lazy_anchor_demoted = False + if ( + audit_mode == "STRICT" + and total_pointers >= lazy_anchor_demote_min_pairs + and lazy_anchor_ratio >= lazy_anchor_demote_threshold + ): + audit_mode = "HYBRID" + lazy_anchor_demoted = True + violations.append({ + "kind": "LAZY_ANCHOR_DEMOTE", + "ratio": round(lazy_anchor_ratio, 3), + "min_pairs": lazy_anchor_demote_min_pairs, + "threshold": lazy_anchor_demote_threshold, + }) + + # Warrant-lite — relation-question hard check (Ticket H from + # feedback-3, 2026-05-01). Claim-cited spans must contain at + # least one of the claim's named answer entities (proper-noun + # phrases). Catches the Homer-Simpson lazy-anchor case fox + # surfaced — claim asserts "Mr. Burns" but cited span is + # Castellaneta voice-actor prose. See aborist/qa/warrant.py + # for the lexical algorithm and rationale (deterministic, + # not NLI). Fires only when the question shape suggests a + # relation lookup AND the lookup is enabled by policy + # (warrant_check_enabled). Per-claim WARRANT_MISSING violations + # cap audit_mode at HYBRID via the same demote pattern as + # lazy_anchor_demoted. + warrant_missing_claims: list[int] = [] + if warrant_check_enabled: + for cs in claim_statuses: + if cs.get("status") not in ("EVIDENCE_LINKED", "EVIDENCE_LINKED_PARTIAL"): + continue + cited_eids = cs.get("evidence_ids") or [] + cited_spans = [ + obj.span + for eid in cited_eids + for obj in [evidence_map_by_evidence_id_local(evidence_map, eid)] + if obj is not None + ] + ok, missing = warrant_check( + cs.get("text") or "", cited_spans, question=question + ) + if not ok: + warrant_missing_claims.append(cs.get("claim_idx")) + violations.append({ + "kind": "WARRANT_MISSING", + "claim_idx": cs.get("claim_idx"), + "missing_anchors": missing, + "rationale": ( + "claim asserts an answer entity or specific date " + "not present in any cited span — pointer-linked " + "but warrant missing" + ), + }) + if warrant_missing_claims and audit_mode == "STRICT": + audit_mode = "HYBRID" + + # Rule 8 — Title-relevance check. For each claim that resolved, + # at least one cited evidence's source title must share a + # stemmed content token with the claim. Catches the + # retrieval-driven hallucination class (2026-05-02 spin-glass + # case): claim about spin glass cited to a chunk from + # *Quantum chromodynamics* — token-coverage check passed on + # incidental physics vocabulary, but the SOURCE was never about + # the claim's subject. + title_mismatch_claims: list[int] = [] + for cs in claim_statuses: + if cs.get("status") not in ("EVIDENCE_LINKED", "EVIDENCE_LINKED_PARTIAL"): + continue + cited_eids = cs.get("evidence_ids") or [] + cited_titles = [ + obj.title + for eid in cited_eids + for obj in [evidence_map_by_evidence_id_local(evidence_map, eid)] + if obj is not None + ] + if not cited_titles: + continue + any_overlap = any( + _claim_title_overlap(cs.get("text") or "", t) + for t in cited_titles + ) + if not any_overlap: + title_mismatch_claims.append(cs.get("claim_idx")) + violations.append({ + "kind": "TITLE_MISMATCH", + "claim_idx": cs.get("claim_idx"), + "claim_text": (cs.get("text") or "")[:200], + "cited_titles": cited_titles, + "rationale": ( + "no cited source's title shares a content token " + "with the claim — pointer-linked but the cited " + "document is structurally unrelated to the claim" + ), + }) + if title_mismatch_claims and audit_mode == "STRICT": + audit_mode = "HYBRID" + # Tightening (2026-05-02 emergent-log finding): when EVERY resolving + # claim has TITLE_MISMATCH, the substrate has zero structural + # grounding for the user's question — every cited source is + # title-irrelevant. The cashback case ("widescreens offer cashback" + # cited to a generic Coupon article) had n_verified=1 but the + # citation was meaningless; HYBRID overclaimed. Demote to + # UNGROUNDED so the four-rung ladder maps it to UNGROUNDED, not + # POINTER-LINKED-PARTIAL. + n_resolving = sum( + 1 + for cs in claim_statuses + if cs.get("status") in ("EVIDENCE_LINKED", "EVIDENCE_LINKED_PARTIAL") + ) + if ( + title_mismatch_claims + and n_resolving > 0 + and len(title_mismatch_claims) == n_resolving + ): + audit_mode = "UNGROUNDED" + + # Rule 9 — Subject-tokens-absent / premise-parroting check (Ticket + # #000006 amend 2026-05-02b, surfaced by `steer/reply/correcter` + # 200-cycle bench-emergent finding). For each resolving claim, + # collect the union of cited evidence spans and check whether ≥ + # subject_tokens_absent_threshold tokens shared by question AND + # claim are absent from that union. If so, the claim is parroting + # the question's distinctive subject without anchoring it — the + # citation rode in on overlapping generic vocabulary while the + # actual subject went unverified. + subject_absent_claims: list[int] = [] + if question and subject_tokens_absent_threshold > 0: + for cs in claim_statuses: + if cs.get("status") not in ("EVIDENCE_LINKED", "EVIDENCE_LINKED_PARTIAL"): + continue + cited_eids = cs.get("evidence_ids") or [] + cited_spans = [] + for eid in cited_eids: + obj = evidence_map_by_evidence_id_local(evidence_map, eid) + if obj is not None and obj.span: + cited_spans.append(obj.span) + if not cited_spans: + continue + absent = _parroted_subject_tokens_absent( + question, cs.get("text") or "", cited_spans + ) + if len(absent) >= subject_tokens_absent_threshold: + subject_absent_claims.append(cs.get("claim_idx")) + violations.append({ + "kind": "SUBJECT_TOKENS_ABSENT", + "claim_idx": cs.get("claim_idx"), + "claim_text": (cs.get("text") or "")[:200], + "absent_tokens": sorted(absent), + "rationale": ( + f"{len(absent)} question-distinctive tokens echoed in " + f"the claim are absent from cited evidence — claim " + f"parrots question premise without anchoring it" + ), + }) + if subject_absent_claims and audit_mode == "STRICT": + audit_mode = "HYBRID" + + # Deflection check (soft demote, promoted from sidecar 2026-05-02). + # When the question's subject anchor is missing from the answer, + # the model deflected — answered an adjacent grounded question + # instead of the user's specific one. Caught the live cases: + # "who burns the amazon river?" → answered about Amazon + # Rainforest deforestation, "river" never in answer + # "what culture burns the amazon rain forest?" → answered + # "what causes burning", "culture" never in answer + # Both passed every other check but the user's question wasn't + # structurally answered. DEFLECTION_DETECTED downgrades + # EVIDENCE-WARRANTED → ANCHOR-WARRANTED via the soft-demote + # path. Render-layer ladder picks this up automatically. + deflection_detected = False + if deflection_check_enabled and question and rendered_text: + # Deferred import to avoid pulling aborist.compress + aborist.store + # at verify.py module-load time when callers may not need them. + from aborist.qa.inspect import diagnose_deflection + signal = diagnose_deflection(question, rendered_text) + if signal.get("kind") == "deflection": + deflection_detected = True + violations.append({ + "kind": "DEFLECTION_DETECTED", + "subject_anchor": signal.get("subject_anchor"), + "overlap_ratio": signal.get("overlap_ratio"), + "rationale": ( + "answer's content tokens omit the question's " + "subject anchor — model answered an adjacent " + "grounded question rather than the user's " + "specific one" + ), + }) + if deflection_detected and audit_mode == "STRICT": + audit_mode = "HYBRID" + + # Format-collapse check (FORMAT_COLLAPSED soft demote). + # The "winners of all major sports?" case fox surfaced 2026-05-02: + # Hermes melted under an under-specified broad question, dumped + # 50+ free-form prose claims with ZERO `[E\d+]` pointer tags. The + # parser found 2 line-shaped fragments to count as claims; both + # ungrounded → UNGROUNDED 0/2. Verifier was honest, but operators + # couldn't tell from the audit line whether UNGROUNDED meant + # "tried to ground & failed" vs "abandoned the protocol entirely." + # This sidecar separates those two failure shapes by inspecting + # the raw answer text for the absence of bracket tags amid + # multiple meaningful prose lines. + format_collapsed = False + if format_collapse_check_enabled and answer_text: + meaningful_lines = [ + line for line in answer_text.splitlines() + if len(line.strip()) > 20 + ] + bracket_count = len(re.findall(r"\[E\d+", answer_text)) + if len(meaningful_lines) >= 5 and bracket_count == 0: + format_collapsed = True + violations.append({ + "kind": "FORMAT_COLLAPSED", + "meaningful_lines": len(meaningful_lines), + "bracket_count": bracket_count, + "rationale": ( + "model emitted multi-line prose with zero [E\\d+] " + "pointer tags — abandoned the claim_lattice_pointer " + "protocol entirely. UNGROUNDED below this signal is " + "format collapse, not graceful per-claim refusal." + ), + }) + if format_collapsed and audit_mode == "STRICT": + audit_mode = "HYBRID" + + return { + "n_quotes": n_pairs, + "n_verified": n_pairs_verified, + "audit_mode": audit_mode, + "unverified_quotes": unverified, + "partially_verified_quotes": partially_verified, + "verifier_method": "claim_lattice", + "claim_statuses": claim_statuses, + "violations": violations, + "rendered_text": rendered_text, + "evidence_id_pairs": evidence_id_pairs, + "pointer_id_distribution": pointer_distribution, + "lazy_anchor_ratio": lazy_anchor_ratio, + "lazy_anchor_demoted": lazy_anchor_demoted, + "warrant_missing_claim_idxs": warrant_missing_claims, + "title_mismatch_claim_idxs": title_mismatch_claims, + "deflection_detected": deflection_detected, + "format_collapsed": format_collapsed, + }
+ + + +
+[docs] +def evidence_map_by_evidence_id_local(evidence_map, eid: str): + """Local helper — returns the EvidenceObject whose ``evidence_id`` + matches ``eid``, or None. Avoids the import-cycle risk of pulling + `evidence_map_by_evidence_id` into this module's hot path; the + O(N) walk is fine since evidence maps are <30 entries. + """ + for obj in evidence_map or []: + if obj.evidence_id == eid: + return obj + return None
+ + + +# --------------------------------------------------------------------------- +# JSON variant — `answer_mode="claim_lattice"`. Same lattice semantics as +# the pointer variant, but the model emits a structured JSON object +# {"claims":[{"text":str,"evidence_ids":[str,...]}]} with content- +# addressed evidence_ids directly. Pairs naturally with grammar- +# constrained inference (vLLM guided_json, Claude/GPT-4 native JSON +# mode, Qwen 3.6 reasoner) where schema-conformance is generation-time- +# enforced. The lenient pre-parser above keeps the path survivable on +# inference paths without grammar guidance. +# --------------------------------------------------------------------------- + + +CLAIM_LATTICE_JSON_SCHEMA = { + "type": "object", + "properties": { + "claims": { + "type": "array", + "items": { + "type": "object", + "properties": { + "text": {"type": "string"}, + "evidence_ids": { + "type": "array", + "items": {"type": "string"}, + }, + }, + "required": ["text", "evidence_ids"], + "additionalProperties": False, + }, + }, + }, + "required": ["claims"], + "additionalProperties": False, +} + + +
+[docs] +def verify_claim_lattice_json( + answer_json_text: str, + evidence_map, + *, + allowed_source_roles: tuple[str, ...] = DEFAULT_ALLOWED_SOURCE_ROLES, + max_evidence_per_claim: int = DEFAULT_MAX_POINTERS_PER_CLAIM, + min_citation_coverage: float = DEFAULT_MIN_CITATION_COVERAGE, + max_claims_per_answer: int = DEFAULT_MAX_CLAIMS_PER_ANSWER, + subject_tokens_absent_threshold: int = DEFAULT_SUBJECT_TOKENS_ABSENT_THRESHOLD, + question: str | None = None, + warrant_check_enabled: bool = True, + deflection_check_enabled: bool = True, +) -> dict: + """Deterministic verifier for ``answer_mode="claim_lattice"`` (JSON). + + Parses the model's JSON output (lenient pre-parser handles markdown + fences / preamble / curly quotes / trailing commas), validates the + schema, then runs the same hard checks as ``verify_claim_lattice`` + but reading ``evidence_ids`` from the JSON claim objects. + + 2026-04-30: switched from content-addressed evidence_ids + (``Eed1b6e396``) to pointer_ids (``E1``, ``E2``, …) in the prompt + & JSON output. Hermes-3-8B was fabricating plausible content- + addressed IDs (``E1b6e396``-style near-misses) on cross-document + relationship questions; the verifier correctly rejected them as + UNKNOWN_EVIDENCE_ID but the answer text was often factually + correct, leaving us with honest UNGROUNDED on right answers. + Pointer IDs are short, enumerable, and fabrication-obvious. The + runtime still resolves each pointer_id to its content-addressed + evidence_id internally and stores that in ``evidence_id_pairs`` + (cache/run-DAG continuity); only the prompt-facing surface + changes. + + 1. JSON parses (lenient). Failure → SCHEMA_INVALID, UNGROUNDED. + 2. Top-level is ``{"claims": [...]}``. + 3. Each claim is ``{"text": str, "evidence_ids": [str, ...]}``. + 4. Each evidence_id resolves in the runtime-built evidence map + (no model-invented IDs). + 5. Resolved entry's ``source_role`` is in ``allowed_source_roles``. + 6. Claim text contains no double-quote characters anywhere. + 7. Claim text non-empty. + 8. Claim's content tokens textually overlap the cited evidence span. + 9. ``len(evidence_ids) <= max_evidence_per_claim``. + + Returns a verdict in the same shape as ``verify_claim_lattice`` plus + a ``json_fixups`` field naming any drift the lenient parser had to + peel (``"fence"`` / ``"prose_trim"`` / ``"curly_quotes"`` / + ``"trailing_comma"``). Empty list = strict JSON parse on first try. + """ + from aborist.qa.evidence import ( + evidence_map_by_pointer_id as _by_pointer, + render_claim_lattice as _render, + ) + + by_pointer = _by_pointer(evidence_map) + violations: list[dict] = [] + claim_statuses: list[dict] = [] + unverified: list[str] = [] + json_fixups: list[str] = [] + + parsed = None + try: + parsed, json_fixups = _lenient_json_parse(answer_json_text or "") + except Exception as exc: + violations.append({ + "kind": "SCHEMA_INVALID", + "reason": f"json parse: {str(exc)[:200]}", + }) + if parsed is not None and not isinstance(parsed, dict): + violations.append({ + "kind": "SCHEMA_INVALID", + "reason": f"top-level not object (got {type(parsed).__name__})", + }) + parsed = None + raw_claims = (parsed or {}).get("claims") if parsed is not None else None + if parsed is not None and not isinstance(raw_claims, list): + violations.append({ + "kind": "SCHEMA_INVALID", + "reason": "missing or non-list 'claims'", + }) + raw_claims = None + + # Claim-count ceiling — same defense-in-depth signal as the + # pointer verifier. A "tell me all there is to know" prompt + # shape can spam encyclopedic claims; cap demotes the verdict + # so the runaway is operator-visible regardless of per-claim + # verification success. + if isinstance(raw_claims, list) and len(raw_claims) > max_claims_per_answer: + violations.append({ + "kind": "TOO_MANY_CLAIMS", + "n_claims": len(raw_claims), + "max": max_claims_per_answer, + }) + + n_pairs = 0 + n_pairs_verified = 0 + valid_claims: list[dict] = [] + evidence_id_pairs: list[list[str]] = [] + + for idx, c in enumerate(raw_claims or []): + if not isinstance(c, dict): + violations.append({ + "kind": "SCHEMA_INVALID", + "claim_idx": idx, + "reason": f"claim not object (got {type(c).__name__})", + }) + claim_statuses.append({ + "text": "", "evidence_ids": [], + "status": "SCHEMA_INVALID", "reasons": ["not_object"], + }) + continue + claim_text = c.get("text") or "" + eids = c.get("evidence_ids") or [] + if not isinstance(claim_text, str) or not isinstance(eids, list): + violations.append({ + "kind": "SCHEMA_INVALID", "claim_idx": idx, + "reason": "claim shape: text=str, evidence_ids=list[str]", + }) + claim_statuses.append({ + "text": str(claim_text)[:200], "evidence_ids": [], + "status": "SCHEMA_INVALID", "reasons": ["bad_field_types"], + }) + continue + + # Manual-quote prohibition (same rule as pointer mode). + if _has_manual_quote(claim_text): + violations.append({ + "kind": "MANUAL_QUOTE_VIOLATION", "claim_idx": idx, + "claim_text": claim_text[:200], + }) + unverified.append(claim_text) + claim_statuses.append({ + "text": claim_text, "evidence_ids": eids, + "status": "MANUAL_QUOTE_VIOLATION", + "reasons": ["double_quote_in_text"], + }) + n_pairs += max(1, len(eids)) + continue + + if not claim_text.strip(): + violations.append({ + "kind": "SCHEMA_INVALID", "claim_idx": idx, + "reason": "empty claim text", + }) + claim_statuses.append({ + "text": "", "evidence_ids": eids, + "status": "SCHEMA_INVALID", "reasons": ["empty_text"], + }) + continue + + if len(eids) > max_evidence_per_claim: + violations.append({ + "kind": "TOO_MANY_EVIDENCE_IDS", "claim_idx": idx, + "claim_text": claim_text[:200], + "n_ids": len(eids), "max": max_evidence_per_claim, + }) + + # Per-id resolution + checks. ``eids`` are pointer_ids + # (E1, E2, …) emitted by the model; we resolve each to its + # EvidenceObject and capture the content-addressed + # ``evidence_id`` for the cache/run-DAG handle. Pointer-style + # IDs make fabrication obvious — if only E1-E10 were shown, + # an emitted "E27" reads as a hallucination at the schema + # check, not as a near-miss content-addressed string. + per_id_results = [] + verified_pointer_ids: list[str] = [] + verified_evidence_ids: list[str] = [] + for eid in eids: + if not isinstance(eid, str): + per_id_results.append({"eid": str(eid), "ok": False, "kind": "SCHEMA_INVALID"}) + continue + obj = by_pointer.get(eid) + if obj is None: + per_id_results.append({"eid": eid, "ok": False, "kind": "UNKNOWN_EVIDENCE_ID"}) + violations.append({ + "kind": "UNKNOWN_EVIDENCE_ID", + "claim_idx": idx, "evidence_id": eid, + }) + continue + if obj.source_role not in allowed_source_roles: + per_id_results.append({"eid": eid, "ok": False, "kind": "SOURCE_ROLE_BLOCKED"}) + violations.append({ + "kind": "SOURCE_ROLE_BLOCKED", + "claim_idx": idx, "evidence_id": obj.evidence_id, + "pointer_id": eid, + "source_role": obj.source_role, + }) + continue + if not _claim_textually_overlaps_evidence( + claim_text, obj.span, min_coverage=min_citation_coverage + ): + per_id_results.append({"eid": eid, "ok": False, "kind": "CITATION_MISMATCH"}) + violations.append({ + "kind": "CITATION_MISMATCH", + "claim_idx": idx, "evidence_id": obj.evidence_id, + "pointer_id": eid, + "claim_text": claim_text[:200], + }) + continue + per_id_results.append({"eid": eid, "ok": True}) + verified_pointer_ids.append(eid) + verified_evidence_ids.append(obj.evidence_id) + + n_pairs += max(1, len(eids)) + n_pairs_verified += len(verified_pointer_ids) + + if not eids: + claim_statuses.append({ + "text": claim_text, "evidence_ids": [], + "status": "NO_EVIDENCE_POINTER", + "reasons": ["no_evidence_ids"], + }) + unverified.append(claim_text) + n_pairs += 1 + continue + + if len(verified_pointer_ids) == len(eids): + status = "EVIDENCE_LINKED" + elif verified_pointer_ids: + status = "EVIDENCE_LINKED_PARTIAL" + else: + # Pick the worst per-id reason for the claim status. + kinds = [r["kind"] for r in per_id_results if not r["ok"]] + status = kinds[0] if kinds else "UNKNOWN_EVIDENCE_ID" + unverified.append(claim_text) + + # claim_statuses records BOTH ids: pointer (what model wrote) + # and content-addressed (run-stable handle). Keeps the audit + # trail legible at both layers. + claim_statuses.append({ + "text": claim_text, + "pointer_ids": list(eids), + "evidence_ids": list(verified_evidence_ids), + "status": status, + "reasons": [r["kind"] for r in per_id_results if not r["ok"]], + }) + if verified_pointer_ids: + # Renderer takes the pointer-id form (model's view) and the + # by_pointer index; cache/run-DAG get the content-addressed + # evidence_ids (run-stable form). + valid_claims.append({ + "text": claim_text, + "pointer_ids": verified_pointer_ids, + }) + evidence_id_pairs.append(list(verified_evidence_ids)) + + rendered_text = _render(valid_claims, by_pointer) if valid_claims else "" + + if n_pairs_verified > 0 and not violations: + audit_mode = "STRICT" + elif n_pairs_verified > 0: + audit_mode = "HYBRID" + else: + audit_mode = "UNGROUNDED" + + # Warrant-lite — same relation-question hard check as the pointer + # variant. See verify_claim_lattice for the rationale (Ticket H, + # 2026-05-01). Identical demote-to-HYBRID semantics; the JSON + # variant carries the same WARRANT_MISSING violations & the same + # warrant_missing_claim_idxs field on the verdict. + warrant_missing_claims: list[int] = [] + if warrant_check_enabled: + for cs in claim_statuses: + if cs.get("status") not in ("EVIDENCE_LINKED", "EVIDENCE_LINKED_PARTIAL"): + continue + cited_eids = cs.get("evidence_ids") or [] + cited_spans = [ + obj.span + for eid in cited_eids + for obj in [evidence_map_by_evidence_id_local(evidence_map, eid)] + if obj is not None + ] + ok, missing = warrant_check( + cs.get("text") or "", cited_spans, question=question + ) + if not ok: + warrant_missing_claims.append(cs.get("claim_idx")) + violations.append({ + "kind": "WARRANT_MISSING", + "claim_idx": cs.get("claim_idx"), + "missing_anchors": missing, + "rationale": ( + "claim asserts an answer entity or specific date " + "not present in any cited span — pointer-linked " + "but warrant missing" + ), + }) + if warrant_missing_claims and audit_mode == "STRICT": + audit_mode = "HYBRID" + + # Rule 8 — Title-relevance check (mirrors the pointer variant). + # See verify_claim_lattice for rationale (2026-05-02 spin-glass + # case). Demote-to-HYBRID semantics; JSON variant emits the same + # TITLE_MISMATCH violation kind & title_mismatch_claim_idxs field. + title_mismatch_claims: list[int] = [] + for cs in claim_statuses: + if cs.get("status") not in ("EVIDENCE_LINKED", "EVIDENCE_LINKED_PARTIAL"): + continue + cited_eids = cs.get("evidence_ids") or [] + cited_titles = [ + obj.title + for eid in cited_eids + for obj in [evidence_map_by_evidence_id_local(evidence_map, eid)] + if obj is not None + ] + if not cited_titles: + continue + any_overlap = any( + _claim_title_overlap(cs.get("text") or "", t) + for t in cited_titles + ) + if not any_overlap: + title_mismatch_claims.append(cs.get("claim_idx")) + violations.append({ + "kind": "TITLE_MISMATCH", + "claim_idx": cs.get("claim_idx"), + "claim_text": (cs.get("text") or "")[:200], + "cited_titles": cited_titles, + "rationale": ( + "no cited source's title shares a content token " + "with the claim — pointer-linked but the cited " + "document is structurally unrelated to the claim" + ), + }) + if title_mismatch_claims and audit_mode == "STRICT": + audit_mode = "HYBRID" + # Tightening (2026-05-02): mirrors the pointer-variant promotion. + # When EVERY resolving claim has TITLE_MISMATCH, demote to + # UNGROUNDED — the substrate has zero structural grounding for + # the user's question. See verify_claim_lattice for full rationale. + n_resolving = sum( + 1 + for cs in claim_statuses + if cs.get("status") in ("EVIDENCE_LINKED", "EVIDENCE_LINKED_PARTIAL") + ) + if ( + title_mismatch_claims + and n_resolving > 0 + and len(title_mismatch_claims) == n_resolving + ): + audit_mode = "UNGROUNDED" + + # Rule 9 — Subject-tokens-absent / premise-parroting check. See + # `verify_claim_lattice` for the full rationale. + subject_absent_claims: list[int] = [] + if question and subject_tokens_absent_threshold > 0: + for cs in claim_statuses: + if cs.get("status") not in ("EVIDENCE_LINKED", "EVIDENCE_LINKED_PARTIAL"): + continue + cited_eids = cs.get("evidence_ids") or [] + cited_spans = [] + for eid in cited_eids: + obj = evidence_map_by_evidence_id_local(evidence_map, eid) + if obj is not None and obj.span: + cited_spans.append(obj.span) + if not cited_spans: + continue + absent = _parroted_subject_tokens_absent( + question, cs.get("text") or "", cited_spans + ) + if len(absent) >= subject_tokens_absent_threshold: + subject_absent_claims.append(cs.get("claim_idx")) + violations.append({ + "kind": "SUBJECT_TOKENS_ABSENT", + "claim_idx": cs.get("claim_idx"), + "claim_text": (cs.get("text") or "")[:200], + "absent_tokens": sorted(absent), + "rationale": ( + f"{len(absent)} question-distinctive tokens echoed in " + f"the claim are absent from cited evidence — claim " + f"parrots question premise without anchoring it" + ), + }) + if subject_absent_claims and audit_mode == "STRICT": + audit_mode = "HYBRID" + + # Deflection check (parallel to pointer-variant promotion). + deflection_detected = False + if deflection_check_enabled and question and rendered_text: + # Deferred import to avoid pulling aborist.compress + aborist.store + # at verify.py module-load time when callers may not need them. + from aborist.qa.inspect import diagnose_deflection + signal = diagnose_deflection(question, rendered_text) + if signal.get("kind") == "deflection": + deflection_detected = True + violations.append({ + "kind": "DEFLECTION_DETECTED", + "subject_anchor": signal.get("subject_anchor"), + "overlap_ratio": signal.get("overlap_ratio"), + "rationale": ( + "answer's content tokens omit the question's " + "subject anchor — model answered an adjacent " + "grounded question rather than the user's " + "specific one" + ), + }) + if deflection_detected and audit_mode == "STRICT": + audit_mode = "HYBRID" + + # Same `verifier_method` as the pointer variant ("claim_lattice") + # so the providence_cache CHECK constraint accepts both. The mode + # is disambiguated downstream via `answer_mode` on the run-DAG & + # via the JSON-only `json_fixups` field on this verdict. + return { + "n_quotes": n_pairs, + "n_verified": n_pairs_verified, + "audit_mode": audit_mode, + "unverified_quotes": unverified, + "verifier_method": "claim_lattice", + "claim_statuses": claim_statuses, + "violations": violations, + "rendered_text": rendered_text, + "evidence_id_pairs": evidence_id_pairs, + "json_fixups": json_fixups, + "warrant_missing_claim_idxs": warrant_missing_claims, + "title_mismatch_claim_idxs": title_mismatch_claims, + }
+ +
+
+
+
+ + +
+
+ + Made with Sphinx and @pradyunsg's + + Furo + +
+
+ +
+
+ +
+
+ +
+
+ + + + + \ No newline at end of file diff --git a/docs/_source/_build/html/_modules/aborist/search/base.html b/docs/_source/_build/html/_modules/aborist/search/base.html new file mode 100644 index 0000000..852e070 --- /dev/null +++ b/docs/_source/_build/html/_modules/aborist/search/base.html @@ -0,0 +1,325 @@ + + + + + + + + aborist.search.base - Aborist API Reference + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + + + +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for aborist.search.base

+"""Search backend ABC + Hit dataclass with explicit audit mode.
+
+Every search hit carries an `audit_mode` so callers never overclaim. Aborist
+adapts the Merkle-AGI v7 audit-mode trichotomy to the RAG layer:
+- STRICT     — Merkle-verified evidence: every claim cited verbatim against
+               the source-content tree.
+- HYBRID     — partial / mixed evidence: some claims source-grounded, others
+               emerged from training. Cache or search hit is partially trusted.
+- UNGROUNDED — no recoverable proof of grounding. Keyword (FTS5) hits land
+               here by default; LLM answers fall here when no double-quoted
+               span, sentence, or proper-noun phrase verifies against context.
+               Substrate name was VISUAL (no formal guarantees attached); we
+               renamed to UNGROUNDED so the RAG semantic is explicit.
+"""
+
+from __future__ import annotations
+
+import enum
+import sqlite3
+from abc import ABC, abstractmethod
+from dataclasses import dataclass
+
+
+
+[docs] +class AuditMode(str, enum.Enum): + STRICT = "STRICT" + HYBRID = "HYBRID" + UNGROUNDED = "UNGROUNDED"
+ + + +
+[docs] +@dataclass(frozen=True) +class Hit: + document_root: str + document_uri: str + chunk_idx: int + snippet: str + score: float + audit_mode: AuditMode + title: str | None = None
+ + + +
+[docs] +class SearchBackend(ABC): + """A search hook over the chunk store.""" + + name: str + audit_mode: AuditMode # default mode this backend reports + + def __init__(self, conn: sqlite3.Connection): + self.conn = conn + +
+[docs] + @abstractmethod + def search(self, query: str, limit: int = 20) -> list[Hit]: + ...
+
+ +
+
+
+
+ + +
+
+ + Made with Sphinx and @pradyunsg's + + Furo + +
+
+ +
+
+ +
+
+ +
+
+ + + + + \ No newline at end of file diff --git a/docs/_source/_build/html/_modules/aborist/search/fts5.html b/docs/_source/_build/html/_modules/aborist/search/fts5.html new file mode 100644 index 0000000..bfa9573 --- /dev/null +++ b/docs/_source/_build/html/_modules/aborist/search/fts5.html @@ -0,0 +1,508 @@ + + + + + + + + aborist.search.fts5 - Aborist API Reference + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + + + +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for aborist.search.fts5

+"""SQLite FTS5 keyword search. Returns UNGROUNDED-mode hits (no proof claim).
+
+Snippet generation runs in Python because chunks_fts is contentless
+(`content=''`) — SQLite's snippet()/highlight() functions return empty
+strings under contentless mode. We JOIN the FTS5 hit rowid back to
+`chunks.chunk_id` to fetch and decompress the original chunk content,
+then locate query tokens locally.
+"""
+
+from __future__ import annotations
+
+import re
+
+from aborist.compress import unpack_chunk
+from aborist.search.base import AuditMode, Hit, SearchBackend
+
+
+_FTS5_TOKEN_RE = re.compile(r"[A-Za-z][A-Za-z0-9]*")
+_FTS5_STOPWORDS = frozenset(
+    """
+    the a an is are was were be been being
+    of to in on at for with by from as about into through during
+    and or but not no nor so yet too very also just
+    what who where when why how which this that these those such
+    i you he she it we they me him her us them
+    do does did have has had can could should would will may might
+    tell show describe explain summarize say give list find make
+    please
+    all there know everything anything something
+
+    one some another without soon currently
+    """.split()
+)
+
+
+# OR-mode fallback hits SQLite FTS5 with one clause per token. With 15+
+# tokens including common English connectors it matches millions of
+# docs, then BM25 has to rank them all to pick the top-K — 13s/shard
+# observed on a 3.47M-doc corpus. Long queries that fail AND-mode
+# should not pay for an OR-mode that's effectively a full-corpus scan.
+# Cap OR-mode at the top-N LONGEST tokens (proxy for rarity / topical
+# specificity — "neurotechnology" matters; "soon" doesn't).
+_OR_FALLBACK_MAX_TOKENS = 5
+
+
+def _query_tokens(query: str) -> list[str]:
+    raw = _FTS5_TOKEN_RE.findall(query)
+    return [t for t in raw if t.lower() not in _FTS5_STOPWORDS and len(t) > 1]
+
+
+def _quote(t: str) -> str:
+    return '"' + t.replace('"', '""') + '"'
+
+
+def _escape_fts5(
+    query: str,
+    *,
+    mode: str = "and",
+    extra_or_tokens: set[str] | None = None,
+) -> str:
+    """Build a MATCH expression from a free-text query.
+
+    Tokenizes by alpha runs (so `?` and other punctuation can't break the
+    quoted phrase), drops question stopwords ('what', 'tell', etc.), then:
+
+    - mode='and' (default): every content token must appear in the doc.
+      Strict relevance — keeps unrelated docs out of the context window.
+    - mode='or':  the top-N LONGEST tokens (proxy for topical specificity)
+      get OR-joined. Used as a fallback when AND returns zero hits.
+      Capped at ``_OR_FALLBACK_MAX_TOKENS`` so OR-mode doesn't degrade
+      into a full-corpus scan on long noisy queries (a 19-token OR
+      clause matches millions of docs and forces BM25 to rank them all
+      — 13s/shard observed pre-cap).
+
+    The ``extra_or_tokens`` kwarg accepts synonym-expanded tokens
+    (e.g. "telepathy" / "neurotechnology" expanded from the query token
+    "thoughts"). These join the top-N-longest OR pool — long synonym
+    tokens like "neurotechnology" (15 chars) outrank short query tokens
+    like "thoughts" (8) by length and surface the right titles. Pure
+    quality win at OR-mode-fallback time without extra retrieval cost
+    since the top-N cap still applies to the merged pool.
+
+    All-stopword queries fall back to OR over the raw tokens so they
+    still find something instead of crashing FTS5 with an empty MATCH.
+    """
+    tokens = _query_tokens(query)
+    if not tokens:
+        raw = _FTS5_TOKEN_RE.findall(query)
+        tokens = [t for t in raw if len(t) > 1] or ['""']
+        sep = " OR "
+    elif mode == "and":
+        sep = " AND "
+    else:
+        # OR fallback: keep only the rarest tokens (approximated by
+        # longest — long words tend to be more topical / rarer in the
+        # corpus). Bounds the per-clause cost so OR-mode terminates
+        # quickly instead of scanning the corpus.
+        sep = " OR "
+        candidate_pool = list(tokens)
+        if extra_or_tokens:
+            # Synonyms join the OR pool; dedupe lowercase.
+            seen_lower = {t.lower() for t in candidate_pool}
+            for t in extra_or_tokens:
+                tl = t.lower()
+                if tl and tl not in seen_lower and tl not in _FTS5_STOPWORDS:
+                    candidate_pool.append(tl)
+                    seen_lower.add(tl)
+        if len(candidate_pool) > _OR_FALLBACK_MAX_TOKENS:
+            candidate_pool = sorted(candidate_pool, key=len, reverse=True)[
+                :_OR_FALLBACK_MAX_TOKENS
+            ]
+        tokens = candidate_pool
+    return sep.join(_quote(t) for t in tokens)
+
+
+# Snippet rendering. Locate any query token (case-insensitive) in the chunk
+# text, return ~16 words of surrounding context with the matched token
+# bracketed. If no token matches (rare: query was all-stopwords or the
+# tokens only appear in titles), fall back to the chunk's leading slice.
+_SNIPPET_WINDOW_WORDS = 16
+
+
+def _build_snippet(text: str, query: str) -> str:
+    if not text:
+        return ""
+    tokens = _query_tokens(query)
+    if not tokens:
+        # Best-effort: leading slice.
+        words = text.split()
+        return " ".join(words[: _SNIPPET_WINDOW_WORDS * 2])
+
+    # Find the earliest case-insensitive match of any query token.
+    text_lower = text.lower()
+    best_pos = -1
+    best_token = ""
+    for t in tokens:
+        pos = text_lower.find(t.lower())
+        if pos >= 0 and (best_pos < 0 or pos < best_pos):
+            best_pos = pos
+            best_token = t
+    if best_pos < 0:
+        words = text.split()
+        return " ".join(words[: _SNIPPET_WINDOW_WORDS * 2])
+
+    # Walk word boundaries around the match position.
+    words = text.split()
+    if not words:
+        return ""
+    # Map character position to word index (approximate — split() collapses
+    # runs of whitespace; close enough for visual snippet purposes).
+    char_count = 0
+    target_word = 0
+    for i, w in enumerate(words):
+        char_count += len(w) + 1  # +1 for the join space
+        if char_count > best_pos:
+            target_word = i
+            break
+
+    start = max(0, target_word - _SNIPPET_WINDOW_WORDS)
+    end = min(len(words), target_word + _SNIPPET_WINDOW_WORDS)
+    window = words[start:end]
+
+    # Bracket every case-insensitive occurrence of every matched token in
+    # the window. Done with a precompiled regex for each token.
+    rendered = " ".join(window)
+    for t in tokens:
+        pat = re.compile(re.escape(t), re.IGNORECASE)
+        rendered = pat.sub(lambda m: f"[{m.group(0)}]", rendered)
+
+    if start > 0:
+        rendered = "…" + rendered
+    if end < len(words):
+        rendered = rendered + "…"
+    return rendered
+
+
+
+[docs] +class FTS5Backend(SearchBackend): + name = "fts5" + audit_mode = AuditMode.UNGROUNDED + +
+[docs] + def search( + self, + query: str, + limit: int = 20, + extra_or_tokens: set[str] | None = None, + ) -> list[Hit]: + """Run FTS5 BM25 over chunk content. + + ``extra_or_tokens`` (synonym-expanded set) is passed through to + the OR-mode fallback only. AND mode stays on original query + tokens (adding synonyms there would relax the AND constraint + and pull in noise). The intended caller is the retrieval + pipeline that has already computed ``synonym_expand(qtokens)`` + — passing it here saves the OR-mode pool from missing topical + synonym terms. + """ + if not query.strip(): + return [] + # Try strict AND first; fall back to OR if it returns nothing. + for mode in ("and", "or"): + fts_query = _escape_fts5( + query, + mode=mode, + extra_or_tokens=extra_or_tokens if mode == "or" else None, + ) + try: + rows = self.conn.execute( + """ + SELECT + c.document_root, + c.idx, + c.content AS raw_content, + bm25(chunks_fts) AS rank, + d.document_uri, + d.title + FROM chunks_fts AS f + JOIN chunks AS c ON c.chunk_id = f.rowid + JOIN documents AS d ON d.document_root = c.document_root + WHERE chunks_fts MATCH ? + ORDER BY rank ASC + LIMIT ? + """, + (fts_query, limit), + ).fetchall() + except Exception: + rows = [] + if rows: + break + return [ + Hit( + document_root=r["document_root"], + document_uri=r["document_uri"], + chunk_idx=r["idx"], + snippet=_build_snippet(unpack_chunk(r["raw_content"]) or "", query), + # bm25 returns negative numbers (lower = better); flip sign. + score=-float(r["rank"]) if r["rank"] is not None else 0.0, + audit_mode=self.audit_mode, + title=r["title"], + ) + for r in rows + ]
+
+ +
+
+
+
+ + +
+
+ + Made with Sphinx and @pradyunsg's + + Furo + +
+
+ +
+
+ +
+
+ +
+
+ + + + + \ No newline at end of file diff --git a/docs/_source/_build/html/_modules/aborist/source.html b/docs/_source/_build/html/_modules/aborist/source.html new file mode 100644 index 0000000..4c5bde8 --- /dev/null +++ b/docs/_source/_build/html/_modules/aborist/source.html @@ -0,0 +1,291 @@ + + + + + + + + aborist.source - Aborist API Reference + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + + + +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for aborist.source

+"""Source ABC.
+
+Adding a new corpus to aborist = one new Source subclass. The Source contract
+is intentionally minimal: yield Document objects, one at a time.
+"""
+
+from __future__ import annotations
+
+from abc import ABC, abstractmethod
+from typing import Iterator
+
+from aborist.document import Document
+
+
+
+[docs] +class Source(ABC): + """A corpus that yields documents into the ingest pipeline.""" + + #: source_type tag stored on every Document this source produces. + source_type: str + +
+[docs] + @abstractmethod + def iter_documents(self) -> Iterator[Document]: + """Yield Document objects. Must be deterministic & idempotent.""" + ...
+
+ +
+
+
+
+ + +
+
+ + Made with Sphinx and @pradyunsg's + + Furo + +
+
+ +
+
+ +
+
+ +
+
+ + + + + \ No newline at end of file diff --git a/docs/_source/_build/html/_modules/aborist/sources/html_page.html b/docs/_source/_build/html/_modules/aborist/sources/html_page.html new file mode 100644 index 0000000..3f5774f --- /dev/null +++ b/docs/_source/_build/html/_modules/aborist/sources/html_page.html @@ -0,0 +1,419 @@ + + + + + + + + aborist.sources.html_page - Aborist API Reference + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + + + +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for aborist.sources.html_page

+"""HTML page source.
+
+Fetches URLs, honors robots.txt automatically, strips noise (script/style/nav/
+footer/header), extracts main body text + outbound `<a href>` links as edges.
+
+Optional dependency. Install with `pip install aborist[html]`.
+"""
+
+from __future__ import annotations
+
+import re
+import urllib.parse
+import urllib.robotparser
+from pathlib import Path
+from typing import Iterable, Iterator
+
+try:
+    import httpx
+    from selectolax.parser import HTMLParser
+except ImportError as e:  # pragma: no cover
+    raise ImportError(
+        "HTML source requires extras: pip install 'aborist[html]'"
+    ) from e
+
+from aborist.document import Document, Edge
+from aborist.source import Source
+
+
+USER_AGENT = "aborist/0.0.1 (+https://unturf.com)"
+NOISE_SELECTORS = ("script", "style", "noscript", "nav", "header", "footer", "aside")
+
+
+def _normalize_text(text: str) -> str:
+    text = re.sub(r"[ \t]+", " ", text)
+    text = re.sub(r"\n{3,}", "\n\n", text)
+    return text.strip()
+
+
+
+[docs] +def parse_html(url: str, html: str, source_type: str = "html") -> Document | None: + """Pure parse function. Separated so tests can run without network.""" + tree = HTMLParser(html) + for sel in NOISE_SELECTORS: + for node in tree.css(sel): + node.decompose() + + body = tree.css_first("body") or tree.root + if body is None: + return None + text = _normalize_text(body.text(separator="\n", strip=True)) + if not text: + return None + + title_node = tree.css_first("title") + title = title_node.text(strip=True) if title_node is not None else None + + edges: list[Edge] = [] + seen: set[tuple[str, str]] = set() + for a in tree.css("a[href]"): + href = (a.attributes.get("href") or "").strip() + if not href or href.startswith(("javascript:", "mailto:", "tel:", "#")): + continue + absolute = urllib.parse.urljoin(url, href) + split = urllib.parse.urlsplit(absolute) + if split.scheme not in ("http", "https"): + continue + anchor = split.fragment or "" + dst_uri = urllib.parse.urlunsplit(split._replace(fragment="")) + key = (dst_uri, anchor) + if key in seen: + continue + seen.add(key) + edges.append(Edge(edge_type="hyperlink", dst_uri=dst_uri, anchor=anchor or None)) + + return Document( + uri=url, + content=text, + source_type=source_type, + title=title, + edges=edges, + )
+ + + +
+[docs] +class HtmlPageSource(Source): + """Iterates a list of URLs, fetching and parsing each as HTML.""" + + source_type = "html" + + def __init__( + self, + urls: Iterable[str], + *, + respect_robots: bool = True, + timeout: float = 30.0, + ): + self.urls = list(urls) + self.respect_robots = respect_robots + self.timeout = timeout + self._robots_cache: dict[str, urllib.robotparser.RobotFileParser] = {} + +
+[docs] + @classmethod + def from_file(cls, path: str | Path, **kwargs) -> HtmlPageSource: + urls = [ + line.strip() + for line in Path(path).read_text(encoding="utf-8").splitlines() + if line.strip() and not line.lstrip().startswith("#") + ] + return cls(urls, **kwargs)
+ + +
+[docs] + def iter_documents(self) -> Iterator[Document]: + with httpx.Client( + headers={"User-Agent": USER_AGENT}, + timeout=self.timeout, + follow_redirects=True, + ) as client: + for url in self.urls: + if self.respect_robots and not self._allowed(client, url): + continue + try: + resp = client.get(url) + resp.raise_for_status() + except httpx.HTTPError: + continue + ctype = resp.headers.get("content-type", "").lower() + if "html" not in ctype and "xml" not in ctype: + continue + doc = parse_html(str(resp.url), resp.text, self.source_type) + if doc is not None: + yield doc
+ + + def _allowed(self, client: "httpx.Client", url: str) -> bool: + parsed = urllib.parse.urlparse(url) + origin = f"{parsed.scheme}://{parsed.netloc}" + rp = self._robots_cache.get(origin) + if rp is None: + rp = urllib.robotparser.RobotFileParser() + try: + resp = client.get(f"{origin}/robots.txt") + except httpx.HTTPError: + resp = None + if resp is not None and resp.status_code == 200: + rp.parse(resp.text.splitlines()) + else: + # Missing robots.txt = no rules per RFC 9309. + rp.allow_all = True + self._robots_cache[origin] = rp + return rp.can_fetch(USER_AGENT, url)
+ +
+
+
+
+ + +
+
+ + Made with Sphinx and @pradyunsg's + + Furo + +
+
+ +
+
+ +
+
+ +
+
+ + + + + \ No newline at end of file diff --git a/docs/_source/_build/html/_modules/aborist/sources/wikipedia.html b/docs/_source/_build/html/_modules/aborist/sources/wikipedia.html new file mode 100644 index 0000000..7e01318 --- /dev/null +++ b/docs/_source/_build/html/_modules/aborist/sources/wikipedia.html @@ -0,0 +1,676 @@ + + + + + + + + aborist.sources.wikipedia - Aborist API Reference + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + + + +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for aborist.sources.wikipedia

+"""MediaWiki 'cur' table SQL dump source.
+
+Handles 2003-era SQL dumps in bz2 format (e.g. 20030516_cur_tablesql.bz2).
+Yields one Document per non-redirect main-namespace article, with
+[[wikilinks]] extracted as outbound edges.
+
+Implements a stream parser for MySQL extended INSERT syntax. The cur table
+schema for that era starts: cur_id, cur_namespace, cur_title, cur_text, ...
+We rely on positional access for the first four columns.
+"""
+
+from __future__ import annotations
+
+import bz2
+import re
+from pathlib import Path
+from typing import IO, Iterator
+
+from aborist.document import Document, Edge
+from aborist.source import Source
+
+
+# Match [[Target]], [[Target|display]], [[Target#anchor]] forms.
+WIKILINK_RE = re.compile(r"\[\[([^\]\|#\n\r]+)(?:#[^\]\|\n\r]*)?(?:\|[^\]\n\r]*)?\]\]")
+NAMESPACE_MAIN = 0
+
+
+_MYSQL_ESCAPE_MAP = {
+    "n": "\n",
+    "r": "\r",
+    "t": "\t",
+    "0": "\0",
+    "\\": "\\",
+    "'": "'",
+    '"': '"',
+    "Z": "\x1a",
+}
+
+
+def _decode_mysql_string(s: str) -> str:
+    """Decode MySQL-escaped string content (outer quotes already stripped).
+
+    Hot fast path: the vast majority of string fields contain no backslashes
+    at all. Returning the input unchanged for those skips the slow loop.
+    """
+    if "\\" not in s:
+        return s
+    out: list[str] = []
+    i = 0
+    n = len(s)
+    # Cache the mapping locally for tight-loop dispatch.
+    while i < n:
+        # Jump forward to the next backslash with C-level find.
+        bs = s.find("\\", i)
+        if bs < 0:
+            out.append(s[i:])
+            break
+        if bs > i:
+            out.append(s[i:bs])
+        if bs + 1 >= n:
+            out.append("\\")
+            i = bs + 1
+            continue
+        nxt = s[bs + 1]
+        out.append(_MYSQL_ESCAPE_MAP.get(nxt, nxt))
+        i = bs + 2
+    return "".join(out)
+
+
+def _find_string_end(payload: str, start: int) -> int:
+    """Return position of the closing `'` for a string opened just before `start`.
+
+    Backslash escapes considered: even count of preceding backslashes => the
+    quote is a real terminator; odd count => the quote is escaped.
+    """
+    n = len(payload)
+    i = start
+    while i < n:
+        pos = payload.find("'", i)
+        if pos < 0:
+            return -1
+        bs = 0
+        p = pos - 1
+        while p >= start and payload[p] == "\\":
+            bs += 1
+            p -= 1
+        if bs % 2 == 0:
+            return pos
+        i = pos + 1
+    return -1
+
+
+def _split_values_tuples(payload: str) -> list[list[str | None]]:
+    """Parse `(v1,v2,...),(...),...` into tuples of decoded strings or None.
+
+    String slicing + str.find based. No per-char list.append. Roughly 5-10x
+    faster than the char-by-char version on real Wikipedia dumps.
+    """
+    rows: list[list[str | None]] = []
+    i = 0
+    n = len(payload)
+    while i < n:
+        # Find the next '(' starting a tuple.
+        open_paren = payload.find("(", i)
+        if open_paren < 0:
+            break
+        i = open_paren + 1
+        values: list[str | None] = []
+        while i < n:
+            # Skip leading whitespace inside the tuple.
+            while i < n and payload[i] in " \t":
+                i += 1
+            if i >= n:
+                break
+            if payload[i] == "'":
+                end = _find_string_end(payload, i + 1)
+                if end < 0:
+                    return rows  # malformed; bail
+                values.append(_decode_mysql_string(payload[i + 1 : end]))
+                i = end + 1
+            else:
+                # NULL or unquoted scalar; ends at , or )
+                end = i
+                while end < n and payload[end] not in ",)":
+                    end += 1
+                v = payload[i:end].strip()
+                values.append(None if v.upper() == "NULL" else v)
+                i = end
+            while i < n and payload[i] in " \t":
+                i += 1
+            if i < n and payload[i] == ",":
+                i += 1
+                continue
+            if i < n and payload[i] == ")":
+                i += 1
+                break
+            break
+        rows.append(values)
+    return rows
+
+
+def _iter_insert_statements(file_obj: IO[str]) -> Iterator[str]:
+    """Yield complete SQL statements using buffered string find.
+
+    State persists across read() chunks via three indices:
+      yield_from  position in `pending` where the next yielded statement starts
+      scan_pos    position to resume the scanner from (do NOT restart at 0
+                  across chunks — would re-find the already-processed open `'`)
+      in_string   are we inside a single-quoted string
+
+    The previous version reset the scanner to position 0 on every chunk,
+    which silently flipped in_string=False as soon as the first `'` in the
+    new buffer was re-encountered (the title-field open quote of the
+    article we were already deep inside). Persisting scan_pos fixes it.
+    """
+    pending = ""
+    in_string = False
+    yield_from = 0  # start of current statement, in `pending` coords
+    scan_pos = 0    # next position to scan, in `pending` coords
+
+    while True:
+        chunk = file_obj.read(1 << 19)
+        if not chunk:
+            break
+        pending += chunk
+        n = len(pending)
+
+        i = scan_pos
+        while i < n:
+            if in_string:
+                # Find string terminator (handle escape parity).
+                end = i
+                while True:
+                    pos = pending.find("'", end)
+                    if pos < 0:
+                        i = n  # need more data
+                        break
+                    bs = 0
+                    # Don't walk back past the start of the current statement;
+                    # chars before yield_from belong to a previous statement.
+                    p = pos - 1
+                    while p >= yield_from and pending[p] == "\\":
+                        bs += 1
+                        p -= 1
+                    if bs % 2 == 0:
+                        in_string = False
+                        i = pos + 1
+                        break
+                    end = pos + 1
+                if in_string:
+                    break
+                continue
+            sc = pending.find(";", i)
+            qt = pending.find("'", i)
+            if sc < 0 and qt < 0:
+                i = n
+                break
+            if qt < 0 or (sc >= 0 and sc < qt):
+                yield pending[yield_from : sc + 1]
+                yield_from = sc + 1
+                i = sc + 1
+            else:
+                in_string = True
+                i = qt + 1
+
+        scan_pos = i
+        # Release yielded bytes; remap our two indices into the new buffer.
+        if yield_from > 0:
+            pending = pending[yield_from:]
+            scan_pos -= yield_from
+            yield_from = 0
+
+    tail = pending[yield_from:]
+    if tail.strip():
+        yield tail
+
+
+def _extract_wikilinks(text: str, base_uri: str) -> list[Edge]:
+    seen: set[str] = set()
+    edges: list[Edge] = []
+    for m in WIKILINK_RE.finditer(text):
+        target = m.group(1).strip()
+        if not target or target.startswith(":"):
+            continue
+        # Skip image / file / category interlinks (they often start "Image:" etc.)
+        if ":" in target:
+            continue
+        uri = base_uri + target.replace(" ", "_")
+        if uri in seen:
+            continue
+        seen.add(uri)
+        edges.append(Edge(edge_type="wikilink", dst_uri=uri))
+    return edges
+
+
+
+[docs] +class WikipediaSqlDump(Source): + """Iterates a MediaWiki SQL table dump (cur or old), bz2 or plain. + + Both `cur` (current snapshot) and `old` (revision history) tables share + the first four column positions: id, namespace, title, text. The `cur` + table has `cur_is_redirect` at position 10 (we skip redirects); `old` + has no redirect flag (every revision is real). + + Shard support: pass `shard=(rank, total)` and the source yields only + docs whose 0-based index satisfies `index % total == rank`. Useful for + spawning N parallel ingest processes against the same dump file — + parser CPU runs in parallel, writes serialize at the WAL writer lock. + """ + + def __init__( + self, + path: str | Path, + *, + table: str = "cur", + namespace: int = NAMESPACE_MAIN, + base_uri: str = "https://en.wikipedia.org/wiki/", + shard: tuple[int, int] | None = None, + start_id: int = 0, + encoding: str = "latin-1", + ): + if table not in ("cur", "old"): + raise ValueError("table must be 'cur' or 'old'") + self.path = Path(path) + self.table = table + self.namespace = namespace + self.base_uri = base_uri + self.source_type = f"wikipedia_{table}" + if shard is not None: + rank, total = shard + if not (0 <= rank < total) or total < 1: + raise ValueError( + f"invalid shard {shard}: need 0 <= rank < total, total >= 1" + ) + self.shard = shard + # Resume support: skip rows whose id (cur_id or old_id) is <= start_id. + # The rsync-style fast-forward — already-cached docs aren't re-hashed. + self.start_id = start_id + # High-water mark observed during iteration. Caller reads this back + # after each batch and persists it via store.set_meta(). + self.last_id: int = start_id + # 2003-era MediaWiki dumps mix Latin-1 raw bytes (e.g., 0xE9 for é) + # with HTML entities. Latin-1 decode is lossless on every byte and + # gives the right code point for the raw-byte cases. Modern dumps + # are UTF-8 — pass encoding="utf-8" for those. + self.encoding = encoding + +
+[docs] + def iter_documents(self) -> Iterator[Document]: + opener = bz2.open if str(self.path).endswith(".bz2") else open + rank, total = (0, 1) if self.shard is None else self.shard + idx = 0 + with opener(self.path, "rt", encoding=self.encoding, errors="replace") as f: + for stmt in _iter_insert_statements(f): + head = stmt.lstrip() + if not head.upper().startswith("INSERT INTO"): + continue + up = head.upper() + vidx = up.find("VALUES") + if vidx < 0: + continue + table_clause = head[:vidx].lower() + token = f" {self.table} " + if token not in (table_clause + " "): + continue + payload = head[vidx + len("VALUES"):] + payload = payload.rstrip().rstrip(";").rstrip() + for row in _split_values_tuples(payload): + # Stride filter applied at the row index — every shard sees + # the full SQL stream but only emits its share. + if idx % total != rank: + idx += 1 + continue + idx += 1 + # Resume fast-forward: skip rows already cached. We still + # parse the value tuples (needed to find row[0]) but skip + # the wikilink extraction + Document construction, which + # is the dominant per-row cost. + if self.start_id: + try: + row_id = int(row[0]) if row[0] is not None else 0 + except (ValueError, TypeError): + row_id = 0 + if row_id <= self.start_id: + continue + if row_id > self.last_id: + self.last_id = row_id + else: + try: + row_id = int(row[0]) if row[0] is not None else 0 + if row_id > self.last_id: + self.last_id = row_id + except (ValueError, TypeError): + pass + yield from self._row_to_doc(row)
+ + + def _row_to_doc(self, row: list[str | None]) -> Iterator[Document]: + if len(row) < 4: + return + try: + ns = int(row[1]) if row[1] is not None else None + except (ValueError, TypeError): + return + if ns != self.namespace: + return + title = row[2] or "" + text = row[3] or "" + if not title or not text: + return + # Cur-only: skip rows flagged as redirects (col 10 in the 2003 schema). + if self.table == "cur": + is_redirect = False + if len(row) > 10 and row[10] is not None: + try: + is_redirect = bool(int(row[10])) + except (ValueError, TypeError): + is_redirect = False + if is_redirect: + return + edges = _extract_wikilinks(text, self.base_uri) + uri = self.base_uri + title.replace(" ", "_") + extra: dict = {} + # Surface the row id for resume / chronological ordering. + if row[0] is not None: + extra[f"{self.table}_id"] = row[0] + # `old` rows also carry a timestamp at position 7. + if self.table == "old" and len(row) > 7 and row[7]: + extra["old_timestamp"] = row[7] + yield Document( + uri=uri, + content=text, + source_type=self.source_type, + title=title, + edges=edges, + extra=extra, + )
+ + + +# Backward-compatible thin wrapper. +
+[docs] +class WikipediaCurDump(WikipediaSqlDump): + """Iterates a MediaWiki 'cur' table SQL dump.""" + + def __init__( + self, + path: str | Path, + namespace: int = NAMESPACE_MAIN, + base_uri: str = "https://en.wikipedia.org/wiki/", + ): + super().__init__( + path, table="cur", namespace=namespace, base_uri=base_uri + )
+ + + +
+[docs] +class WikipediaOldDump(WikipediaSqlDump): + """Iterates a MediaWiki 'old' (revision history) table SQL dump.""" + + def __init__( + self, + path: str | Path, + namespace: int = NAMESPACE_MAIN, + base_uri: str = "https://en.wikipedia.org/wiki/", + ): + super().__init__( + path, table="old", namespace=namespace, base_uri=base_uri + )
+ +
+
+
+
+ + +
+
+ + Made with Sphinx and @pradyunsg's + + Furo + +
+
+ +
+
+ +
+
+ +
+
+ + + + + \ No newline at end of file diff --git a/docs/_source/_build/html/_modules/aborist/store.html b/docs/_source/_build/html/_modules/aborist/store.html new file mode 100644 index 0000000..fe719f5 --- /dev/null +++ b/docs/_source/_build/html/_modules/aborist/store.html @@ -0,0 +1,1293 @@ + + + + + + + + aborist.store - Aborist API Reference + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + + + +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for aborist.store

+"""SQLite-backed v9.8 store.
+
+Schema implements the Merkle-AGI v9.8 admissibility ledger:
+- 8-dim providence_cache key (source_root, question_hash, model_profile_hash,
+  conversation_hash, governance_policy_hash, schema_version,
+  canonicalization_version, chunking_version)
+- falsification_state ∈ {live, failed, stale, quarantined}
+- audit_events append-only chain (event_hash chains via prev_event_hash)
+- documents.kind ∈ {surface, core} for layered compression
+- chunks.tier ∈ {hot, warm, cold} for reversible eviction
+- derivations table binds core docs back to source surface roots
+
+The providence_cache layer is schema-only in Phase 0 — no Q&A inference yet.
+"""
+
+from __future__ import annotations
+
+import json
+import sqlite3
+import time
+from contextlib import contextmanager
+from pathlib import Path
+from typing import Iterator
+
+
+DEFAULT_DB_PATH = Path.home() / ".aborist" / "aborist.db"
+
+
+SCHEMA_SQL = """
+PRAGMA journal_mode = WAL;
+PRAGMA foreign_keys = ON;
+
+CREATE TABLE IF NOT EXISTS schema_meta (
+    key   TEXT PRIMARY KEY,
+    value TEXT NOT NULL
+);
+
+-- Free-form per-DB metadata. Used by the resume mechanic to track each
+-- source's high-water mark so a stopped ingest can rsync forward without
+-- re-parsing rows that are already in this DB.
+CREATE TABLE IF NOT EXISTS meta (
+    key   TEXT PRIMARY KEY,
+    value TEXT NOT NULL,
+    updated_at INTEGER
+);
+
+-- Documents: surface (raw ingest) or core (distilled, Merkle-signed back).
+CREATE TABLE IF NOT EXISTS documents (
+    document_root            TEXT PRIMARY KEY,    -- hex sha256 of merkle root
+    document_uri             TEXT NOT NULL,
+    source_type              TEXT NOT NULL,
+    kind                     TEXT NOT NULL DEFAULT 'surface'
+                                CHECK (kind IN ('surface','core')),
+    compression_depth        INTEGER NOT NULL DEFAULT 0,
+    title                    TEXT,
+    chunking_version         TEXT NOT NULL,
+    canonicalization_version TEXT NOT NULL,
+    schema_version           TEXT NOT NULL,
+    ingest_ts                INTEGER NOT NULL,
+    hit_count                INTEGER NOT NULL DEFAULT 0,
+    last_hit_at              INTEGER
+);
+CREATE INDEX IF NOT EXISTS idx_documents_uri  ON documents(document_uri);
+CREATE INDEX IF NOT EXISTS idx_documents_kind ON documents(kind);
+
+-- Per-document HTTP metadata for cheap recrawl-checks. ETag and
+-- Last-Modified come from the response headers at ingest time and let a
+-- recrawl pass send conditional HEAD requests (If-None-Match /
+-- If-Modified-Since); a 304 lets us skip the body fetch entirely.
+-- last_status / last_checked_at record the most recent recheck so
+-- operators can audit "when was this URL last verified."
+CREATE TABLE IF NOT EXISTS document_http_meta (
+    document_root    TEXT PRIMARY KEY,
+    etag             TEXT,
+    last_modified    TEXT,
+    last_fetched_at  INTEGER NOT NULL,
+    last_status      INTEGER,
+    last_checked_at  INTEGER,
+    FOREIGN KEY (document_root) REFERENCES documents(document_root) ON DELETE CASCADE
+);
+
+-- Chunks with tier-based reversible eviction.
+-- content nullable: cold tier evicts content but retains leaf_hash + URI for
+-- rehydration. Identity verified on rehydrate by recomputing leaf_hash.
+--
+-- chunk_id INTEGER PRIMARY KEY AUTOINCREMENT serves dual duty: it's both the
+-- primary key and the rowid that the contentless FTS5 virtual table joins
+-- against. The (document_root, idx) UNIQUE constraint preserves the prior
+-- "one chunk per (doc, position)" invariant for callers that look up by it.
+CREATE TABLE IF NOT EXISTS chunks (
+    chunk_id      INTEGER PRIMARY KEY AUTOINCREMENT,
+    document_root TEXT NOT NULL,
+    idx           INTEGER NOT NULL,
+    leaf_hash     TEXT NOT NULL,
+    content       TEXT,
+    tier          TEXT NOT NULL DEFAULT 'hot'
+                     CHECK (tier IN ('hot','warm','cold')),
+    UNIQUE (document_root, idx),
+    FOREIGN KEY (document_root) REFERENCES documents(document_root) ON DELETE CASCADE
+);
+CREATE INDEX IF NOT EXISTS idx_chunks_leaf ON chunks(leaf_hash);
+
+-- Interior Merkle nodes (layer >= 1). Layer 0 lives in chunks.leaf_hash.
+CREATE TABLE IF NOT EXISTS merkle_nodes (
+    document_root TEXT NOT NULL,
+    layer         INTEGER NOT NULL,
+    idx           INTEGER NOT NULL,
+    hash          TEXT NOT NULL,
+    PRIMARY KEY (document_root, layer, idx),
+    FOREIGN KEY (document_root) REFERENCES documents(document_root) ON DELETE CASCADE
+);
+
+-- Cross-links between documents (the forest).
+-- Unresolved forward links (dst not yet ingested) carry dst_root='' and the
+-- ingest pass backfills dst_root when the target appears.
+--
+-- WITHOUT ROWID: the PK covers every column, so a default rowid-based table
+-- would near-duplicate the row data in the PK index. WITHOUT ROWID makes
+-- the table itself a B-tree keyed on the PK and saves ~50% of edge storage
+-- on real Wikipedia ingests (measured: 38 MB -> 21 MB / 1000 docs).
+-- Behaviorally identical; only the on-disk layout changes.
+CREATE TABLE IF NOT EXISTS edges (
+    src_root  TEXT NOT NULL,
+    dst_root  TEXT NOT NULL DEFAULT '',      -- '' = unresolved, backfilled later
+    dst_uri   TEXT NOT NULL DEFAULT '',      -- always present so we can resolve later
+    edge_type TEXT NOT NULL,                 -- wikilink, citation, derived_from, ...
+    anchor    TEXT NOT NULL DEFAULT '',      -- chunk index or fragment, '' if N/A
+    PRIMARY KEY (src_root, edge_type, dst_root, dst_uri, anchor)
+) WITHOUT ROWID;
+CREATE INDEX IF NOT EXISTS idx_edges_dst_root ON edges(dst_root) WHERE dst_root <> '';
+-- idx_edges_dst_uri intentionally omitted: only the gravity_top_inbound
+-- analytical query in cli.py filters on dst_uri alone, and a full scan +
+-- sort over edges is acceptable for that one-shot reporting path.
+
+-- Distillation: core_root <- src_root with Merkle-signed proof binding.
+CREATE TABLE IF NOT EXISTS derivations (
+    core_root    TEXT NOT NULL,
+    src_root     TEXT NOT NULL,
+    proof_blob   TEXT NOT NULL,              -- JSON merkle proof
+    process_id   TEXT NOT NULL,              -- distillation process identifier
+    distilled_at INTEGER NOT NULL,
+    PRIMARY KEY (core_root, src_root, process_id),
+    FOREIGN KEY (core_root) REFERENCES documents(document_root) ON DELETE CASCADE,
+    FOREIGN KEY (src_root)  REFERENCES documents(document_root) ON DELETE CASCADE
+);
+
+-- v9.8 providence cache: 8-dim admissibility key + falsification state.
+-- Schema-only in Phase 0 (no Q&A runs yet); ready for Phase 1.
+CREATE TABLE IF NOT EXISTS providence_cache (
+    cache_key                TEXT PRIMARY KEY,
+    source_root              TEXT NOT NULL,
+    document_uri             TEXT NOT NULL,
+    question_hash            TEXT NOT NULL,
+    question_text            TEXT NOT NULL,
+    answer_text              TEXT NOT NULL,
+    merkle_proof             TEXT NOT NULL,   -- JSON
+    model_profile_hash       TEXT NOT NULL,   -- model_id + revision + quantization
+    conversation_hash        TEXT NOT NULL,
+    governance_policy_hash   TEXT NOT NULL,
+    schema_version           TEXT NOT NULL,
+    canonicalization_version TEXT NOT NULL,
+    chunking_version         TEXT NOT NULL,
+    falsification_state      TEXT NOT NULL DEFAULT 'live'
+                                CHECK (falsification_state IN ('live','failed','stale','quarantined')),
+    chain                    TEXT NOT NULL DEFAULT 'private'
+                                CHECK (chain IN ('private','public')),
+    audit_event_hash         TEXT,            -- latest audit event for this record
+    created_at               INTEGER NOT NULL,
+    last_hit_at              INTEGER,
+    hit_count                INTEGER NOT NULL DEFAULT 0,
+    -- v9.8 audit_mode trichotomy (RAG-adapted vocabulary; substrate calls
+    -- UNGROUNDED "VISUAL"): STRICT (every quote in answer verified against
+    -- context), HYBRID (some claims verified, some emergent), UNGROUNDED
+    -- (no verbatim grounding — purely emergent from training).
+    -- Default UNGROUNDED: an unclassified record is the weakest claim.
+    audit_mode               TEXT NOT NULL DEFAULT 'UNGROUNDED'
+                                CHECK (audit_mode IN ('STRICT','HYBRID','UNGROUNDED')),
+    n_quotes                 INTEGER NOT NULL DEFAULT 0,
+    n_verified               INTEGER NOT NULL DEFAULT 0,
+    -- JSON array of quoted spans the model produced but we couldn't find
+    -- verbatim in context. Mining these surfaces "what the model emerged
+    -- beyond the corpus" — candidate ingest targets.
+    unverified_quotes        TEXT,
+    -- Which verifier strategy classified this record. 'quote' = model
+    -- followed the format and wrapped claims in double quotes. 'span' =
+    -- bullet/sentence-level substring match in context. 'entity' = no
+    -- spans matched but multi-word proper nouns did. 'paraphrase' =
+    -- token-coverage match (soft signal). 'claim_lattice' = quote-by-
+    -- pointer mode (model emitted JSON; verifier checked evidence_id
+    -- resolution + source_role + manual-quote prohibition). 'none' = no
+    -- evidence at all (truly emergent).
+    verifier_method          TEXT NOT NULL DEFAULT 'none'
+                                CHECK (verifier_method IN ('quote','span','entity','paraphrase','claim_lattice','none')),
+    -- Per-run Merkle-DAG. run_dag_root = MerkleTree over ordered stage
+    -- hashes (question / retrieval / context / prompt / answer / verify /
+    -- final_label). run_dag_blob carries the full {root, nodes} JSON so
+    -- an auditor can recompute & verify. Distinct from audit_event_hash
+    -- (linear DB-wide chain). NULL on legacy records pre-2026-04-30.
+    run_dag_root             TEXT,
+    run_dag_blob             TEXT
+);
+CREATE INDEX IF NOT EXISTS idx_providence_root  ON providence_cache(source_root);
+CREATE INDEX IF NOT EXISTS idx_providence_state ON providence_cache(falsification_state);
+-- idx_providence_audit lives in _migrate_audit_mode() so legacy shards
+-- (where audit_mode column gets added by ALTER TABLE) don't trip this
+-- script before the migration runs.
+
+-- Append-only audit chain. event_hash = sha256(prev_event_hash || canonical(body)).
+CREATE TABLE IF NOT EXISTS audit_events (
+    seq             INTEGER PRIMARY KEY AUTOINCREMENT,
+    event_hash      TEXT NOT NULL UNIQUE,
+    prev_event_hash TEXT,                    -- NULL for genesis
+    event_type      TEXT NOT NULL,           -- ingest|falsify|evict_warm|evict_cold|derive|rehydrate|...
+    subject_root    TEXT,                    -- document_root or cache_key
+    body            TEXT NOT NULL,           -- canonical JSON
+    ts              INTEGER NOT NULL
+);
+CREATE INDEX IF NOT EXISTS idx_audit_subject ON audit_events(subject_root);
+
+-- Falsification log: which records were marked failed/stale/quarantined and why.
+CREATE TABLE IF NOT EXISTS falsifications (
+    cache_key        TEXT NOT NULL,
+    state            TEXT NOT NULL,
+    reason           TEXT,
+    by_actor         TEXT,
+    at               INTEGER NOT NULL,
+    audit_event_hash TEXT NOT NULL,
+    PRIMARY KEY (cache_key, at)
+);
+
+-- Snapshots: corpus-level Merkle root pinning a forest state at a point in
+-- time. snapshot_root = MerkleTree.build([sorted document_roots]). Audit-
+-- chain-linked so peers can verify a claimed snapshot was actually witnessed
+-- by this instance. parent_snapshot lets snapshots chain (A -> B -> C) for
+-- diff/replay. doc_count is informational; the root is the canonical id.
+CREATE TABLE IF NOT EXISTS snapshots (
+    snapshot_root      TEXT PRIMARY KEY,
+    taken_at           INTEGER NOT NULL,
+    audit_event_hash   TEXT NOT NULL,
+    doc_count          INTEGER NOT NULL,
+    parent_snapshot    TEXT,
+    reason             TEXT
+);
+CREATE INDEX IF NOT EXISTS idx_snapshots_taken_at ON snapshots(taken_at);
+
+-- Mesh layer tables. Off by default — populated only when the user runs
+-- `aborist mesh init`. Never accessed by ingest / query / distill paths;
+-- mesh state is opt-in plumbing for federated peers (see aborist.mesh).
+CREATE TABLE IF NOT EXISTS mesh_identity (
+    id           INTEGER PRIMARY KEY CHECK (id = 1),  -- singleton
+    member_id    TEXT NOT NULL UNIQUE,
+    sign_priv    BLOB NOT NULL,                       -- ed25519 32B raw
+    sign_pub     BLOB NOT NULL,                       -- ed25519 32B raw
+    dh_priv      BLOB NOT NULL,                       -- x25519 32B raw
+    dh_pub       BLOB NOT NULL,                       -- x25519 32B raw
+    group_name   TEXT NOT NULL,
+    created_at   INTEGER NOT NULL
+);
+
+-- Per-epoch roster. epoch 0 = group genesis (founder only). Each membership
+-- mutation (join, kick, scheduled rotate) bumps the epoch_id by 1 and writes
+-- a fresh row-set capturing the new roster.
+CREATE TABLE IF NOT EXISTS mesh_roster (
+    epoch_id     INTEGER NOT NULL,
+    member_id    TEXT NOT NULL,
+    sign_pub     BLOB NOT NULL,
+    dh_pub       BLOB NOT NULL,
+    role         TEXT NOT NULL DEFAULT 'member'
+                    CHECK (role IN ('admin','member')),
+    PRIMARY KEY (epoch_id, member_id)
+);
+CREATE INDEX IF NOT EXISTS idx_mesh_roster_member ON mesh_roster(member_id);
+
+-- Epoch lifecycle log. secret_envelope is JSON of the form
+--    {"member_id": {"nonce_b64": "...", "ct_b64": "..."}, ...}
+-- where each entry is the symmetric epoch secret AEAD-wrapped to that
+-- member's X25519 pubkey via ECDH. Eviction happens by NOT including the
+-- evicted member's entry in the next epoch's envelope.
+CREATE TABLE IF NOT EXISTS mesh_epochs (
+    epoch_id           INTEGER PRIMARY KEY,
+    started_at         INTEGER NOT NULL,
+    started_event_hash TEXT NOT NULL,
+    secret_envelope    TEXT NOT NULL,
+    reason             TEXT
+);
+
+-- Per-peer audit-chain tracking. Each row records the most recent
+-- event_hash a given peer has broadcast to us; we enforce that every
+-- subsequent gossip envelope carries `prev_event_hash == last_event_hash`
+-- of that peer. A mismatch is a fork — the gossip is rejected (409).
+-- last_seq is the local count of accepted envelopes from that peer
+-- (informational; the canonical chain identity is last_event_hash).
+CREATE TABLE IF NOT EXISTS mesh_peer_chains (
+    peer_member_id    TEXT PRIMARY KEY,
+    last_event_hash   TEXT NOT NULL,
+    last_seq          INTEGER NOT NULL,
+    last_seen_at      INTEGER NOT NULL
+);
+
+-- FTS5 over chunk content for UNGROUNDED-mode keyword search.
+--
+-- Contentless mode (`content=''`): FTS5 stores ONLY the inverted index, no
+-- copy of the indexed text. This eliminates the ~28 MB / 1000 docs that the
+-- prior schema spent on chunks_fts_content (the stored copy was redundant
+-- with chunks.content). The trade: snippet() / highlight() return empty
+-- in contentless mode, so the FTS5 backend builds snippets in Python by
+-- joining `chunks_fts.rowid = chunks.chunk_id`, decompressing chunks.content,
+-- and locating query tokens.
+--
+-- Inserts use `INSERT INTO chunks_fts (rowid, content) VALUES (chunk_id, plain)`
+-- — the rowid must equal the chunks.chunk_id of the underlying row so the
+-- search-time join lines up.
+CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5(
+    content,
+    content='',
+    contentless_delete=1,
+    tokenize = 'porter unicode61'
+);
+
+-- Document-title FTS5 index. Replaces the un-indexable
+-- `LOWER(title) LIKE '%tok%'` title-LIKE search with O(K) hash
+-- lookup. Pre-2026-05-02 the title-LIKE backup was either skipped
+-- (>5 tokens) or paid ~10s/shard for short queries. The MATCH-based
+-- replacement runs in ~0.05s/shard regardless of token count, which
+-- means we can re-enable synonym-expanded title search for long
+-- queries without paying the corpus-scan cost.
+--
+-- Contentless mode: same trick as chunks_fts — store only the
+-- inverted index, not a copy of the title. The rowid joins back to
+-- documents.rowid (sqlite's hidden integer rowid is fine for a
+-- 1-1 mapping). On re-ingest, the FTS5 row gets replaced via the
+-- ingest path's INSERT OR REPLACE INTO documents flow.
+CREATE VIRTUAL TABLE IF NOT EXISTS documents_fts USING fts5(
+    title,
+    content='',
+    contentless_delete=1,
+    tokenize = 'porter unicode61'
+);
+
+-- Concept-relations layer. Append-only secondary index over the corpus.
+-- Each row is a (token, target) edge of a given relation_kind, derived
+-- from a specific source document by a specific extractor (evidence_kind).
+-- Re-derivation is idempotent at the (source_root, relation_kind, token,
+-- target, evidence_kind) level via UNIQUE.
+--
+-- This table is SEPARATE from the Merkle layer: writes here NEVER affect
+-- document_root / chunk_root / cache_key. So the corpus's whole Merkle
+-- tree stays valid across re-derivations; we can backfill or re-extract
+-- concept relations without invalidating any cached answers.
+--
+-- Cross-shard lookup. Concept relations live in the shard whose document
+-- they were derived from; the lookup helpers in aborist.concepts walk all
+-- shards (same pattern as cross-shard FTS5 search). Mesh sync moves shards
+-- between peers; concept relations come along for the ride automatically.
+--
+-- relation_kind:
+--   'synonym'  - token & target retrieve interchangeably (See-also
+--                bidirectional, redirect target, internal-link cluster)
+--   'antonym'  - token & target are explicit opposites (manual / hatnote
+--                "not to be confused with")
+--   'rivalry'  - token & target compete in a category (same-category
+--                membership without cross-link; manual rivalries)
+--   'category' - token belongs to category target (Wikipedia
+--                [[Category:X]] tail; HTML schema.org/<meta> classification)
+--
+-- evidence_kind: which extractor produced the row. Lets `aborist concepts
+-- purge --evidence-kind X` revoke a single extractor's output cleanly
+-- without touching manual or other-extractor rows. New extractors register
+-- a stable evidence_kind string; legacy seeds are 'manual_legacy'.
+CREATE TABLE IF NOT EXISTS concept_relations (
+    id              INTEGER PRIMARY KEY AUTOINCREMENT,
+    source_root     TEXT NOT NULL,
+    relation_kind   TEXT NOT NULL
+                        CHECK (relation_kind IN ('synonym','antonym','rivalry','category')),
+    token           TEXT NOT NULL,
+    target          TEXT NOT NULL,
+    evidence_kind   TEXT NOT NULL,
+    confidence      REAL NOT NULL DEFAULT 1.0,
+    derived_at      INTEGER NOT NULL,
+    derived_from    TEXT,                                  -- shard/uri/extractor identifier
+    UNIQUE (source_root, relation_kind, token, target, evidence_kind)
+);
+CREATE INDEX IF NOT EXISTS idx_concept_token  ON concept_relations(token);
+CREATE INDEX IF NOT EXISTS idx_concept_target ON concept_relations(target);
+CREATE INDEX IF NOT EXISTS idx_concept_kind   ON concept_relations(relation_kind);
+CREATE INDEX IF NOT EXISTS idx_concept_evid   ON concept_relations(evidence_kind);
+
+-- Per-token corpus document-frequency (for IDF ranking at synonym
+-- expansion cap-time). Computed once at backfill via fts5vocab over
+-- chunks_fts. Only tokens that appear in concept_relations get a row;
+-- the synonym layer is the consumer & it ranks expansion by 1/log(doc_freq)
+-- when the cap saturates so common-corpus words drop before rare topical
+-- ones.
+--
+-- doc_freq is FTS5-chunk-level (number of chunks containing the term);
+-- adequate proxy for true doc-level since chunks are sized 512 tokens
+-- and a doc rarely has the same term in only one chunk. Cross-shard
+-- ranking sums doc_freq across all shards' rows.
+CREATE TABLE IF NOT EXISTS concept_token_idf (
+    token       TEXT PRIMARY KEY,
+    doc_freq    INTEGER NOT NULL,
+    total_docs  INTEGER NOT NULL,
+    derived_at  INTEGER NOT NULL
+);
+CREATE INDEX IF NOT EXISTS idx_token_idf_freq ON concept_token_idf(doc_freq);
+"""
+
+
+
+[docs] +def connect(db_path: Path | str = DEFAULT_DB_PATH) -> sqlite3.Connection: + """Open a writable connection, creating the parent dir + schema if needed. + + Performance pragmas applied per-connection. Under WAL (set in the schema): + - synchronous=NORMAL skips the per-commit fsync; durable up to the last + checkpoint (SQLite auto-checkpoints at WAL ~1000 frames). + - cache_size=-65536 = 64 MB page cache (reduces re-reads). + - temp_store=MEMORY keeps temp tables in RAM (no /tmp churn). + - mmap_size=256 MB lets reads come from page-cache without read() syscalls. + """ + p = Path(db_path) + p.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(p, isolation_level=None) # autocommit; we'll BEGIN manually + conn.row_factory = sqlite3.Row + conn.executescript(SCHEMA_SQL) + _migrate_audit_mode(conn) + _migrate_mesh_peer_chains(conn) + _migrate_document_http_meta(conn) + conn.execute("PRAGMA synchronous = NORMAL") + conn.execute("PRAGMA cache_size = -65536") + conn.execute("PRAGMA temp_store = MEMORY") + conn.execute("PRAGMA mmap_size = 268435456") + return conn
+ + + +def _migrate_audit_mode(conn: sqlite3.Connection) -> None: + """Forward-migrate pre-v9.8-audit-mode providence_cache shards. + + Adds audit_mode + n_quotes + n_verified + unverified_quotes + verifier_method + columns to DBs that pre-date the faithfulness-classification rollout. SQLite + ALTER TABLE ADD COLUMN is O(1) (metadata-only) so this is cheap on every + open. Idempotent — checks PRAGMA before each ADD. + + Also handles the VISUAL → UNGROUNDED rename for the audit_mode value + space. SQLite cannot ALTER a column's CHECK in place, so legacy tables + with the old `CHECK (audit_mode IN ('STRICT','HYBRID','VISUAL'))` get + rebuilt via the standard temp-table dance, with values translated. + """ + cols = {row["name"] for row in conn.execute("PRAGMA table_info(providence_cache)")} + if "audit_mode" not in cols: + conn.execute( + "ALTER TABLE providence_cache ADD COLUMN audit_mode TEXT " + "NOT NULL DEFAULT 'UNGROUNDED' " + "CHECK (audit_mode IN ('STRICT','HYBRID','UNGROUNDED'))" + ) + if "n_quotes" not in cols: + conn.execute( + "ALTER TABLE providence_cache ADD COLUMN n_quotes INTEGER NOT NULL DEFAULT 0" + ) + if "n_verified" not in cols: + conn.execute( + "ALTER TABLE providence_cache ADD COLUMN n_verified INTEGER NOT NULL DEFAULT 0" + ) + if "unverified_quotes" not in cols: + conn.execute( + "ALTER TABLE providence_cache ADD COLUMN unverified_quotes TEXT" + ) + if "verifier_method" not in cols: + conn.execute( + "ALTER TABLE providence_cache ADD COLUMN verifier_method TEXT " + "NOT NULL DEFAULT 'none' " + "CHECK (verifier_method IN ('quote','span','entity','paraphrase','none'))" + ) + if "run_dag_root" not in cols: + conn.execute( + "ALTER TABLE providence_cache ADD COLUMN run_dag_root TEXT" + ) + if "run_dag_blob" not in cols: + conn.execute( + "ALTER TABLE providence_cache ADD COLUMN run_dag_blob TEXT" + ) + + # VISUAL → UNGROUNDED rename. Detect legacy CHECK by inspecting DDL. + ddl_row = conn.execute( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='providence_cache'" + ).fetchone() + if ddl_row and "'VISUAL'" in (ddl_row[0] or ""): + _rebuild_providence_cache_ungrounded(conn) + + # paraphrase verifier_method addition. Detect legacy CHECK by + # inspecting DDL — if the constraint doesn't already list + # 'paraphrase', rebuild the table. + ddl_row = conn.execute( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='providence_cache'" + ).fetchone() + if ddl_row and "'paraphrase'" not in (ddl_row[0] or ""): + _rebuild_providence_cache_paraphrase(conn) + + # claim_lattice verifier_method addition (G0 — quote-by-pointer mode). + ddl_row = conn.execute( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='providence_cache'" + ).fetchone() + if ddl_row and "'claim_lattice'" not in (ddl_row[0] or ""): + _rebuild_providence_cache_claim_lattice(conn) + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_providence_audit " + "ON providence_cache(audit_mode)" + ) + + +def _migrate_document_http_meta(conn: sqlite3.Connection) -> None: + """Forward-migrate pre-recrawl-check shards. + + Adds ``document_http_meta`` to DBs that pre-date the recrawl-check + feature. Mirrors ``_migrate_mesh_peer_chains``: idempotent + table-existence probe, then CREATE if missing. + """ + row = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='document_http_meta'" + ).fetchone() + if row is None: + conn.execute( + "CREATE TABLE document_http_meta (" + " document_root TEXT PRIMARY KEY," + " etag TEXT," + " last_modified TEXT," + " last_fetched_at INTEGER NOT NULL," + " last_status INTEGER," + " last_checked_at INTEGER," + " FOREIGN KEY (document_root) REFERENCES documents(document_root)" + " ON DELETE CASCADE" + ")" + ) + + +def _migrate_mesh_peer_chains(conn: sqlite3.Connection) -> None: + """Forward-migrate pre-mesh-fork-detection shards. + + Adds the `mesh_peer_chains` table to DBs that pre-date per-peer + audit-chain tracking on the mesh wire. CREATE TABLE IF NOT EXISTS + in SCHEMA_SQL covers brand-new shards; this migration is a belt- + and-suspenders idempotency check for callers that bypass the full + SCHEMA_SQL pass (cross-shard query views, etc.). Idempotent — + PRAGMA-checks before issuing CREATE. + """ + row = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='mesh_peer_chains'" + ).fetchone() + if row is None: + conn.execute( + "CREATE TABLE mesh_peer_chains (" + " peer_member_id TEXT PRIMARY KEY," + " last_event_hash TEXT NOT NULL," + " last_seq INTEGER NOT NULL," + " last_seen_at INTEGER NOT NULL" + ")" + ) + + +def _rebuild_providence_cache_paraphrase(conn: sqlite3.Connection) -> None: + """One-time table rebuild: expand verifier_method CHECK to include + 'paraphrase' (4th verifier strategy: token-coverage paraphrase + matching, alongside quote/span/entity). + + Same pattern as ``_rebuild_providence_cache_ungrounded``: SQLite + can't modify a CHECK constraint in place, so we create a new table + with the expanded CHECK, copy the data verbatim (no value + translation needed — paraphrase is additive), drop the old, rename + the new. Caller probes existing CHECK from sqlite_master and only + invokes this when 'paraphrase' is missing. + """ + new_create = """ + CREATE TABLE providence_cache_new ( + cache_key TEXT PRIMARY KEY, + source_root TEXT NOT NULL, + document_uri TEXT NOT NULL, + question_hash TEXT NOT NULL, + question_text TEXT NOT NULL, + answer_text TEXT NOT NULL, + merkle_proof TEXT NOT NULL, + model_profile_hash TEXT NOT NULL, + conversation_hash TEXT NOT NULL, + governance_policy_hash TEXT NOT NULL, + schema_version TEXT NOT NULL, + canonicalization_version TEXT NOT NULL, + chunking_version TEXT NOT NULL, + falsification_state TEXT NOT NULL DEFAULT 'live' + CHECK (falsification_state IN ('live','failed','stale','quarantined')), + chain TEXT NOT NULL DEFAULT 'private' + CHECK (chain IN ('private','public')), + audit_event_hash TEXT, + created_at INTEGER NOT NULL, + last_hit_at INTEGER, + hit_count INTEGER NOT NULL DEFAULT 0, + audit_mode TEXT NOT NULL DEFAULT 'UNGROUNDED' + CHECK (audit_mode IN ('STRICT','HYBRID','UNGROUNDED')), + n_quotes INTEGER NOT NULL DEFAULT 0, + n_verified INTEGER NOT NULL DEFAULT 0, + unverified_quotes TEXT, + verifier_method TEXT NOT NULL DEFAULT 'none' + CHECK (verifier_method IN ('quote','span','entity','paraphrase','none')), + run_dag_root TEXT, + run_dag_blob TEXT + ) + """ + conn.execute("BEGIN IMMEDIATE") + try: + conn.execute(new_create) + conn.execute( + "INSERT INTO providence_cache_new " + "(cache_key, source_root, document_uri, question_hash, question_text, " + " answer_text, merkle_proof, model_profile_hash, conversation_hash, " + " governance_policy_hash, schema_version, canonicalization_version, " + " chunking_version, falsification_state, chain, audit_event_hash, " + " created_at, last_hit_at, hit_count, audit_mode, n_quotes, " + " n_verified, unverified_quotes, verifier_method) " + "SELECT " + " cache_key, source_root, document_uri, question_hash, question_text, " + " answer_text, merkle_proof, model_profile_hash, conversation_hash, " + " governance_policy_hash, schema_version, canonicalization_version, " + " chunking_version, falsification_state, chain, audit_event_hash, " + " created_at, last_hit_at, hit_count, audit_mode, n_quotes, " + " n_verified, unverified_quotes, verifier_method " + "FROM providence_cache" + ) + conn.execute("DROP TABLE providence_cache") + conn.execute("ALTER TABLE providence_cache_new RENAME TO providence_cache") + conn.execute("COMMIT") + except Exception: + conn.execute("ROLLBACK") + raise + + +def _rebuild_providence_cache_claim_lattice(conn: sqlite3.Connection) -> None: + """One-time table rebuild: expand verifier_method CHECK to include + 'claim_lattice' (quote-by-pointer answer mode added in G0). + + Same pattern as the paraphrase / ungrounded rebuilds: SQLite cannot + modify a CHECK constraint in place, so we create a new table with + the expanded CHECK, copy the data verbatim (claim_lattice is + additive — no value translation needed), drop the old, rename the + new. Caller probes existing CHECK from sqlite_master and only + invokes this when 'claim_lattice' is missing. + + Unlike the older rebuilds this one preserves ``run_dag_root`` & + ``run_dag_blob`` columns in the copy. By the time a shard reaches + this migration those columns may already be populated; dropping + them would erase per-run computation provenance. + """ + new_create = """ + CREATE TABLE providence_cache_new ( + cache_key TEXT PRIMARY KEY, + source_root TEXT NOT NULL, + document_uri TEXT NOT NULL, + question_hash TEXT NOT NULL, + question_text TEXT NOT NULL, + answer_text TEXT NOT NULL, + merkle_proof TEXT NOT NULL, + model_profile_hash TEXT NOT NULL, + conversation_hash TEXT NOT NULL, + governance_policy_hash TEXT NOT NULL, + schema_version TEXT NOT NULL, + canonicalization_version TEXT NOT NULL, + chunking_version TEXT NOT NULL, + falsification_state TEXT NOT NULL DEFAULT 'live' + CHECK (falsification_state IN ('live','failed','stale','quarantined')), + chain TEXT NOT NULL DEFAULT 'private' + CHECK (chain IN ('private','public')), + audit_event_hash TEXT, + created_at INTEGER NOT NULL, + last_hit_at INTEGER, + hit_count INTEGER NOT NULL DEFAULT 0, + audit_mode TEXT NOT NULL DEFAULT 'UNGROUNDED' + CHECK (audit_mode IN ('STRICT','HYBRID','UNGROUNDED')), + n_quotes INTEGER NOT NULL DEFAULT 0, + n_verified INTEGER NOT NULL DEFAULT 0, + unverified_quotes TEXT, + verifier_method TEXT NOT NULL DEFAULT 'none' + CHECK (verifier_method IN ('quote','span','entity','paraphrase','claim_lattice','none')), + run_dag_root TEXT, + run_dag_blob TEXT + ) + """ + conn.execute("BEGIN IMMEDIATE") + try: + conn.execute(new_create) + conn.execute( + "INSERT INTO providence_cache_new " + "(cache_key, source_root, document_uri, question_hash, question_text, " + " answer_text, merkle_proof, model_profile_hash, conversation_hash, " + " governance_policy_hash, schema_version, canonicalization_version, " + " chunking_version, falsification_state, chain, audit_event_hash, " + " created_at, last_hit_at, hit_count, audit_mode, n_quotes, " + " n_verified, unverified_quotes, verifier_method, " + " run_dag_root, run_dag_blob) " + "SELECT " + " cache_key, source_root, document_uri, question_hash, question_text, " + " answer_text, merkle_proof, model_profile_hash, conversation_hash, " + " governance_policy_hash, schema_version, canonicalization_version, " + " chunking_version, falsification_state, chain, audit_event_hash, " + " created_at, last_hit_at, hit_count, audit_mode, n_quotes, " + " n_verified, unverified_quotes, verifier_method, " + " run_dag_root, run_dag_blob " + "FROM providence_cache" + ) + conn.execute("DROP TABLE providence_cache") + conn.execute("ALTER TABLE providence_cache_new RENAME TO providence_cache") + conn.execute("COMMIT") + except Exception: + conn.execute("ROLLBACK") + raise + + +def _rebuild_providence_cache_ungrounded(conn: sqlite3.Connection) -> None: + """One-time table rebuild: rename audit_mode value VISUAL → UNGROUNDED. + + SQLite cannot modify a column's CHECK constraint in place. Standard + pattern: create new table with new CHECK, copy data while translating + values, drop old, rename new. Wrapped in IMMEDIATE transaction so a + failure rolls back cleanly without leaving the DB half-migrated. + """ + new_create = """ + CREATE TABLE providence_cache_new ( + cache_key TEXT PRIMARY KEY, + source_root TEXT NOT NULL, + document_uri TEXT NOT NULL, + question_hash TEXT NOT NULL, + question_text TEXT NOT NULL, + answer_text TEXT NOT NULL, + merkle_proof TEXT NOT NULL, + model_profile_hash TEXT NOT NULL, + conversation_hash TEXT NOT NULL, + governance_policy_hash TEXT NOT NULL, + schema_version TEXT NOT NULL, + canonicalization_version TEXT NOT NULL, + chunking_version TEXT NOT NULL, + falsification_state TEXT NOT NULL DEFAULT 'live' + CHECK (falsification_state IN ('live','failed','stale','quarantined')), + chain TEXT NOT NULL DEFAULT 'private' + CHECK (chain IN ('private','public')), + audit_event_hash TEXT, + created_at INTEGER NOT NULL, + last_hit_at INTEGER, + hit_count INTEGER NOT NULL DEFAULT 0, + audit_mode TEXT NOT NULL DEFAULT 'UNGROUNDED' + CHECK (audit_mode IN ('STRICT','HYBRID','UNGROUNDED')), + n_quotes INTEGER NOT NULL DEFAULT 0, + n_verified INTEGER NOT NULL DEFAULT 0, + unverified_quotes TEXT, + verifier_method TEXT NOT NULL DEFAULT 'none' + CHECK (verifier_method IN ('quote','span','entity','paraphrase','none')), + run_dag_root TEXT, + run_dag_blob TEXT + ) + """ + conn.execute("BEGIN IMMEDIATE") + try: + conn.execute(new_create) + conn.execute( + "INSERT INTO providence_cache_new " + "(cache_key, source_root, document_uri, question_hash, question_text, " + " answer_text, merkle_proof, model_profile_hash, conversation_hash, " + " governance_policy_hash, schema_version, canonicalization_version, " + " chunking_version, falsification_state, chain, audit_event_hash, " + " created_at, last_hit_at, hit_count, audit_mode, n_quotes, " + " n_verified, unverified_quotes, verifier_method) " + "SELECT " + " cache_key, source_root, document_uri, question_hash, question_text, " + " answer_text, merkle_proof, model_profile_hash, conversation_hash, " + " governance_policy_hash, schema_version, canonicalization_version, " + " chunking_version, falsification_state, chain, audit_event_hash, " + " created_at, last_hit_at, hit_count, " + " CASE WHEN audit_mode = 'VISUAL' THEN 'UNGROUNDED' ELSE audit_mode END, " + " n_quotes, n_verified, unverified_quotes, verifier_method " + "FROM providence_cache" + ) + conn.execute("DROP TABLE providence_cache") + conn.execute("ALTER TABLE providence_cache_new RENAME TO providence_cache") + conn.execute("COMMIT") + except Exception: + conn.execute("ROLLBACK") + raise + + +# Tables that exist in every shard with the same schema. Used to build +# cross-shard UNION views in connect_query(). +_SHARDABLE_TABLES = ( + "documents", + "chunks", + "merkle_nodes", + "edges", + "derivations", + "providence_cache", + "audit_events", + "falsifications", + "concept_relations", + "concept_token_idf", +) + +# Per-table column lists for cross-shard UNION views. The `chunks` table +# is pinned explicitly because the column order matters for cross-shard +# search: chunks_fts is contentless and joins back to `chunks.chunk_id`. +# Mixing legacy (composite-PK, no chunk_id column) shards with current +# (chunk_id-keyed) shards in the same --shards-dir is unsupported — run +# the migration on legacy shards first or keep them in a separate dir. +_SHARED_COLUMNS = { + "chunks": "chunk_id, document_root, idx, leaf_hash, content, tier", +} + + +
+[docs] +def discover_shards(shards_dir: Path | str) -> list[Path]: + """Enumerate shard DB files in `shards_dir`. Returns sorted list of paths.""" + p = Path(shards_dir) + if not p.is_dir(): + return [] + return sorted(p.glob("*.db"))
+ + + +
+[docs] +def connect_query( + db_path: Path | str | None = None, + shards_dir: Path | str | None = None, +) -> sqlite3.Connection: + """Open a read-only-style connection that surfaces ALL shards as one DB. + + If `shards_dir` is set, every `*.db` in it is ATTACHed and UNION ALL views + are created over the standard tables so existing queries (`SELECT * FROM + documents`) work unchanged across shards. Reads only — writes still go + through `connect()` against a specific shard. + + If `shards_dir` is None, returns a normal `connect(db_path)` for back-compat. + """ + if shards_dir is None: + return connect(db_path or DEFAULT_DB_PATH) + + shard_paths = discover_shards(shards_dir) + conn = sqlite3.connect(":memory:", isolation_level=None) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA temp_store = MEMORY") + + if not shard_paths: + # Nothing attached; create empty placeholder tables so callers don't crash. + conn.executescript(SCHEMA_SQL) + return conn + + aliases: list[str] = [] + for i, sp in enumerate(shard_paths): + alias = f"sh{i:03d}" + conn.execute(f"ATTACH DATABASE ? AS {alias}", (str(sp.resolve()),)) + aliases.append(alias) + + # UNION ALL views over the shardable tables. Columns are listed + # explicitly (not `SELECT *`) so a shard cluster that mixes the prior + # composite-PK chunks layout with the newer chunk_id-keyed layout still + # unions cleanly — the explicit list is the intersection of columns + # present in both schema generations. + for table in _SHARDABLE_TABLES: + cols = _SHARED_COLUMNS.get(table, "*") + unions = " UNION ALL ".join( + f"SELECT {cols} FROM {a}.{table}" for a in aliases + ) + conn.execute(f"CREATE TEMP VIEW {table} AS {unions}") + + # Stash the shard list for tools that want it. + conn.execute( + "CREATE TEMP TABLE _shards (shard_id TEXT, path TEXT, alias TEXT)" + ) + conn.executemany( + "INSERT INTO _shards (shard_id, path, alias) VALUES (?, ?, ?)", + [(p.stem, str(p.resolve()), a) for p, a in zip(shard_paths, aliases)], + ) + return conn
+ + + +
+[docs] +@contextmanager +def transaction(conn: sqlite3.Connection) -> Iterator[sqlite3.Connection]: + """BEGIN IMMEDIATE / COMMIT / ROLLBACK around a block.""" + conn.execute("BEGIN IMMEDIATE") + try: + yield conn + except Exception: + conn.execute("ROLLBACK") + raise + else: + conn.execute("COMMIT")
+ + + +def _canonical_json(obj) -> str: + """Stable JSON for audit hashing: sorted keys, no whitespace.""" + return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + + +
+[docs] +def get_meta(conn: sqlite3.Connection, key: str) -> str | None: + """Read a value from the per-DB meta table; None if missing.""" + row = conn.execute("SELECT value FROM meta WHERE key = ?", (key,)).fetchone() + return row["value"] if row else None
+ + + +
+[docs] +def set_meta(conn: sqlite3.Connection, key: str, value: str) -> None: + """Upsert a (key, value) into meta. Caller wraps in a transaction.""" + conn.execute( + "INSERT INTO meta (key, value, updated_at) VALUES (?, ?, ?) " + "ON CONFLICT(key) DO UPDATE SET value = excluded.value, " + "updated_at = excluded.updated_at", + (key, value, int(time.time())), + )
+ + + +
+[docs] +def latest_event_hash(conn: sqlite3.Connection) -> str | None: + """Return the last event_hash in the audit chain, or None for genesis.""" + row = conn.execute( + "SELECT event_hash FROM audit_events ORDER BY seq DESC LIMIT 1" + ).fetchone() + return row["event_hash"] if row else None
+ + + +
+[docs] +def chain_audit_events( + prev_event_hash: str | None, + events: list[dict], +) -> tuple[list[tuple], str | None]: + """Compute the event_hash chain for a batch in pure Python. + + Each event dict needs: `event_type`, `body` (dict), `subject_root` (str|None), `ts` (int). + Returns (rows_for_executemany, last_event_hash). Insert with: + + executemany("INSERT INTO audit_events + (event_hash, prev_event_hash, event_type, subject_root, + body, ts) VALUES (?, ?, ?, ?, ?, ?)", rows) + + All chain SHA-256s are computed locally — zero DB round-trips per event. + """ + import hashlib + + rows: list[tuple] = [] + prev = prev_event_hash + for ev in events: + body_json = _canonical_json(ev["body"]) + h = hashlib.sha256() + if prev is not None: + h.update(bytes.fromhex(prev)) + h.update(body_json.encode("utf-8", errors="surrogatepass")) + event_hash = h.hexdigest() + rows.append( + ( + event_hash, + prev, + ev["event_type"], + ev.get("subject_root"), + body_json, + ev["ts"], + ) + ) + prev = event_hash + return rows, prev
+ + + +
+[docs] +def append_audit( + conn: sqlite3.Connection, + event_type: str, + body: dict, + subject_root: str | None = None, + ts: int | None = None, +) -> str: + """Append one event to the audit chain. Returns the new event_hash (hex). + + Convenience wrapper for one-off events. Bulk inserts should use + chain_audit_events() + executemany() for ~10x throughput on large batches. + """ + import hashlib + + if ts is None: + ts = int(time.time()) + prev = latest_event_hash(conn) + body_json = _canonical_json(body) + h = hashlib.sha256() + if prev is not None: + h.update(bytes.fromhex(prev)) + h.update(body_json.encode("utf-8", errors="surrogatepass")) + event_hash = h.hexdigest() + conn.execute( + "INSERT INTO audit_events (event_hash, prev_event_hash, event_type, subject_root, body, ts) " + "VALUES (?, ?, ?, ?, ?, ?)", + (event_hash, prev, event_type, subject_root, body_json, ts), + ) + return event_hash
+ + + +
+[docs] +def stats(conn: sqlite3.Connection) -> dict: + """Quick landscape report.""" + def one(sql: str, *args) -> int: + return conn.execute(sql, args).fetchone()[0] + + return { + "documents_total": one("SELECT COUNT(*) FROM documents"), + "documents_surface": one("SELECT COUNT(*) FROM documents WHERE kind='surface'"), + "documents_core": one("SELECT COUNT(*) FROM documents WHERE kind='core'"), + "chunks_total": one("SELECT COUNT(*) FROM chunks"), + "chunks_hot": one("SELECT COUNT(*) FROM chunks WHERE tier='hot'"), + "chunks_warm": one("SELECT COUNT(*) FROM chunks WHERE tier='warm'"), + "chunks_cold": one("SELECT COUNT(*) FROM chunks WHERE tier='cold'"), + "edges_total": one("SELECT COUNT(*) FROM edges"), + "providence_total": one("SELECT COUNT(*) FROM providence_cache"), + "audit_events_total": one("SELECT COUNT(*) FROM audit_events"), + }
+ +
+
+
+
+ + +
+
+ + Made with Sphinx and @pradyunsg's + + Furo + +
+
+ +
+
+ +
+
+ +
+
+ + + + + \ No newline at end of file diff --git a/docs/_source/_build/html/_modules/aborist/wikitext.html b/docs/_source/_build/html/_modules/aborist/wikitext.html new file mode 100644 index 0000000..021a167 --- /dev/null +++ b/docs/_source/_build/html/_modules/aborist/wikitext.html @@ -0,0 +1,406 @@ + + + + + + + + aborist.wikitext - Aborist API Reference + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + + + +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for aborist.wikitext

+"""Wikitext → base prose conversion.
+
+Aborist stores raw MediaWiki wikitext in ``chunks.content`` so the link
+graph and original markup are recoverable from any page on demand. For
+LLM context and post-LLM faithfulness verification we need *prose* — a
+deterministic plain-text projection of the same chunk.
+
+This module provides that projection. ``to_base(raw)`` is a pure function
+of its input plus ``BASE_VERSION``: same wikitext → same prose, forever,
+as long as ``BASE_VERSION`` is unchanged.
+
+Versioning protocol
+-------------------
+Bump ``BASE_VERSION`` whenever the algorithm changes. Callers fold
+``BASE_VERSION`` into ``governance_policy_hash`` (via ``policy["base_version"]``
+in ``aborist.qa.runner`` / ``aborist.qa.query``) so a bump invalidates every
+prior providence-cache record's 8-dim cache_key on the next lookup. No
+schema migration; the next ``ask`` re-derives against fresh prose.
+
+Algorithm (wikitext-base-v1)
+----------------------------
+1. Parse with ``mwparserfromhell`` (handles nested templates, complex
+   tables, and edge cases that pure regex mangles).
+2. Drop ``<ref>...</ref>`` and self-closing ``<ref ... />`` tags. Citations
+   are not quotable claims about the topic.
+3. Drop namespace-prefixed wikilinks: ``[[File:...]]``, ``[[Image:...]]``,
+   ``[[Category:...]]``. Image params (``thumb|250px|...``) and category
+   tags are not prose; they're metadata.
+4. ``strip_code(normalize=True, collapse=True)`` — converts surviving
+   templates to empty, wikilinks to their display text, headers to bare
+   text, bold/italic markers to plain text, HTML tags to inner text,
+   HTML entities to characters, external links to anchor text.
+5. Whitespace pass: collapse runs of spaces/tabs, drop trailing space on
+   lines, collapse 3+ newlines to 2.
+
+Optional dependency. Install with ``pip install aborist[wikitext]``.
+"""
+
+from __future__ import annotations
+
+import re
+
+try:
+    import mwparserfromhell as _mw
+except ImportError as e:  # pragma: no cover
+    raise ImportError(
+        "wikitext base conversion requires extras: "
+        "pip install 'aborist[wikitext]'"
+    ) from e
+
+
+BASE_VERSION = "wikitext-base-v1"
+
+# Namespaces whose links carry no prose. ``File`` and ``Image`` are the
+# same target type (image inclusion); MediaWiki accepts both prefixes.
+# ``Category`` tags categorize a page but don't render as readable prose
+# in the article body.
+_DROP_NAMESPACES = frozenset({"file", "image", "category"})
+
+_WS_RUN = re.compile(r"[ \t]+")
+_TRAILING_WS = re.compile(r" +\n")
+_BLANK_LINES = re.compile(r"\n{3,}")
+
+
+
+[docs] +def to_base(raw: str) -> str: + """Convert raw wikitext to base prose. Deterministic. Idempotent. + + Empty / whitespace-only input returns ``""``. Non-wikitext input + (already-clean prose) round-trips unchanged modulo whitespace + collapsing. + """ + if not raw or not raw.strip(): + return "" + + code = _mw.parse(raw) + + # Drop <ref>...</ref> and self-closing <ref ... /> tags. We match on + # the tag name (case-insensitive) so that <REF>, <Ref>, etc. all go. + for tag in list(code.filter_tags()): + if str(tag.tag).strip().lower() == "ref": + try: + code.remove(tag) + except ValueError: + # Tag was already removed via a parent node. mwparserfromhell + # raises rather than no-op'ing; we swallow it. + pass + + # Drop File: / Image: / Category: wikilinks. Image captions sometimes + # contain useful prose ("thumb|250px|<caption>") but the technical + # parameters dominate and corrupt the prose stream; cleaner to drop. + for link in list(code.filter_wikilinks()): + title = str(link.title).strip() + if ":" in title: + ns = title.split(":", 1)[0].strip().lower() + if ns in _DROP_NAMESPACES: + try: + code.remove(link) + except ValueError: + pass + + base = code.strip_code(normalize=True, collapse=True) + + # Whitespace normalization — keeps paragraph breaks, drops runs. + base = _WS_RUN.sub(" ", base) + base = _TRAILING_WS.sub("\n", base) + base = _BLANK_LINES.sub("\n\n", base) + return base.strip()
+ + + + + +
+
+
+
+ + +
+
+ + Made with Sphinx and @pradyunsg's + + Furo + +
+
+ +
+
+ +
+
+ +
+
+ + + + + \ No newline at end of file diff --git a/docs/_source/_build/html/_modules/index.html b/docs/_source/_build/html/_modules/index.html new file mode 100644 index 0000000..b625381 --- /dev/null +++ b/docs/_source/_build/html/_modules/index.html @@ -0,0 +1,289 @@ + + + + + + + + Overview: module code - Aborist API Reference + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + + + +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+ + +
+
+ + + + + \ No newline at end of file diff --git a/docs/_source/_build/html/_sources/api/cli.rst.txt b/docs/_source/_build/html/_sources/api/cli.rst.txt new file mode 100644 index 0000000..b1a17cb --- /dev/null +++ b/docs/_source/_build/html/_sources/api/cli.rst.txt @@ -0,0 +1,8 @@ +CLI: Command-line interface +============================ + +Entry point for all aborist operations. + +.. automodule:: aborist.cli + :members: + :undoc-members: diff --git a/docs/_source/_build/html/_sources/api/distill.rst.txt b/docs/_source/_build/html/_sources/api/distill.rst.txt new file mode 100644 index 0000000..e8597b6 --- /dev/null +++ b/docs/_source/_build/html/_sources/api/distill.rst.txt @@ -0,0 +1,9 @@ +Distillation: surface → core compression +========================================== + +Surface-to-core extraction and recursive distillation. + +.. automodule:: aborist.distill + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/_source/_build/html/_sources/api/mesh.rst.txt b/docs/_source/_build/html/_sources/api/mesh.rst.txt new file mode 100644 index 0000000..d190ec8 --- /dev/null +++ b/docs/_source/_build/html/_sources/api/mesh.rst.txt @@ -0,0 +1,9 @@ +Federation: multiplayer aborist +================================ + +Gossip-based mesh for cross-peer data sharing. + +.. automodule:: aborist.mesh + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/_source/_build/html/_sources/api/qa.rst.txt b/docs/_source/_build/html/_sources/api/qa.rst.txt new file mode 100644 index 0000000..536a794 --- /dev/null +++ b/docs/_source/_build/html/_sources/api/qa.rst.txt @@ -0,0 +1,67 @@ +Q&A Pipeline: question → answer → verify → cache +================================================== + +Question answering, caching, verification, evidence mapping. + +keys +---- + +.. automodule:: aborist.qa.keys + :members: + :undoc-members: + +runner +------ + +.. automodule:: aborist.qa.runner + :members: + :undoc-members: + +query +----- + +.. automodule:: aborist.qa.query + :members: + :undoc-members: + +verify +------ + +.. automodule:: aborist.qa.verify + :members: + :undoc-members: + +evidence +-------- + +.. automodule:: aborist.qa.evidence + :members: + :undoc-members: + +quantifier +---------- + +.. automodule:: aborist.qa.quantifier + :members: + :undoc-members: + +metacognition +------------- + +.. automodule:: aborist.qa.metacognition + :members: + :undoc-members: + +dag +--- + +.. automodule:: aborist.qa.dag + :members: + :undoc-members: + +client +------ + +.. automodule:: aborist.qa.client + :members: + :undoc-members: diff --git a/docs/_source/_build/html/_sources/api/retrieval.rst.txt b/docs/_source/_build/html/_sources/api/retrieval.rst.txt new file mode 100644 index 0000000..2ca3cfa --- /dev/null +++ b/docs/_source/_build/html/_sources/api/retrieval.rst.txt @@ -0,0 +1,35 @@ +Retrieval: FTS5 search and concepts +=================================== + +Full-text search, concept relations (synonym/rivalry overlay). + +search +------ + +.. automodule:: aborist.search + :members: + :undoc-members: + :show-inheritance: + +sources +------- + +.. automodule:: aborist.source + :members: + :undoc-members: + +.. automodule:: aborist.sources.wikipedia + :members: + :undoc-members: + +.. automodule:: aborist.sources.html_page + :members: + :undoc-members: + +concepts +-------- + +.. automodule:: aborist.concepts + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/_source/_build/html/_sources/api/storage.rst.txt b/docs/_source/_build/html/_sources/api/storage.rst.txt new file mode 100644 index 0000000..17a90b5 --- /dev/null +++ b/docs/_source/_build/html/_sources/api/storage.rst.txt @@ -0,0 +1,27 @@ +Storage: SQLite schema and audit +================================ + +v9.8 SQLite schema, audit chain, and cross-shard views. + +store +----- + +.. automodule:: aborist.store + :members: + :undoc-members: + :show-inheritance: + +ingest +------ + +.. automodule:: aborist.ingest + :members: + :undoc-members: + :show-inheritance: + +evict +----- + +.. automodule:: aborist.evict + :members: + :undoc-members: diff --git a/docs/_source/_build/html/_sources/api/substrate.rst.txt b/docs/_source/_build/html/_sources/api/substrate.rst.txt new file mode 100644 index 0000000..7007f6f --- /dev/null +++ b/docs/_source/_build/html/_sources/api/substrate.rst.txt @@ -0,0 +1,27 @@ +Substrate: Core data structures +=============================== + +Pure Merkle tree and document primitives. + +merkle +------ + +.. automodule:: aborist.merkle + :members: + :undoc-members: + :show-inheritance: + +document +-------- + +.. automodule:: aborist.document + :members: + :undoc-members: + :show-inheritance: + +wikitext +-------- + +.. automodule:: aborist.wikitext + :members: + :undoc-members: diff --git a/docs/_source/_build/html/_sources/index.rst.txt b/docs/_source/_build/html/_sources/index.rst.txt new file mode 100644 index 0000000..1d1d5b9 --- /dev/null +++ b/docs/_source/_build/html/_sources/index.rst.txt @@ -0,0 +1,26 @@ +Aborist API Reference +===================== + +Generated from docstrings. Replaces the static modules.md. + +Contents: + +.. toctree:: + :maxdepth: 3 + :caption: API Modules + + api/substrate + api/storage + api/retrieval + api/qa + api/distill + api/mesh + api/cli + + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/docs/_source/_build/html/_static/base-stemmer.js b/docs/_source/_build/html/_static/base-stemmer.js new file mode 100644 index 0000000..e6fa0c4 --- /dev/null +++ b/docs/_source/_build/html/_static/base-stemmer.js @@ -0,0 +1,476 @@ +// @ts-check + +/**@constructor*/ +BaseStemmer = function() { + /** @protected */ + this.current = ''; + this.cursor = 0; + this.limit = 0; + this.limit_backward = 0; + this.bra = 0; + this.ket = 0; + + /** + * @param {string} value + */ + this.setCurrent = function(value) { + this.current = value; + this.cursor = 0; + this.limit = this.current.length; + this.limit_backward = 0; + this.bra = this.cursor; + this.ket = this.limit; + }; + + /** + * @return {string} + */ + this.getCurrent = function() { + return this.current; + }; + + /** + * @param {BaseStemmer} other + */ + this.copy_from = function(other) { + /** @protected */ + this.current = other.current; + this.cursor = other.cursor; + this.limit = other.limit; + this.limit_backward = other.limit_backward; + this.bra = other.bra; + this.ket = other.ket; + }; + + /** + * @param {number[]} s + * @param {number} min + * @param {number} max + * @return {boolean} + */ + this.in_grouping = function(s, min, max) { + /** @protected */ + if (this.cursor >= this.limit) return false; + var ch = this.current.charCodeAt(this.cursor); + if (ch > max || ch < min) return false; + ch -= min; + if ((s[ch >>> 3] & (0x1 << (ch & 0x7))) == 0) return false; + this.cursor++; + return true; + }; + + /** + * @param {number[]} s + * @param {number} min + * @param {number} max + * @return {boolean} + */ + this.go_in_grouping = function(s, min, max) { + /** @protected */ + while (this.cursor < this.limit) { + var ch = this.current.charCodeAt(this.cursor); + if (ch > max || ch < min) + return true; + ch -= min; + if ((s[ch >>> 3] & (0x1 << (ch & 0x7))) == 0) + return true; + this.cursor++; + } + return false; + }; + + /** + * @param {number[]} s + * @param {number} min + * @param {number} max + * @return {boolean} + */ + this.in_grouping_b = function(s, min, max) { + /** @protected */ + if (this.cursor <= this.limit_backward) return false; + var ch = this.current.charCodeAt(this.cursor - 1); + if (ch > max || ch < min) return false; + ch -= min; + if ((s[ch >>> 3] & (0x1 << (ch & 0x7))) == 0) return false; + this.cursor--; + return true; + }; + + /** + * @param {number[]} s + * @param {number} min + * @param {number} max + * @return {boolean} + */ + this.go_in_grouping_b = function(s, min, max) { + /** @protected */ + while (this.cursor > this.limit_backward) { + var ch = this.current.charCodeAt(this.cursor - 1); + if (ch > max || ch < min) return true; + ch -= min; + if ((s[ch >>> 3] & (0x1 << (ch & 0x7))) == 0) return true; + this.cursor--; + } + return false; + }; + + /** + * @param {number[]} s + * @param {number} min + * @param {number} max + * @return {boolean} + */ + this.out_grouping = function(s, min, max) { + /** @protected */ + if (this.cursor >= this.limit) return false; + var ch = this.current.charCodeAt(this.cursor); + if (ch > max || ch < min) { + this.cursor++; + return true; + } + ch -= min; + if ((s[ch >>> 3] & (0X1 << (ch & 0x7))) == 0) { + this.cursor++; + return true; + } + return false; + }; + + /** + * @param {number[]} s + * @param {number} min + * @param {number} max + * @return {boolean} + */ + this.go_out_grouping = function(s, min, max) { + /** @protected */ + while (this.cursor < this.limit) { + var ch = this.current.charCodeAt(this.cursor); + if (ch <= max && ch >= min) { + ch -= min; + if ((s[ch >>> 3] & (0X1 << (ch & 0x7))) != 0) { + return true; + } + } + this.cursor++; + } + return false; + }; + + /** + * @param {number[]} s + * @param {number} min + * @param {number} max + * @return {boolean} + */ + this.out_grouping_b = function(s, min, max) { + /** @protected */ + if (this.cursor <= this.limit_backward) return false; + var ch = this.current.charCodeAt(this.cursor - 1); + if (ch > max || ch < min) { + this.cursor--; + return true; + } + ch -= min; + if ((s[ch >>> 3] & (0x1 << (ch & 0x7))) == 0) { + this.cursor--; + return true; + } + return false; + }; + + /** + * @param {number[]} s + * @param {number} min + * @param {number} max + * @return {boolean} + */ + this.go_out_grouping_b = function(s, min, max) { + /** @protected */ + while (this.cursor > this.limit_backward) { + var ch = this.current.charCodeAt(this.cursor - 1); + if (ch <= max && ch >= min) { + ch -= min; + if ((s[ch >>> 3] & (0x1 << (ch & 0x7))) != 0) { + return true; + } + } + this.cursor--; + } + return false; + }; + + /** + * @param {string} s + * @return {boolean} + */ + this.eq_s = function(s) + { + /** @protected */ + if (this.limit - this.cursor < s.length) return false; + if (this.current.slice(this.cursor, this.cursor + s.length) != s) + { + return false; + } + this.cursor += s.length; + return true; + }; + + /** + * @param {string} s + * @return {boolean} + */ + this.eq_s_b = function(s) + { + /** @protected */ + if (this.cursor - this.limit_backward < s.length) return false; + if (this.current.slice(this.cursor - s.length, this.cursor) != s) + { + return false; + } + this.cursor -= s.length; + return true; + }; + + /** + * @param {Among[]} v + * @return {number} + */ + this.find_among = function(v) + { + /** @protected */ + var i = 0; + var j = v.length; + + var c = this.cursor; + var l = this.limit; + + var common_i = 0; + var common_j = 0; + + var first_key_inspected = false; + + while (true) + { + var k = i + ((j - i) >>> 1); + var diff = 0; + var common = common_i < common_j ? common_i : common_j; // smaller + // w[0]: string, w[1]: substring_i, w[2]: result, w[3]: function (optional) + var w = v[k]; + var i2; + for (i2 = common; i2 < w[0].length; i2++) + { + if (c + common == l) + { + diff = -1; + break; + } + diff = this.current.charCodeAt(c + common) - w[0].charCodeAt(i2); + if (diff != 0) break; + common++; + } + if (diff < 0) + { + j = k; + common_j = common; + } + else + { + i = k; + common_i = common; + } + if (j - i <= 1) + { + if (i > 0) break; // v->s has been inspected + if (j == i) break; // only one item in v + + // - but now we need to go round once more to get + // v->s inspected. This looks messy, but is actually + // the optimal approach. + + if (first_key_inspected) break; + first_key_inspected = true; + } + } + do { + var w = v[i]; + if (common_i >= w[0].length) + { + this.cursor = c + w[0].length; + if (w.length < 4) return w[2]; + var res = w[3](this); + this.cursor = c + w[0].length; + if (res) return w[2]; + } + i = w[1]; + } while (i >= 0); + return 0; + }; + + // find_among_b is for backwards processing. Same comments apply + /** + * @param {Among[]} v + * @return {number} + */ + this.find_among_b = function(v) + { + /** @protected */ + var i = 0; + var j = v.length + + var c = this.cursor; + var lb = this.limit_backward; + + var common_i = 0; + var common_j = 0; + + var first_key_inspected = false; + + while (true) + { + var k = i + ((j - i) >> 1); + var diff = 0; + var common = common_i < common_j ? common_i : common_j; + var w = v[k]; + var i2; + for (i2 = w[0].length - 1 - common; i2 >= 0; i2--) + { + if (c - common == lb) + { + diff = -1; + break; + } + diff = this.current.charCodeAt(c - 1 - common) - w[0].charCodeAt(i2); + if (diff != 0) break; + common++; + } + if (diff < 0) + { + j = k; + common_j = common; + } + else + { + i = k; + common_i = common; + } + if (j - i <= 1) + { + if (i > 0) break; + if (j == i) break; + if (first_key_inspected) break; + first_key_inspected = true; + } + } + do { + var w = v[i]; + if (common_i >= w[0].length) + { + this.cursor = c - w[0].length; + if (w.length < 4) return w[2]; + var res = w[3](this); + this.cursor = c - w[0].length; + if (res) return w[2]; + } + i = w[1]; + } while (i >= 0); + return 0; + }; + + /* to replace chars between c_bra and c_ket in this.current by the + * chars in s. + */ + /** + * @param {number} c_bra + * @param {number} c_ket + * @param {string} s + * @return {number} + */ + this.replace_s = function(c_bra, c_ket, s) + { + /** @protected */ + var adjustment = s.length - (c_ket - c_bra); + this.current = this.current.slice(0, c_bra) + s + this.current.slice(c_ket); + this.limit += adjustment; + if (this.cursor >= c_ket) this.cursor += adjustment; + else if (this.cursor > c_bra) this.cursor = c_bra; + return adjustment; + }; + + /** + * @return {boolean} + */ + this.slice_check = function() + { + /** @protected */ + if (this.bra < 0 || + this.bra > this.ket || + this.ket > this.limit || + this.limit > this.current.length) + { + return false; + } + return true; + }; + + /** + * @param {number} c_bra + * @return {boolean} + */ + this.slice_from = function(s) + { + /** @protected */ + var result = false; + if (this.slice_check()) + { + this.replace_s(this.bra, this.ket, s); + result = true; + } + return result; + }; + + /** + * @return {boolean} + */ + this.slice_del = function() + { + /** @protected */ + return this.slice_from(""); + }; + + /** + * @param {number} c_bra + * @param {number} c_ket + * @param {string} s + */ + this.insert = function(c_bra, c_ket, s) + { + /** @protected */ + var adjustment = this.replace_s(c_bra, c_ket, s); + if (c_bra <= this.bra) this.bra += adjustment; + if (c_bra <= this.ket) this.ket += adjustment; + }; + + /** + * @return {string} + */ + this.slice_to = function() + { + /** @protected */ + var result = ''; + if (this.slice_check()) + { + result = this.current.slice(this.bra, this.ket); + } + return result; + }; + + /** + * @return {string} + */ + this.assign_to = function() + { + /** @protected */ + return this.current.slice(0, this.limit); + }; +}; diff --git a/docs/_source/_build/html/_static/basic.css b/docs/_source/_build/html/_static/basic.css new file mode 100644 index 0000000..4738b2e --- /dev/null +++ b/docs/_source/_build/html/_static/basic.css @@ -0,0 +1,906 @@ +/* + * Sphinx stylesheet -- basic theme. + */ + +/* -- main layout ----------------------------------------------------------- */ + +div.clearer { + clear: both; +} + +div.section::after { + display: block; + content: ''; + clear: left; +} + +/* -- relbar ---------------------------------------------------------------- */ + +div.related { + width: 100%; + font-size: 90%; +} + +div.related h3 { + display: none; +} + +div.related ul { + margin: 0; + padding: 0 0 0 10px; + list-style: none; +} + +div.related li { + display: inline; +} + +div.related li.right { + float: right; + margin-right: 5px; +} + +/* -- sidebar --------------------------------------------------------------- */ + +div.sphinxsidebarwrapper { + padding: 10px 5px 0 10px; +} + +div.sphinxsidebar { + float: left; + width: 230px; + margin-left: -100%; + font-size: 90%; + word-wrap: break-word; + overflow-wrap : break-word; +} + +div.sphinxsidebar ul { + list-style: none; +} + +div.sphinxsidebar ul ul, +div.sphinxsidebar ul.want-points { + margin-left: 20px; + list-style: square; +} + +div.sphinxsidebar ul ul { + margin-top: 0; + margin-bottom: 0; +} + +div.sphinxsidebar form { + margin-top: 10px; +} + +div.sphinxsidebar input { + border: 1px solid #98dbcc; + font-family: sans-serif; + font-size: 1em; +} + +div.sphinxsidebar #searchbox form.search { + overflow: hidden; +} + +div.sphinxsidebar #searchbox input[type="text"] { + float: left; + width: 80%; + padding: 0.25em; + box-sizing: border-box; +} + +div.sphinxsidebar #searchbox input[type="submit"] { + float: left; + width: 20%; + border-left: none; + padding: 0.25em; + box-sizing: border-box; +} + + +img { + border: 0; + max-width: 100%; +} + +/* -- search page ----------------------------------------------------------- */ + +ul.search { + margin-top: 10px; +} + +ul.search li { + padding: 5px 0; +} + +ul.search li a { + font-weight: bold; +} + +ul.search li p.context { + color: #888; + margin: 2px 0 0 30px; + text-align: left; +} + +ul.keywordmatches li.goodmatch a { + font-weight: bold; +} + +/* -- index page ------------------------------------------------------------ */ + +table.contentstable { + width: 90%; + margin-left: auto; + margin-right: auto; +} + +table.contentstable p.biglink { + line-height: 150%; +} + +a.biglink { + font-size: 1.3em; +} + +span.linkdescr { + font-style: italic; + padding-top: 5px; + font-size: 90%; +} + +/* -- general index --------------------------------------------------------- */ + +table.indextable { + width: 100%; +} + +table.indextable td { + text-align: left; + vertical-align: top; +} + +table.indextable ul { + margin-top: 0; + margin-bottom: 0; + list-style-type: none; +} + +table.indextable > tbody > tr > td > ul { + padding-left: 0em; +} + +table.indextable tr.pcap { + height: 10px; +} + +table.indextable tr.cap { + margin-top: 10px; + background-color: #f2f2f2; +} + +img.toggler { + margin-right: 3px; + margin-top: 3px; + cursor: pointer; +} + +div.modindex-jumpbox { + border-top: 1px solid #ddd; + border-bottom: 1px solid #ddd; + margin: 1em 0 1em 0; + padding: 0.4em; +} + +div.genindex-jumpbox { + border-top: 1px solid #ddd; + border-bottom: 1px solid #ddd; + margin: 1em 0 1em 0; + padding: 0.4em; +} + +/* -- domain module index --------------------------------------------------- */ + +table.modindextable td { + padding: 2px; + border-collapse: collapse; +} + +/* -- general body styles --------------------------------------------------- */ + +div.body { + min-width: 360px; + max-width: 800px; +} + +div.body p, div.body dd, div.body li, div.body blockquote { + -moz-hyphens: auto; + -ms-hyphens: auto; + -webkit-hyphens: auto; + hyphens: auto; +} + +a.headerlink { + visibility: hidden; +} + +a:visited { + color: #551A8B; +} + +h1:hover > a.headerlink, +h2:hover > a.headerlink, +h3:hover > a.headerlink, +h4:hover > a.headerlink, +h5:hover > a.headerlink, +h6:hover > a.headerlink, +dt:hover > a.headerlink, +caption:hover > a.headerlink, +p.caption:hover > a.headerlink, +div.code-block-caption:hover > a.headerlink { + visibility: visible; +} + +div.body p.caption { + text-align: inherit; +} + +div.body td { + text-align: left; +} + +.first { + margin-top: 0 !important; +} + +p.rubric { + margin-top: 30px; + font-weight: bold; +} + +img.align-left, figure.align-left, .figure.align-left, object.align-left { + clear: left; + float: left; + margin-right: 1em; +} + +img.align-right, figure.align-right, .figure.align-right, object.align-right { + clear: right; + float: right; + margin-left: 1em; +} + +img.align-center, figure.align-center, .figure.align-center, object.align-center { + display: block; + margin-left: auto; + margin-right: auto; +} + +img.align-default, figure.align-default, .figure.align-default { + display: block; + margin-left: auto; + margin-right: auto; +} + +.align-left { + text-align: left; +} + +.align-center { + text-align: center; +} + +.align-default { + text-align: center; +} + +.align-right { + text-align: right; +} + +/* -- sidebars -------------------------------------------------------------- */ + +div.sidebar, +aside.sidebar { + margin: 0 0 0.5em 1em; + border: 1px solid #ddb; + padding: 7px; + background-color: #ffe; + width: 40%; + float: right; + clear: right; + overflow-x: auto; +} + +p.sidebar-title { + font-weight: bold; +} + +nav.contents, +aside.topic, +div.admonition, div.topic, blockquote { + clear: left; +} + +/* -- topics ---------------------------------------------------------------- */ + +nav.contents, +aside.topic, +div.topic { + border: 1px solid #ccc; + padding: 7px; + margin: 10px 0 10px 0; +} + +p.topic-title { + font-size: 1.1em; + font-weight: bold; + margin-top: 10px; +} + +/* -- admonitions ----------------------------------------------------------- */ + +div.admonition { + margin-top: 10px; + margin-bottom: 10px; + padding: 7px; +} + +div.admonition dt { + font-weight: bold; +} + +p.admonition-title { + margin: 0px 10px 5px 0px; + font-weight: bold; +} + +div.body p.centered { + text-align: center; + margin-top: 25px; +} + +/* -- content of sidebars/topics/admonitions -------------------------------- */ + +div.sidebar > :last-child, +aside.sidebar > :last-child, +nav.contents > :last-child, +aside.topic > :last-child, +div.topic > :last-child, +div.admonition > :last-child { + margin-bottom: 0; +} + +div.sidebar::after, +aside.sidebar::after, +nav.contents::after, +aside.topic::after, +div.topic::after, +div.admonition::after, +blockquote::after { + display: block; + content: ''; + clear: both; +} + +/* -- tables ---------------------------------------------------------------- */ + +table.docutils { + margin-top: 10px; + margin-bottom: 10px; + border: 0; + border-collapse: collapse; +} + +table.align-center { + margin-left: auto; + margin-right: auto; +} + +table.align-default { + margin-left: auto; + margin-right: auto; +} + +table caption span.caption-number { + font-style: italic; +} + +table caption span.caption-text { +} + +table.docutils td, table.docutils th { + padding: 1px 8px 1px 5px; + border-top: 0; + border-left: 0; + border-right: 0; + border-bottom: 1px solid #aaa; +} + +th { + text-align: left; + padding-right: 5px; +} + +table.citation { + border-left: solid 1px gray; + margin-left: 1px; +} + +table.citation td { + border-bottom: none; +} + +th > :first-child, +td > :first-child { + margin-top: 0px; +} + +th > :last-child, +td > :last-child { + margin-bottom: 0px; +} + +/* -- figures --------------------------------------------------------------- */ + +div.figure, figure { + margin: 0.5em; + padding: 0.5em; +} + +div.figure p.caption, figcaption { + padding: 0.3em; +} + +div.figure p.caption span.caption-number, +figcaption span.caption-number { + font-style: italic; +} + +div.figure p.caption span.caption-text, +figcaption span.caption-text { +} + +/* -- field list styles ----------------------------------------------------- */ + +table.field-list td, table.field-list th { + border: 0 !important; +} + +.field-list ul { + margin: 0; + padding-left: 1em; +} + +.field-list p { + margin: 0; +} + +.field-name { + -moz-hyphens: manual; + -ms-hyphens: manual; + -webkit-hyphens: manual; + hyphens: manual; +} + +/* -- hlist styles ---------------------------------------------------------- */ + +table.hlist { + margin: 1em 0; +} + +table.hlist td { + vertical-align: top; +} + +/* -- object description styles --------------------------------------------- */ + +.sig { + font-family: 'Consolas', 'Menlo', 'DejaVu Sans Mono', 'Bitstream Vera Sans Mono', monospace; +} + +.sig-name, code.descname { + background-color: transparent; + font-weight: bold; +} + +.sig-name { + font-size: 1.1em; +} + +code.descname { + font-size: 1.2em; +} + +.sig-prename, code.descclassname { + background-color: transparent; +} + +.optional { + font-size: 1.3em; +} + +.sig-paren { + font-size: larger; +} + +.sig-param.n { + font-style: italic; +} + +/* C++ specific styling */ + +.sig-inline.c-texpr, +.sig-inline.cpp-texpr { + font-family: unset; +} + +.sig.c .k, .sig.c .kt, +.sig.cpp .k, .sig.cpp .kt { + color: #0033B3; +} + +.sig.c .m, +.sig.cpp .m { + color: #1750EB; +} + +.sig.c .s, .sig.c .sc, +.sig.cpp .s, .sig.cpp .sc { + color: #067D17; +} + + +/* -- other body styles ----------------------------------------------------- */ + +ol.arabic { + list-style: decimal; +} + +ol.loweralpha { + list-style: lower-alpha; +} + +ol.upperalpha { + list-style: upper-alpha; +} + +ol.lowerroman { + list-style: lower-roman; +} + +ol.upperroman { + list-style: upper-roman; +} + +:not(li) > ol > li:first-child > :first-child, +:not(li) > ul > li:first-child > :first-child { + margin-top: 0px; +} + +:not(li) > ol > li:last-child > :last-child, +:not(li) > ul > li:last-child > :last-child { + margin-bottom: 0px; +} + +ol.simple ol p, +ol.simple ul p, +ul.simple ol p, +ul.simple ul p { + margin-top: 0; +} + +ol.simple > li:not(:first-child) > p, +ul.simple > li:not(:first-child) > p { + margin-top: 0; +} + +ol.simple p, +ul.simple p { + margin-bottom: 0; +} + +aside.footnote > span, +div.citation > span { + float: left; +} +aside.footnote > span:last-of-type, +div.citation > span:last-of-type { + padding-right: 0.5em; +} +aside.footnote > p { + margin-left: 2em; +} +div.citation > p { + margin-left: 4em; +} +aside.footnote > p:last-of-type, +div.citation > p:last-of-type { + margin-bottom: 0em; +} +aside.footnote > p:last-of-type:after, +div.citation > p:last-of-type:after { + content: ""; + clear: both; +} + +dl.field-list { + display: grid; + grid-template-columns: fit-content(30%) auto; +} + +dl.field-list > dt { + font-weight: bold; + word-break: break-word; + padding-left: 0.5em; + padding-right: 5px; +} + +dl.field-list > dd { + padding-left: 0.5em; + margin-top: 0em; + margin-left: 0em; + margin-bottom: 0em; +} + +dl { + margin-bottom: 15px; +} + +dd > :first-child { + margin-top: 0px; +} + +dd ul, dd table { + margin-bottom: 10px; +} + +dd { + margin-top: 3px; + margin-bottom: 10px; + margin-left: 30px; +} + +.sig dd { + margin-top: 0px; + margin-bottom: 0px; +} + +.sig dl { + margin-top: 0px; + margin-bottom: 0px; +} + +dl > dd:last-child, +dl > dd:last-child > :last-child { + margin-bottom: 0; +} + +dt:target, span.highlighted { + background-color: #fbe54e; +} + +rect.highlighted { + fill: #fbe54e; +} + +dl.glossary dt { + font-weight: bold; + font-size: 1.1em; +} + +.versionmodified { + font-style: italic; +} + +.system-message { + background-color: #fda; + padding: 5px; + border: 3px solid red; +} + +.footnote:target { + background-color: #ffa; +} + +.line-block { + display: block; + margin-top: 1em; + margin-bottom: 1em; +} + +.line-block .line-block { + margin-top: 0; + margin-bottom: 0; + margin-left: 1.5em; +} + +.guilabel, .menuselection { + font-family: sans-serif; +} + +.accelerator { + text-decoration: underline; +} + +.classifier { + font-style: oblique; +} + +.classifier:before { + font-style: normal; + margin: 0 0.5em; + content: ":"; + display: inline-block; +} + +abbr, acronym { + border-bottom: dotted 1px; + cursor: help; +} + +/* -- code displays --------------------------------------------------------- */ + +pre { + overflow: auto; + overflow-y: hidden; /* fixes display issues on Chrome browsers */ +} + +pre, div[class*="highlight-"] { + clear: both; +} + +span.pre { + -moz-hyphens: none; + -ms-hyphens: none; + -webkit-hyphens: none; + hyphens: none; + white-space: nowrap; +} + +div[class*="highlight-"] { + margin: 1em 0; +} + +td.linenos pre { + border: 0; + background-color: transparent; + color: #aaa; +} + +table.highlighttable { + display: block; +} + +table.highlighttable tbody { + display: block; +} + +table.highlighttable tr { + display: flex; +} + +table.highlighttable td { + margin: 0; + padding: 0; +} + +table.highlighttable td.linenos { + padding-right: 0.5em; +} + +table.highlighttable td.code { + flex: 1; + overflow: hidden; +} + +.highlight .hll { + display: block; +} + +div.highlight pre, +table.highlighttable pre { + margin: 0; +} + +div.code-block-caption + div { + margin-top: 0; +} + +div.code-block-caption { + margin-top: 1em; + padding: 2px 5px; + font-size: small; +} + +div.code-block-caption code { + background-color: transparent; +} + +table.highlighttable td.linenos, +span.linenos, +div.highlight span.gp { /* gp: Generic.Prompt */ + user-select: none; + -webkit-user-select: text; /* Safari fallback only */ + -webkit-user-select: none; /* Chrome/Safari */ + -moz-user-select: none; /* Firefox */ + -ms-user-select: none; /* IE10+ */ +} + +div.code-block-caption span.caption-number { + padding: 0.1em 0.3em; + font-style: italic; +} + +div.code-block-caption span.caption-text { +} + +div.literal-block-wrapper { + margin: 1em 0; +} + +code.xref, a code { + background-color: transparent; + font-weight: bold; +} + +h1 code, h2 code, h3 code, h4 code, h5 code, h6 code { + background-color: transparent; +} + +.viewcode-link { + float: right; +} + +.viewcode-back { + float: right; + font-family: sans-serif; +} + +div.viewcode-block:target { + margin: -1px -10px; + padding: 0 10px; +} + +/* -- math display ---------------------------------------------------------- */ + +img.math { + vertical-align: middle; +} + +div.body div.math p { + text-align: center; +} + +span.eqno { + float: right; +} + +span.eqno a.headerlink { + position: absolute; + z-index: 1; +} + +div.math:hover a.headerlink { + visibility: visible; +} + +/* -- printout stylesheet --------------------------------------------------- */ + +@media print { + div.document, + div.documentwrapper, + div.bodywrapper { + margin: 0 !important; + width: 100%; + } + + div.sphinxsidebar, + div.related, + div.footer, + #top-link { + display: none; + } +} \ No newline at end of file diff --git a/docs/_source/_build/html/_static/debug.css b/docs/_source/_build/html/_static/debug.css new file mode 100644 index 0000000..74d4aec --- /dev/null +++ b/docs/_source/_build/html/_static/debug.css @@ -0,0 +1,69 @@ +/* + This CSS file should be overridden by the theme authors. It's + meant for debugging and developing the skeleton that this theme provides. +*/ +body { + font-family: -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, + "Apple Color Emoji", "Segoe UI Emoji"; + background: lavender; +} +.sb-announcement { + background: rgb(131, 131, 131); +} +.sb-announcement__inner { + background: black; + color: white; +} +.sb-header { + background: lightskyblue; +} +.sb-header__inner { + background: royalblue; + color: white; +} +.sb-header-secondary { + background: lightcyan; +} +.sb-header-secondary__inner { + background: cornflowerblue; + color: white; +} +.sb-sidebar-primary { + background: lightgreen; +} +.sb-main { + background: blanchedalmond; +} +.sb-main__inner { + background: antiquewhite; +} +.sb-header-article { + background: lightsteelblue; +} +.sb-article-container { + background: snow; +} +.sb-article-main { + background: white; +} +.sb-footer-article { + background: lightpink; +} +.sb-sidebar-secondary { + background: lightgoldenrodyellow; +} +.sb-footer-content { + background: plum; +} +.sb-footer-content__inner { + background: palevioletred; +} +.sb-footer { + background: pink; +} +.sb-footer__inner { + background: salmon; +} +.sb-article { + background: white; +} diff --git a/docs/_source/_build/html/_static/doctools.js b/docs/_source/_build/html/_static/doctools.js new file mode 100644 index 0000000..807cdb1 --- /dev/null +++ b/docs/_source/_build/html/_static/doctools.js @@ -0,0 +1,150 @@ +/* + * Base JavaScript utilities for all Sphinx HTML documentation. + */ +"use strict"; + +const BLACKLISTED_KEY_CONTROL_ELEMENTS = new Set([ + "TEXTAREA", + "INPUT", + "SELECT", + "BUTTON", +]); + +const _ready = (callback) => { + if (document.readyState !== "loading") { + callback(); + } else { + document.addEventListener("DOMContentLoaded", callback); + } +}; + +/** + * Small JavaScript module for the documentation. + */ +const Documentation = { + init: () => { + Documentation.initDomainIndexTable(); + Documentation.initOnKeyListeners(); + }, + + /** + * i18n support + */ + TRANSLATIONS: {}, + PLURAL_EXPR: (n) => (n === 1 ? 0 : 1), + LOCALE: "unknown", + + // gettext and ngettext don't access this so that the functions + // can safely bound to a different name (_ = Documentation.gettext) + gettext: (string) => { + const translated = Documentation.TRANSLATIONS[string]; + switch (typeof translated) { + case "undefined": + return string; // no translation + case "string": + return translated; // translation exists + default: + return translated[0]; // (singular, plural) translation tuple exists + } + }, + + ngettext: (singular, plural, n) => { + const translated = Documentation.TRANSLATIONS[singular]; + if (typeof translated !== "undefined") + return translated[Documentation.PLURAL_EXPR(n)]; + return n === 1 ? singular : plural; + }, + + addTranslations: (catalog) => { + Object.assign(Documentation.TRANSLATIONS, catalog.messages); + Documentation.PLURAL_EXPR = new Function( + "n", + `return (${catalog.plural_expr})`, + ); + Documentation.LOCALE = catalog.locale; + }, + + /** + * helper function to focus on search bar + */ + focusSearchBar: () => { + document.querySelectorAll("input[name=q]")[0]?.focus(); + }, + + /** + * Initialise the domain index toggle buttons + */ + initDomainIndexTable: () => { + const toggler = (el) => { + const idNumber = el.id.substr(7); + const toggledRows = document.querySelectorAll(`tr.cg-${idNumber}`); + if (el.src.substr(-9) === "minus.png") { + el.src = `${el.src.substr(0, el.src.length - 9)}plus.png`; + toggledRows.forEach((el) => (el.style.display = "none")); + } else { + el.src = `${el.src.substr(0, el.src.length - 8)}minus.png`; + toggledRows.forEach((el) => (el.style.display = "")); + } + }; + + const togglerElements = document.querySelectorAll("img.toggler"); + togglerElements.forEach((el) => + el.addEventListener("click", (event) => toggler(event.currentTarget)), + ); + togglerElements.forEach((el) => (el.style.display = "")); + if (DOCUMENTATION_OPTIONS.COLLAPSE_INDEX) togglerElements.forEach(toggler); + }, + + initOnKeyListeners: () => { + // only install a listener if it is really needed + if ( + !DOCUMENTATION_OPTIONS.NAVIGATION_WITH_KEYS + && !DOCUMENTATION_OPTIONS.ENABLE_SEARCH_SHORTCUTS + ) + return; + + document.addEventListener("keydown", (event) => { + // bail for input elements + if (BLACKLISTED_KEY_CONTROL_ELEMENTS.has(document.activeElement.tagName)) + return; + // bail with special keys + if (event.altKey || event.ctrlKey || event.metaKey) return; + + if (!event.shiftKey) { + switch (event.key) { + case "ArrowLeft": + if (!DOCUMENTATION_OPTIONS.NAVIGATION_WITH_KEYS) break; + + const prevLink = document.querySelector('link[rel="prev"]'); + if (prevLink && prevLink.href) { + window.location.href = prevLink.href; + event.preventDefault(); + } + break; + case "ArrowRight": + if (!DOCUMENTATION_OPTIONS.NAVIGATION_WITH_KEYS) break; + + const nextLink = document.querySelector('link[rel="next"]'); + if (nextLink && nextLink.href) { + window.location.href = nextLink.href; + event.preventDefault(); + } + break; + } + } + + // some keyboard layouts may need Shift to get / + switch (event.key) { + case "/": + if (!DOCUMENTATION_OPTIONS.ENABLE_SEARCH_SHORTCUTS) break; + Documentation.focusSearchBar(); + event.preventDefault(); + } + }); + }, +}; + +// quick alias for translations +const _ = Documentation.gettext; + +_ready(Documentation.init); diff --git a/docs/_source/_build/html/_static/documentation_options.js b/docs/_source/_build/html/_static/documentation_options.js new file mode 100644 index 0000000..a795c44 --- /dev/null +++ b/docs/_source/_build/html/_static/documentation_options.js @@ -0,0 +1,13 @@ +const DOCUMENTATION_OPTIONS = { + VERSION: '9.8.0', + LANGUAGE: 'en', + COLLAPSE_INDEX: false, + BUILDER: 'html', + FILE_SUFFIX: '.html', + LINK_SUFFIX: '.html', + HAS_SOURCE: true, + SOURCELINK_SUFFIX: '.txt', + NAVIGATION_WITH_KEYS: false, + SHOW_SEARCH_SUMMARY: true, + ENABLE_SEARCH_SHORTCUTS: true, +}; \ No newline at end of file diff --git a/docs/_source/_build/html/_static/english-stemmer.js b/docs/_source/_build/html/_static/english-stemmer.js new file mode 100644 index 0000000..056760e --- /dev/null +++ b/docs/_source/_build/html/_static/english-stemmer.js @@ -0,0 +1,1066 @@ +// Generated from english.sbl by Snowball 3.0.1 - https://snowballstem.org/ + +/**@constructor*/ +var EnglishStemmer = function() { + var base = new BaseStemmer(); + + /** @const */ var a_0 = [ + ["arsen", -1, -1], + ["commun", -1, -1], + ["emerg", -1, -1], + ["gener", -1, -1], + ["later", -1, -1], + ["organ", -1, -1], + ["past", -1, -1], + ["univers", -1, -1] + ]; + + /** @const */ var a_1 = [ + ["'", -1, 1], + ["'s'", 0, 1], + ["'s", -1, 1] + ]; + + /** @const */ var a_2 = [ + ["ied", -1, 2], + ["s", -1, 3], + ["ies", 1, 2], + ["sses", 1, 1], + ["ss", 1, -1], + ["us", 1, -1] + ]; + + /** @const */ var a_3 = [ + ["succ", -1, 1], + ["proc", -1, 1], + ["exc", -1, 1] + ]; + + /** @const */ var a_4 = [ + ["even", -1, 2], + ["cann", -1, 2], + ["inn", -1, 2], + ["earr", -1, 2], + ["herr", -1, 2], + ["out", -1, 2], + ["y", -1, 1] + ]; + + /** @const */ var a_5 = [ + ["", -1, -1], + ["ed", 0, 2], + ["eed", 1, 1], + ["ing", 0, 3], + ["edly", 0, 2], + ["eedly", 4, 1], + ["ingly", 0, 2] + ]; + + /** @const */ var a_6 = [ + ["", -1, 3], + ["bb", 0, 2], + ["dd", 0, 2], + ["ff", 0, 2], + ["gg", 0, 2], + ["bl", 0, 1], + ["mm", 0, 2], + ["nn", 0, 2], + ["pp", 0, 2], + ["rr", 0, 2], + ["at", 0, 1], + ["tt", 0, 2], + ["iz", 0, 1] + ]; + + /** @const */ var a_7 = [ + ["anci", -1, 3], + ["enci", -1, 2], + ["ogi", -1, 14], + ["li", -1, 16], + ["bli", 3, 12], + ["abli", 4, 4], + ["alli", 3, 8], + ["fulli", 3, 9], + ["lessli", 3, 15], + ["ousli", 3, 10], + ["entli", 3, 5], + ["aliti", -1, 8], + ["biliti", -1, 12], + ["iviti", -1, 11], + ["tional", -1, 1], + ["ational", 14, 7], + ["alism", -1, 8], + ["ation", -1, 7], + ["ization", 17, 6], + ["izer", -1, 6], + ["ator", -1, 7], + ["iveness", -1, 11], + ["fulness", -1, 9], + ["ousness", -1, 10], + ["ogist", -1, 13] + ]; + + /** @const */ var a_8 = [ + ["icate", -1, 4], + ["ative", -1, 6], + ["alize", -1, 3], + ["iciti", -1, 4], + ["ical", -1, 4], + ["tional", -1, 1], + ["ational", 5, 2], + ["ful", -1, 5], + ["ness", -1, 5] + ]; + + /** @const */ var a_9 = [ + ["ic", -1, 1], + ["ance", -1, 1], + ["ence", -1, 1], + ["able", -1, 1], + ["ible", -1, 1], + ["ate", -1, 1], + ["ive", -1, 1], + ["ize", -1, 1], + ["iti", -1, 1], + ["al", -1, 1], + ["ism", -1, 1], + ["ion", -1, 2], + ["er", -1, 1], + ["ous", -1, 1], + ["ant", -1, 1], + ["ent", -1, 1], + ["ment", 15, 1], + ["ement", 16, 1] + ]; + + /** @const */ var a_10 = [ + ["e", -1, 1], + ["l", -1, 2] + ]; + + /** @const */ var a_11 = [ + ["andes", -1, -1], + ["atlas", -1, -1], + ["bias", -1, -1], + ["cosmos", -1, -1], + ["early", -1, 5], + ["gently", -1, 3], + ["howe", -1, -1], + ["idly", -1, 2], + ["news", -1, -1], + ["only", -1, 6], + ["singly", -1, 7], + ["skies", -1, 1], + ["sky", -1, -1], + ["ugly", -1, 4] + ]; + + /** @const */ var /** Array */ g_aeo = [17, 64]; + + /** @const */ var /** Array */ g_v = [17, 65, 16, 1]; + + /** @const */ var /** Array */ g_v_WXY = [1, 17, 65, 208, 1]; + + /** @const */ var /** Array */ g_valid_LI = [55, 141, 2]; + + var /** boolean */ B_Y_found = false; + var /** number */ I_p2 = 0; + var /** number */ I_p1 = 0; + + + /** @return {boolean} */ + function r_prelude() { + B_Y_found = false; + /** @const */ var /** number */ v_1 = base.cursor; + lab0: { + base.bra = base.cursor; + if (!(base.eq_s("'"))) + { + break lab0; + } + base.ket = base.cursor; + if (!base.slice_del()) + { + return false; + } + } + base.cursor = v_1; + /** @const */ var /** number */ v_2 = base.cursor; + lab1: { + base.bra = base.cursor; + if (!(base.eq_s("y"))) + { + break lab1; + } + base.ket = base.cursor; + if (!base.slice_from("Y")) + { + return false; + } + B_Y_found = true; + } + base.cursor = v_2; + /** @const */ var /** number */ v_3 = base.cursor; + lab2: { + while(true) + { + /** @const */ var /** number */ v_4 = base.cursor; + lab3: { + golab4: while(true) + { + /** @const */ var /** number */ v_5 = base.cursor; + lab5: { + if (!(base.in_grouping(g_v, 97, 121))) + { + break lab5; + } + base.bra = base.cursor; + if (!(base.eq_s("y"))) + { + break lab5; + } + base.ket = base.cursor; + base.cursor = v_5; + break golab4; + } + base.cursor = v_5; + if (base.cursor >= base.limit) + { + break lab3; + } + base.cursor++; + } + if (!base.slice_from("Y")) + { + return false; + } + B_Y_found = true; + continue; + } + base.cursor = v_4; + break; + } + } + base.cursor = v_3; + return true; + }; + + /** @return {boolean} */ + function r_mark_regions() { + I_p1 = base.limit; + I_p2 = base.limit; + /** @const */ var /** number */ v_1 = base.cursor; + lab0: { + lab1: { + /** @const */ var /** number */ v_2 = base.cursor; + lab2: { + if (base.find_among(a_0) == 0) + { + break lab2; + } + break lab1; + } + base.cursor = v_2; + if (!base.go_out_grouping(g_v, 97, 121)) + { + break lab0; + } + base.cursor++; + if (!base.go_in_grouping(g_v, 97, 121)) + { + break lab0; + } + base.cursor++; + } + I_p1 = base.cursor; + if (!base.go_out_grouping(g_v, 97, 121)) + { + break lab0; + } + base.cursor++; + if (!base.go_in_grouping(g_v, 97, 121)) + { + break lab0; + } + base.cursor++; + I_p2 = base.cursor; + } + base.cursor = v_1; + return true; + }; + + /** @return {boolean} */ + function r_shortv() { + lab0: { + /** @const */ var /** number */ v_1 = base.limit - base.cursor; + lab1: { + if (!(base.out_grouping_b(g_v_WXY, 89, 121))) + { + break lab1; + } + if (!(base.in_grouping_b(g_v, 97, 121))) + { + break lab1; + } + if (!(base.out_grouping_b(g_v, 97, 121))) + { + break lab1; + } + break lab0; + } + base.cursor = base.limit - v_1; + lab2: { + if (!(base.out_grouping_b(g_v, 97, 121))) + { + break lab2; + } + if (!(base.in_grouping_b(g_v, 97, 121))) + { + break lab2; + } + if (base.cursor > base.limit_backward) + { + break lab2; + } + break lab0; + } + base.cursor = base.limit - v_1; + if (!(base.eq_s_b("past"))) + { + return false; + } + } + return true; + }; + + /** @return {boolean} */ + function r_R1() { + return I_p1 <= base.cursor; + }; + + /** @return {boolean} */ + function r_R2() { + return I_p2 <= base.cursor; + }; + + /** @return {boolean} */ + function r_Step_1a() { + var /** number */ among_var; + /** @const */ var /** number */ v_1 = base.limit - base.cursor; + lab0: { + base.ket = base.cursor; + if (base.find_among_b(a_1) == 0) + { + base.cursor = base.limit - v_1; + break lab0; + } + base.bra = base.cursor; + if (!base.slice_del()) + { + return false; + } + } + base.ket = base.cursor; + among_var = base.find_among_b(a_2); + if (among_var == 0) + { + return false; + } + base.bra = base.cursor; + switch (among_var) { + case 1: + if (!base.slice_from("ss")) + { + return false; + } + break; + case 2: + lab1: { + /** @const */ var /** number */ v_2 = base.limit - base.cursor; + lab2: { + { + /** @const */ var /** number */ c1 = base.cursor - 2; + if (c1 < base.limit_backward) + { + break lab2; + } + base.cursor = c1; + } + if (!base.slice_from("i")) + { + return false; + } + break lab1; + } + base.cursor = base.limit - v_2; + if (!base.slice_from("ie")) + { + return false; + } + } + break; + case 3: + if (base.cursor <= base.limit_backward) + { + return false; + } + base.cursor--; + if (!base.go_out_grouping_b(g_v, 97, 121)) + { + return false; + } + base.cursor--; + if (!base.slice_del()) + { + return false; + } + break; + } + return true; + }; + + /** @return {boolean} */ + function r_Step_1b() { + var /** number */ among_var; + base.ket = base.cursor; + among_var = base.find_among_b(a_5); + base.bra = base.cursor; + lab0: { + /** @const */ var /** number */ v_1 = base.limit - base.cursor; + lab1: { + switch (among_var) { + case 1: + /** @const */ var /** number */ v_2 = base.limit - base.cursor; + lab2: { + lab3: { + /** @const */ var /** number */ v_3 = base.limit - base.cursor; + lab4: { + if (base.find_among_b(a_3) == 0) + { + break lab4; + } + if (base.cursor > base.limit_backward) + { + break lab4; + } + break lab3; + } + base.cursor = base.limit - v_3; + if (!r_R1()) + { + break lab2; + } + if (!base.slice_from("ee")) + { + return false; + } + } + } + base.cursor = base.limit - v_2; + break; + case 2: + break lab1; + case 3: + among_var = base.find_among_b(a_4); + if (among_var == 0) + { + break lab1; + } + switch (among_var) { + case 1: + /** @const */ var /** number */ v_4 = base.limit - base.cursor; + if (!(base.out_grouping_b(g_v, 97, 121))) + { + break lab1; + } + if (base.cursor > base.limit_backward) + { + break lab1; + } + base.cursor = base.limit - v_4; + base.bra = base.cursor; + if (!base.slice_from("ie")) + { + return false; + } + break; + case 2: + if (base.cursor > base.limit_backward) + { + break lab1; + } + break; + } + break; + } + break lab0; + } + base.cursor = base.limit - v_1; + /** @const */ var /** number */ v_5 = base.limit - base.cursor; + if (!base.go_out_grouping_b(g_v, 97, 121)) + { + return false; + } + base.cursor--; + base.cursor = base.limit - v_5; + if (!base.slice_del()) + { + return false; + } + base.ket = base.cursor; + base.bra = base.cursor; + /** @const */ var /** number */ v_6 = base.limit - base.cursor; + among_var = base.find_among_b(a_6); + switch (among_var) { + case 1: + if (!base.slice_from("e")) + { + return false; + } + return false; + case 2: + { + /** @const */ var /** number */ v_7 = base.limit - base.cursor; + lab5: { + if (!(base.in_grouping_b(g_aeo, 97, 111))) + { + break lab5; + } + if (base.cursor > base.limit_backward) + { + break lab5; + } + return false; + } + base.cursor = base.limit - v_7; + } + break; + case 3: + if (base.cursor != I_p1) + { + return false; + } + /** @const */ var /** number */ v_8 = base.limit - base.cursor; + if (!r_shortv()) + { + return false; + } + base.cursor = base.limit - v_8; + if (!base.slice_from("e")) + { + return false; + } + return false; + } + base.cursor = base.limit - v_6; + base.ket = base.cursor; + if (base.cursor <= base.limit_backward) + { + return false; + } + base.cursor--; + base.bra = base.cursor; + if (!base.slice_del()) + { + return false; + } + } + return true; + }; + + /** @return {boolean} */ + function r_Step_1c() { + base.ket = base.cursor; + lab0: { + /** @const */ var /** number */ v_1 = base.limit - base.cursor; + lab1: { + if (!(base.eq_s_b("y"))) + { + break lab1; + } + break lab0; + } + base.cursor = base.limit - v_1; + if (!(base.eq_s_b("Y"))) + { + return false; + } + } + base.bra = base.cursor; + if (!(base.out_grouping_b(g_v, 97, 121))) + { + return false; + } + lab2: { + if (base.cursor > base.limit_backward) + { + break lab2; + } + return false; + } + if (!base.slice_from("i")) + { + return false; + } + return true; + }; + + /** @return {boolean} */ + function r_Step_2() { + var /** number */ among_var; + base.ket = base.cursor; + among_var = base.find_among_b(a_7); + if (among_var == 0) + { + return false; + } + base.bra = base.cursor; + if (!r_R1()) + { + return false; + } + switch (among_var) { + case 1: + if (!base.slice_from("tion")) + { + return false; + } + break; + case 2: + if (!base.slice_from("ence")) + { + return false; + } + break; + case 3: + if (!base.slice_from("ance")) + { + return false; + } + break; + case 4: + if (!base.slice_from("able")) + { + return false; + } + break; + case 5: + if (!base.slice_from("ent")) + { + return false; + } + break; + case 6: + if (!base.slice_from("ize")) + { + return false; + } + break; + case 7: + if (!base.slice_from("ate")) + { + return false; + } + break; + case 8: + if (!base.slice_from("al")) + { + return false; + } + break; + case 9: + if (!base.slice_from("ful")) + { + return false; + } + break; + case 10: + if (!base.slice_from("ous")) + { + return false; + } + break; + case 11: + if (!base.slice_from("ive")) + { + return false; + } + break; + case 12: + if (!base.slice_from("ble")) + { + return false; + } + break; + case 13: + if (!base.slice_from("og")) + { + return false; + } + break; + case 14: + if (!(base.eq_s_b("l"))) + { + return false; + } + if (!base.slice_from("og")) + { + return false; + } + break; + case 15: + if (!base.slice_from("less")) + { + return false; + } + break; + case 16: + if (!(base.in_grouping_b(g_valid_LI, 99, 116))) + { + return false; + } + if (!base.slice_del()) + { + return false; + } + break; + } + return true; + }; + + /** @return {boolean} */ + function r_Step_3() { + var /** number */ among_var; + base.ket = base.cursor; + among_var = base.find_among_b(a_8); + if (among_var == 0) + { + return false; + } + base.bra = base.cursor; + if (!r_R1()) + { + return false; + } + switch (among_var) { + case 1: + if (!base.slice_from("tion")) + { + return false; + } + break; + case 2: + if (!base.slice_from("ate")) + { + return false; + } + break; + case 3: + if (!base.slice_from("al")) + { + return false; + } + break; + case 4: + if (!base.slice_from("ic")) + { + return false; + } + break; + case 5: + if (!base.slice_del()) + { + return false; + } + break; + case 6: + if (!r_R2()) + { + return false; + } + if (!base.slice_del()) + { + return false; + } + break; + } + return true; + }; + + /** @return {boolean} */ + function r_Step_4() { + var /** number */ among_var; + base.ket = base.cursor; + among_var = base.find_among_b(a_9); + if (among_var == 0) + { + return false; + } + base.bra = base.cursor; + if (!r_R2()) + { + return false; + } + switch (among_var) { + case 1: + if (!base.slice_del()) + { + return false; + } + break; + case 2: + lab0: { + /** @const */ var /** number */ v_1 = base.limit - base.cursor; + lab1: { + if (!(base.eq_s_b("s"))) + { + break lab1; + } + break lab0; + } + base.cursor = base.limit - v_1; + if (!(base.eq_s_b("t"))) + { + return false; + } + } + if (!base.slice_del()) + { + return false; + } + break; + } + return true; + }; + + /** @return {boolean} */ + function r_Step_5() { + var /** number */ among_var; + base.ket = base.cursor; + among_var = base.find_among_b(a_10); + if (among_var == 0) + { + return false; + } + base.bra = base.cursor; + switch (among_var) { + case 1: + lab0: { + lab1: { + if (!r_R2()) + { + break lab1; + } + break lab0; + } + if (!r_R1()) + { + return false; + } + { + /** @const */ var /** number */ v_1 = base.limit - base.cursor; + lab2: { + if (!r_shortv()) + { + break lab2; + } + return false; + } + base.cursor = base.limit - v_1; + } + } + if (!base.slice_del()) + { + return false; + } + break; + case 2: + if (!r_R2()) + { + return false; + } + if (!(base.eq_s_b("l"))) + { + return false; + } + if (!base.slice_del()) + { + return false; + } + break; + } + return true; + }; + + /** @return {boolean} */ + function r_exception1() { + var /** number */ among_var; + base.bra = base.cursor; + among_var = base.find_among(a_11); + if (among_var == 0) + { + return false; + } + base.ket = base.cursor; + if (base.cursor < base.limit) + { + return false; + } + switch (among_var) { + case 1: + if (!base.slice_from("sky")) + { + return false; + } + break; + case 2: + if (!base.slice_from("idl")) + { + return false; + } + break; + case 3: + if (!base.slice_from("gentl")) + { + return false; + } + break; + case 4: + if (!base.slice_from("ugli")) + { + return false; + } + break; + case 5: + if (!base.slice_from("earli")) + { + return false; + } + break; + case 6: + if (!base.slice_from("onli")) + { + return false; + } + break; + case 7: + if (!base.slice_from("singl")) + { + return false; + } + break; + } + return true; + }; + + /** @return {boolean} */ + function r_postlude() { + if (!B_Y_found) + { + return false; + } + while(true) + { + /** @const */ var /** number */ v_1 = base.cursor; + lab0: { + golab1: while(true) + { + /** @const */ var /** number */ v_2 = base.cursor; + lab2: { + base.bra = base.cursor; + if (!(base.eq_s("Y"))) + { + break lab2; + } + base.ket = base.cursor; + base.cursor = v_2; + break golab1; + } + base.cursor = v_2; + if (base.cursor >= base.limit) + { + break lab0; + } + base.cursor++; + } + if (!base.slice_from("y")) + { + return false; + } + continue; + } + base.cursor = v_1; + break; + } + return true; + }; + + this.stem = /** @return {boolean} */ function() { + lab0: { + /** @const */ var /** number */ v_1 = base.cursor; + lab1: { + if (!r_exception1()) + { + break lab1; + } + break lab0; + } + base.cursor = v_1; + lab2: { + { + /** @const */ var /** number */ v_2 = base.cursor; + lab3: { + { + /** @const */ var /** number */ c1 = base.cursor + 3; + if (c1 > base.limit) + { + break lab3; + } + base.cursor = c1; + } + break lab2; + } + base.cursor = v_2; + } + break lab0; + } + base.cursor = v_1; + r_prelude(); + r_mark_regions(); + base.limit_backward = base.cursor; base.cursor = base.limit; + /** @const */ var /** number */ v_3 = base.limit - base.cursor; + r_Step_1a(); + base.cursor = base.limit - v_3; + /** @const */ var /** number */ v_4 = base.limit - base.cursor; + r_Step_1b(); + base.cursor = base.limit - v_4; + /** @const */ var /** number */ v_5 = base.limit - base.cursor; + r_Step_1c(); + base.cursor = base.limit - v_5; + /** @const */ var /** number */ v_6 = base.limit - base.cursor; + r_Step_2(); + base.cursor = base.limit - v_6; + /** @const */ var /** number */ v_7 = base.limit - base.cursor; + r_Step_3(); + base.cursor = base.limit - v_7; + /** @const */ var /** number */ v_8 = base.limit - base.cursor; + r_Step_4(); + base.cursor = base.limit - v_8; + /** @const */ var /** number */ v_9 = base.limit - base.cursor; + r_Step_5(); + base.cursor = base.limit - v_9; + base.cursor = base.limit_backward; + /** @const */ var /** number */ v_10 = base.cursor; + r_postlude(); + base.cursor = v_10; + } + return true; + }; + + /**@return{string}*/ + this['stemWord'] = function(/**string*/word) { + base.setCurrent(word); + this.stem(); + return base.getCurrent(); + }; +}; diff --git a/docs/_source/_build/html/_static/file.png b/docs/_source/_build/html/_static/file.png new file mode 100644 index 0000000..a858a41 Binary files /dev/null and b/docs/_source/_build/html/_static/file.png differ diff --git a/docs/_source/_build/html/_static/language_data.js b/docs/_source/_build/html/_static/language_data.js new file mode 100644 index 0000000..5776786 --- /dev/null +++ b/docs/_source/_build/html/_static/language_data.js @@ -0,0 +1,13 @@ +/* + * This script contains the language-specific data used by searchtools.js, + * namely the set of stopwords, stemmer, scorer and splitter. + */ + +const stopwords = new Set(["a", "about", "above", "after", "again", "against", "all", "am", "an", "and", "any", "are", "aren't", "as", "at", "be", "because", "been", "before", "being", "below", "between", "both", "but", "by", "can't", "cannot", "could", "couldn't", "did", "didn't", "do", "does", "doesn't", "doing", "don't", "down", "during", "each", "few", "for", "from", "further", "had", "hadn't", "has", "hasn't", "have", "haven't", "having", "he", "he'd", "he'll", "he's", "her", "here", "here's", "hers", "herself", "him", "himself", "his", "how", "how's", "i", "i'd", "i'll", "i'm", "i've", "if", "in", "into", "is", "isn't", "it", "it's", "its", "itself", "let's", "me", "more", "most", "mustn't", "my", "myself", "no", "nor", "not", "of", "off", "on", "once", "only", "or", "other", "ought", "our", "ours", "ourselves", "out", "over", "own", "same", "shan't", "she", "she'd", "she'll", "she's", "should", "shouldn't", "so", "some", "such", "than", "that", "that's", "the", "their", "theirs", "them", "themselves", "then", "there", "there's", "these", "they", "they'd", "they'll", "they're", "they've", "this", "those", "through", "to", "too", "under", "until", "up", "very", "was", "wasn't", "we", "we'd", "we'll", "we're", "we've", "were", "weren't", "what", "what's", "when", "when's", "where", "where's", "which", "while", "who", "who's", "whom", "why", "why's", "with", "won't", "would", "wouldn't", "you", "you'd", "you'll", "you're", "you've", "your", "yours", "yourself", "yourselves"]); +window.stopwords = stopwords; // Export to global scope + + +/* Non-minified versions are copied as separate JavaScript files, if available */ +BaseStemmer=function(){this.current="",this.cursor=0,this.limit=0,this.limit_backward=0,this.bra=0,this.ket=0,this.setCurrent=function(t){this.current=t,this.cursor=0,this.limit=this.current.length,this.limit_backward=0,this.bra=this.cursor,this.ket=this.limit},this.getCurrent=function(){return this.current},this.copy_from=function(t){this.current=t.current,this.cursor=t.cursor,this.limit=t.limit,this.limit_backward=t.limit_backward,this.bra=t.bra,this.ket=t.ket},this.in_grouping=function(t,r,i){return!(this.cursor>=this.limit||i<(i=this.current.charCodeAt(this.cursor))||i>>3]&1<<(7&i))||(this.cursor++,0))},this.go_in_grouping=function(t,r,i){for(;this.cursor>>3]&1<<(7&s)))return!0;this.cursor++}return!1},this.in_grouping_b=function(t,r,i){return!(this.cursor<=this.limit_backward||i<(i=this.current.charCodeAt(this.cursor-1))||i>>3]&1<<(7&i))||(this.cursor--,0))},this.go_in_grouping_b=function(t,r,i){for(;this.cursor>this.limit_backward;){var s=this.current.charCodeAt(this.cursor-1);if(i>>3]&1<<(7&s)))return!0;this.cursor--}return!1},this.out_grouping=function(t,r,i){return!(this.cursor>=this.limit)&&(i<(i=this.current.charCodeAt(this.cursor))||i>>3]&1<<(7&i)))&&(this.cursor++,!0)},this.go_out_grouping=function(t,r,i){for(;this.cursor>>3]&1<<(7&s)))return!0;this.cursor++}return!1},this.out_grouping_b=function(t,r,i){return!(this.cursor<=this.limit_backward)&&(i<(i=this.current.charCodeAt(this.cursor-1))||i>>3]&1<<(7&i)))&&(this.cursor--,!0)},this.go_out_grouping_b=function(t,r,i){for(;this.cursor>this.limit_backward;){var s=this.current.charCodeAt(this.cursor-1);if(s<=i&&r<=s&&0!=(t[(s-=r)>>>3]&1<<(7&s)))return!0;this.cursor--}return!1},this.eq_s=function(t){return!(this.limit-this.cursor>>1),o=0,a=e=(l=t[r])[0].length){if(this.cursor=s+l[0].length,l.length<4)return l[2];var g=l[3](this);if(this.cursor=s+l[0].length,g)return l[2]}}while(0<=(r=l[1]));return 0},this.find_among_b=function(t){for(var r=0,i=t.length,s=this.cursor,h=this.limit_backward,e=0,n=0,c=!1;;){for(var u,o=r+(i-r>>1),a=0,l=e=(u=t[r])[0].length){if(this.cursor=s-u[0].length,u.length<4)return u[2];var g=u[3](this);if(this.cursor=s-u[0].length,g)return u[2]}}while(0<=(r=u[1]));return 0},this.replace_s=function(t,r,i){var s=i.length-(r-t);return this.current=this.current.slice(0,t)+i+this.current.slice(r),this.limit+=s,this.cursor>=r?this.cursor+=s:this.cursor>t&&(this.cursor=t),s},this.slice_check=function(){return!(this.bra<0||this.bra>this.ket||this.ket>this.limit||this.limit>this.current.length)},this.slice_from=function(t){var r=!1;return this.slice_check()&&(this.replace_s(this.bra,this.ket,t),r=!0),r},this.slice_del=function(){return this.slice_from("")},this.insert=function(t,r,i){r=this.replace_s(t,r,i);t<=this.bra&&(this.bra+=r),t<=this.ket&&(this.ket+=r)},this.slice_to=function(){var t="";return t=this.slice_check()?this.current.slice(this.bra,this.ket):t},this.assign_to=function(){return this.current.slice(0,this.limit)}}; +var EnglishStemmer=function(){var a=new BaseStemmer,c=[["arsen",-1,-1],["commun",-1,-1],["emerg",-1,-1],["gener",-1,-1],["later",-1,-1],["organ",-1,-1],["past",-1,-1],["univers",-1,-1]],o=[["'",-1,1],["'s'",0,1],["'s",-1,1]],u=[["ied",-1,2],["s",-1,3],["ies",1,2],["sses",1,1],["ss",1,-1],["us",1,-1]],t=[["succ",-1,1],["proc",-1,1],["exc",-1,1]],l=[["even",-1,2],["cann",-1,2],["inn",-1,2],["earr",-1,2],["herr",-1,2],["out",-1,2],["y",-1,1]],n=[["",-1,-1],["ed",0,2],["eed",1,1],["ing",0,3],["edly",0,2],["eedly",4,1],["ingly",0,2]],f=[["",-1,3],["bb",0,2],["dd",0,2],["ff",0,2],["gg",0,2],["bl",0,1],["mm",0,2],["nn",0,2],["pp",0,2],["rr",0,2],["at",0,1],["tt",0,2],["iz",0,1]],_=[["anci",-1,3],["enci",-1,2],["ogi",-1,14],["li",-1,16],["bli",3,12],["abli",4,4],["alli",3,8],["fulli",3,9],["lessli",3,15],["ousli",3,10],["entli",3,5],["aliti",-1,8],["biliti",-1,12],["iviti",-1,11],["tional",-1,1],["ational",14,7],["alism",-1,8],["ation",-1,7],["ization",17,6],["izer",-1,6],["ator",-1,7],["iveness",-1,11],["fulness",-1,9],["ousness",-1,10],["ogist",-1,13]],m=[["icate",-1,4],["ative",-1,6],["alize",-1,3],["iciti",-1,4],["ical",-1,4],["tional",-1,1],["ational",5,2],["ful",-1,5],["ness",-1,5]],b=[["ic",-1,1],["ance",-1,1],["ence",-1,1],["able",-1,1],["ible",-1,1],["ate",-1,1],["ive",-1,1],["ize",-1,1],["iti",-1,1],["al",-1,1],["ism",-1,1],["ion",-1,2],["er",-1,1],["ous",-1,1],["ant",-1,1],["ent",-1,1],["ment",15,1],["ement",16,1]],k=[["e",-1,1],["l",-1,2]],g=[["andes",-1,-1],["atlas",-1,-1],["bias",-1,-1],["cosmos",-1,-1],["early",-1,5],["gently",-1,3],["howe",-1,-1],["idly",-1,2],["news",-1,-1],["only",-1,6],["singly",-1,7],["skies",-1,1],["sky",-1,-1],["ugly",-1,4]],d=[17,64],v=[17,65,16,1],i=[1,17,65,208,1],w=[55,141,2],p=!1,y=0,h=0;function q(){var r=a.limit-a.cursor;return!!(a.out_grouping_b(i,89,121)&&a.in_grouping_b(v,97,121)&&a.out_grouping_b(v,97,121)||(a.cursor=a.limit-r,a.out_grouping_b(v,97,121)&&a.in_grouping_b(v,97,121)&&!(a.cursor>a.limit_backward))||(a.cursor=a.limit-r,a.eq_s_b("past")))}function z(){return h<=a.cursor}function Y(){return y<=a.cursor}this.stem=function(){var r=a.cursor;if(!(()=>{var r;if(a.bra=a.cursor,0!=(r=a.find_among(g))&&(a.ket=a.cursor,!(a.cursora.limit)a.cursor=i;else{a.cursor=e,a.cursor=r,(()=>{p=!1;var r=a.cursor;if(a.bra=a.cursor,!a.eq_s("'")||(a.ket=a.cursor,a.slice_del())){a.cursor=r;r=a.cursor;if(a.bra=a.cursor,a.eq_s("y")){if(a.ket=a.cursor,!a.slice_from("Y"))return;p=!0}a.cursor=r;for(r=a.cursor;;){var i=a.cursor;r:{for(;;){var e=a.cursor;if(a.in_grouping(v,97,121)&&(a.bra=a.cursor,a.eq_s("y"))){a.ket=a.cursor,a.cursor=e;break}if(a.cursor=e,a.cursor>=a.limit)break r;a.cursor++}if(!a.slice_from("Y"))return;p=!0;continue}a.cursor=i;break}a.cursor=r}})(),h=a.limit,y=a.limit;i=a.cursor;r:{var s=a.cursor;if(0==a.find_among(c)){if(a.cursor=s,!a.go_out_grouping(v,97,121))break r;if(a.cursor++,!a.go_in_grouping(v,97,121))break r;a.cursor++}h=a.cursor,a.go_out_grouping(v,97,121)&&(a.cursor++,a.go_in_grouping(v,97,121))&&(a.cursor++,y=a.cursor)}a.cursor=i,a.limit_backward=a.cursor,a.cursor=a.limit;var e=a.limit-a.cursor,r=((()=>{var r=a.limit-a.cursor;if(a.ket=a.cursor,0==a.find_among_b(o))a.cursor=a.limit-r;else if(a.bra=a.cursor,!a.slice_del())return;if(a.ket=a.cursor,0!=(r=a.find_among_b(u)))switch(a.bra=a.cursor,r){case 1:if(a.slice_from("ss"))break;return;case 2:r:{var i=a.limit-a.cursor,e=a.cursor-2;if(!(e{a.ket=a.cursor,o=a.find_among_b(n),a.bra=a.cursor;r:{var r=a.limit-a.cursor;i:{switch(o){case 1:var i=a.limit-a.cursor;e:{var e=a.limit-a.cursor;if(0==a.find_among_b(t)||a.cursor>a.limit_backward){if(a.cursor=a.limit-e,!z())break e;if(!a.slice_from("ee"))return}}a.cursor=a.limit-i;break;case 2:break i;case 3:if(0==(o=a.find_among_b(l)))break i;switch(o){case 1:var s=a.limit-a.cursor;if(!a.out_grouping_b(v,97,121))break i;if(a.cursor>a.limit_backward)break i;if(a.cursor=a.limit-s,a.bra=a.cursor,a.slice_from("ie"))break;return;case 2:if(a.cursor>a.limit_backward)break i}}break r}a.cursor=a.limit-r;var c=a.limit-a.cursor;if(!a.go_out_grouping_b(v,97,121))return;if(a.cursor--,a.cursor=a.limit-c,!a.slice_del())return;a.ket=a.cursor,a.bra=a.cursor;var o,c=a.limit-a.cursor;switch(o=a.find_among_b(f)){case 1:return a.slice_from("e");case 2:var u=a.limit-a.cursor;if(a.in_grouping_b(d,97,111)&&!(a.cursor>a.limit_backward))return;a.cursor=a.limit-u;break;case 3:return a.cursor!=h||(u=a.limit-a.cursor,q()&&(a.cursor=a.limit-u,a.slice_from("e")))}if(a.cursor=a.limit-c,a.ket=a.cursor,a.cursor<=a.limit_backward)return;if(a.cursor--,a.bra=a.cursor,!a.slice_del())return}})(),a.cursor=a.limit-r,a.limit-a.cursor),r=(a.ket=a.cursor,e=a.limit-a.cursor,(a.eq_s_b("y")||(a.cursor=a.limit-e,a.eq_s_b("Y")))&&(a.bra=a.cursor,a.out_grouping_b(v,97,121))&&a.cursor>a.limit_backward&&a.slice_from("i"),a.cursor=a.limit-i,a.limit-a.cursor),e=((()=>{var r;if(a.ket=a.cursor,0!=(r=a.find_among_b(_))&&(a.bra=a.cursor,z()))switch(r){case 1:if(a.slice_from("tion"))break;return;case 2:if(a.slice_from("ence"))break;return;case 3:if(a.slice_from("ance"))break;return;case 4:if(a.slice_from("able"))break;return;case 5:if(a.slice_from("ent"))break;return;case 6:if(a.slice_from("ize"))break;return;case 7:if(a.slice_from("ate"))break;return;case 8:if(a.slice_from("al"))break;return;case 9:if(a.slice_from("ful"))break;return;case 10:if(a.slice_from("ous"))break;return;case 11:if(a.slice_from("ive"))break;return;case 12:if(a.slice_from("ble"))break;return;case 13:if(a.slice_from("og"))break;return;case 14:if(!a.eq_s_b("l"))return;if(a.slice_from("og"))break;return;case 15:if(a.slice_from("less"))break;return;case 16:if(!a.in_grouping_b(w,99,116))return;if(a.slice_del())break}})(),a.cursor=a.limit-r,a.limit-a.cursor),i=((()=>{var r;if(a.ket=a.cursor,0!=(r=a.find_among_b(m))&&(a.bra=a.cursor,z()))switch(r){case 1:if(a.slice_from("tion"))break;return;case 2:if(a.slice_from("ate"))break;return;case 3:if(a.slice_from("al"))break;return;case 4:if(a.slice_from("ic"))break;return;case 5:if(a.slice_del())break;return;case 6:if(!Y())return;if(a.slice_del())break}})(),a.cursor=a.limit-e,a.limit-a.cursor),r=((()=>{var r;if(a.ket=a.cursor,0!=(r=a.find_among_b(b))&&(a.bra=a.cursor,Y()))switch(r){case 1:if(a.slice_del())break;return;case 2:var i=a.limit-a.cursor;if(!a.eq_s_b("s")&&(a.cursor=a.limit-i,!a.eq_s_b("t")))return;if(a.slice_del())break}})(),a.cursor=a.limit-i,a.limit-a.cursor),e=((()=>{var r;if(a.ket=a.cursor,0!=(r=a.find_among_b(k)))switch(a.bra=a.cursor,r){case 1:if(!Y()){if(!z())return;var i=a.limit-a.cursor;if(q())return;a.cursor=a.limit-i}if(a.slice_del())break;return;case 2:if(!Y())return;if(!a.eq_s_b("l"))return;if(a.slice_del())break}})(),a.cursor=a.limit-r,a.cursor=a.limit_backward,a.cursor);(()=>{if(p)for(;;){var r=a.cursor;r:{for(;;){var i=a.cursor;if(a.bra=a.cursor,a.eq_s("Y")){a.ket=a.cursor,a.cursor=i;break}if(a.cursor=i,a.cursor>=a.limit)break r;a.cursor++}if(a.slice_from("y"))continue;return}a.cursor=r;break}})(),a.cursor=e}}return!0},this.stemWord=function(r){return a.setCurrent(r),this.stem(),a.getCurrent()}}; +window.Stemmer = EnglishStemmer; diff --git a/docs/_source/_build/html/_static/minus.png b/docs/_source/_build/html/_static/minus.png new file mode 100644 index 0000000..d96755f Binary files /dev/null and b/docs/_source/_build/html/_static/minus.png differ diff --git a/docs/_source/_build/html/_static/plus.png b/docs/_source/_build/html/_static/plus.png new file mode 100644 index 0000000..7107cec Binary files /dev/null and b/docs/_source/_build/html/_static/plus.png differ diff --git a/docs/_source/_build/html/_static/pygments.css b/docs/_source/_build/html/_static/pygments.css new file mode 100644 index 0000000..9d1083b --- /dev/null +++ b/docs/_source/_build/html/_static/pygments.css @@ -0,0 +1,250 @@ +.highlight pre { line-height: 125%; } +.highlight td.linenos .normal { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; } +.highlight span.linenos { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; } +.highlight td.linenos .special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } +.highlight span.linenos.special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } +.highlight .hll { background-color: #fdf2e2 } +.highlight { background: #f2f2f2; color: #1E1E1E } +.highlight .c { color: #515151 } /* Comment */ +.highlight .err { color: #D71835 } /* Error */ +.highlight .k { color: #8045E5 } /* Keyword */ +.highlight .l { color: #7F4707 } /* Literal */ +.highlight .n { color: #1E1E1E } /* Name */ +.highlight .o { color: #163 } /* Operator */ +.highlight .p { color: #1E1E1E } /* Punctuation */ +.highlight .ch { color: #515151 } /* Comment.Hashbang */ +.highlight .cm { color: #515151 } /* Comment.Multiline */ +.highlight .cp { color: #515151 } /* Comment.Preproc */ +.highlight .cpf { color: #515151 } /* Comment.PreprocFile */ +.highlight .c1 { color: #515151 } /* Comment.Single */ +.highlight .cs { color: #515151 } /* Comment.Special */ +.highlight .gd { color: #00749C } /* Generic.Deleted */ +.highlight .ge { font-style: italic } /* Generic.Emph */ +.highlight .gh { color: #00749C } /* Generic.Heading */ +.highlight .gs { font-weight: bold } /* Generic.Strong */ +.highlight .gu { color: #00749C } /* Generic.Subheading */ +.highlight .kc { color: #8045E5 } /* Keyword.Constant */ +.highlight .kd { color: #8045E5 } /* Keyword.Declaration */ +.highlight .kn { color: #8045E5 } /* Keyword.Namespace */ +.highlight .kp { color: #8045E5 } /* Keyword.Pseudo */ +.highlight .kr { color: #8045E5 } /* Keyword.Reserved */ +.highlight .kt { color: #7F4707 } /* Keyword.Type */ +.highlight .ld { color: #7F4707 } /* Literal.Date */ +.highlight .m { color: #7F4707 } /* Literal.Number */ +.highlight .s { color: #163 } /* Literal.String */ +.highlight .na { color: #7F4707 } /* Name.Attribute */ +.highlight .nb { color: #7F4707 } /* Name.Builtin */ +.highlight .nc { color: #00749C } /* Name.Class */ +.highlight .no { color: #00749C } /* Name.Constant */ +.highlight .nd { color: #7F4707 } /* Name.Decorator */ +.highlight .ni { color: #163 } /* Name.Entity */ +.highlight .ne { color: #8045E5 } /* Name.Exception */ +.highlight .nf { color: #00749C } /* Name.Function */ +.highlight .nl { color: #7F4707 } /* Name.Label */ +.highlight .nn { color: #1E1E1E } /* Name.Namespace */ +.highlight .nx { color: #1E1E1E } /* Name.Other */ +.highlight .py { color: #00749C } /* Name.Property */ +.highlight .nt { color: #00749C } /* Name.Tag */ +.highlight .nv { color: #D71835 } /* Name.Variable */ +.highlight .ow { color: #8045E5 } /* Operator.Word */ +.highlight .pm { color: #1E1E1E } /* Punctuation.Marker */ +.highlight .w { color: #1E1E1E } /* Text.Whitespace */ +.highlight .mb { color: #7F4707 } /* Literal.Number.Bin */ +.highlight .mf { color: #7F4707 } /* Literal.Number.Float */ +.highlight .mh { color: #7F4707 } /* Literal.Number.Hex */ +.highlight .mi { color: #7F4707 } /* Literal.Number.Integer */ +.highlight .mo { color: #7F4707 } /* Literal.Number.Oct */ +.highlight .sa { color: #163 } /* Literal.String.Affix */ +.highlight .sb { color: #163 } /* Literal.String.Backtick */ +.highlight .sc { color: #163 } /* Literal.String.Char */ +.highlight .dl { color: #163 } /* Literal.String.Delimiter */ +.highlight .sd { color: #163 } /* Literal.String.Doc */ +.highlight .s2 { color: #163 } /* Literal.String.Double */ +.highlight .se { color: #163 } /* Literal.String.Escape */ +.highlight .sh { color: #163 } /* Literal.String.Heredoc */ +.highlight .si { color: #163 } /* Literal.String.Interpol */ +.highlight .sx { color: #163 } /* Literal.String.Other */ +.highlight .sr { color: #D71835 } /* Literal.String.Regex */ +.highlight .s1 { color: #163 } /* Literal.String.Single */ +.highlight .ss { color: #00749C } /* Literal.String.Symbol */ +.highlight .bp { color: #7F4707 } /* Name.Builtin.Pseudo */ +.highlight .fm { color: #00749C } /* Name.Function.Magic */ +.highlight .vc { color: #D71835 } /* Name.Variable.Class */ +.highlight .vg { color: #D71835 } /* Name.Variable.Global */ +.highlight .vi { color: #D71835 } /* Name.Variable.Instance */ +.highlight .vm { color: #7F4707 } /* Name.Variable.Magic */ +.highlight .il { color: #7F4707 } /* Literal.Number.Integer.Long */ +@media not print { +body[data-theme="dark"] .highlight pre { line-height: 125%; } +body[data-theme="dark"] .highlight td.linenos .normal { color: #aaaaaa; background-color: transparent; padding-left: 5px; padding-right: 5px; } +body[data-theme="dark"] .highlight span.linenos { color: #aaaaaa; background-color: transparent; padding-left: 5px; padding-right: 5px; } +body[data-theme="dark"] .highlight td.linenos .special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } +body[data-theme="dark"] .highlight span.linenos.special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } +body[data-theme="dark"] .highlight .hll { background-color: #404040 } +body[data-theme="dark"] .highlight { background: #202020; color: #D0D0D0 } +body[data-theme="dark"] .highlight .c { color: #ABABAB; font-style: italic } /* Comment */ +body[data-theme="dark"] .highlight .err { color: #A61717; background-color: #E3D2D2 } /* Error */ +body[data-theme="dark"] .highlight .esc { color: #D0D0D0 } /* Escape */ +body[data-theme="dark"] .highlight .g { color: #D0D0D0 } /* Generic */ +body[data-theme="dark"] .highlight .k { color: #6EBF26; font-weight: bold } /* Keyword */ +body[data-theme="dark"] .highlight .l { color: #D0D0D0 } /* Literal */ +body[data-theme="dark"] .highlight .n { color: #D0D0D0 } /* Name */ +body[data-theme="dark"] .highlight .o { color: #D0D0D0 } /* Operator */ +body[data-theme="dark"] .highlight .x { color: #D0D0D0 } /* Other */ +body[data-theme="dark"] .highlight .p { color: #D0D0D0 } /* Punctuation */ +body[data-theme="dark"] .highlight .ch { color: #ABABAB; font-style: italic } /* Comment.Hashbang */ +body[data-theme="dark"] .highlight .cm { color: #ABABAB; font-style: italic } /* Comment.Multiline */ +body[data-theme="dark"] .highlight .cp { color: #FF3A3A; font-weight: bold } /* Comment.Preproc */ +body[data-theme="dark"] .highlight .cpf { color: #ABABAB; font-style: italic } /* Comment.PreprocFile */ +body[data-theme="dark"] .highlight .c1 { color: #ABABAB; font-style: italic } /* Comment.Single */ +body[data-theme="dark"] .highlight .cs { color: #E50808; font-weight: bold; background-color: #520000 } /* Comment.Special */ +body[data-theme="dark"] .highlight .gd { color: #FF3A3A } /* Generic.Deleted */ +body[data-theme="dark"] .highlight .ge { color: #D0D0D0; font-style: italic } /* Generic.Emph */ +body[data-theme="dark"] .highlight .ges { color: #D0D0D0; font-weight: bold; font-style: italic } /* Generic.EmphStrong */ +body[data-theme="dark"] .highlight .gr { color: #FF3A3A } /* Generic.Error */ +body[data-theme="dark"] .highlight .gh { color: #FFF; font-weight: bold } /* Generic.Heading */ +body[data-theme="dark"] .highlight .gi { color: #589819 } /* Generic.Inserted */ +body[data-theme="dark"] .highlight .go { color: #CCC } /* Generic.Output */ +body[data-theme="dark"] .highlight .gp { color: #AAA } /* Generic.Prompt */ +body[data-theme="dark"] .highlight .gs { color: #D0D0D0; font-weight: bold } /* Generic.Strong */ +body[data-theme="dark"] .highlight .gu { color: #FFF; text-decoration: underline } /* Generic.Subheading */ +body[data-theme="dark"] .highlight .gt { color: #FF3A3A } /* Generic.Traceback */ +body[data-theme="dark"] .highlight .kc { color: #6EBF26; font-weight: bold } /* Keyword.Constant */ +body[data-theme="dark"] .highlight .kd { color: #6EBF26; font-weight: bold } /* Keyword.Declaration */ +body[data-theme="dark"] .highlight .kn { color: #6EBF26; font-weight: bold } /* Keyword.Namespace */ +body[data-theme="dark"] .highlight .kp { color: #6EBF26 } /* Keyword.Pseudo */ +body[data-theme="dark"] .highlight .kr { color: #6EBF26; font-weight: bold } /* Keyword.Reserved */ +body[data-theme="dark"] .highlight .kt { color: #6EBF26; font-weight: bold } /* Keyword.Type */ +body[data-theme="dark"] .highlight .ld { color: #D0D0D0 } /* Literal.Date */ +body[data-theme="dark"] .highlight .m { color: #51B2FD } /* Literal.Number */ +body[data-theme="dark"] .highlight .s { color: #ED9D13 } /* Literal.String */ +body[data-theme="dark"] .highlight .na { color: #BBB } /* Name.Attribute */ +body[data-theme="dark"] .highlight .nb { color: #2FBCCD } /* Name.Builtin */ +body[data-theme="dark"] .highlight .nc { color: #71ADFF; text-decoration: underline } /* Name.Class */ +body[data-theme="dark"] .highlight .no { color: #40FFFF } /* Name.Constant */ +body[data-theme="dark"] .highlight .nd { color: #FFA500 } /* Name.Decorator */ +body[data-theme="dark"] .highlight .ni { color: #D0D0D0 } /* Name.Entity */ +body[data-theme="dark"] .highlight .ne { color: #BBB } /* Name.Exception */ +body[data-theme="dark"] .highlight .nf { color: #71ADFF } /* Name.Function */ +body[data-theme="dark"] .highlight .nl { color: #D0D0D0 } /* Name.Label */ +body[data-theme="dark"] .highlight .nn { color: #71ADFF; text-decoration: underline } /* Name.Namespace */ +body[data-theme="dark"] .highlight .nx { color: #D0D0D0 } /* Name.Other */ +body[data-theme="dark"] .highlight .py { color: #D0D0D0 } /* Name.Property */ +body[data-theme="dark"] .highlight .nt { color: #6EBF26; font-weight: bold } /* Name.Tag */ +body[data-theme="dark"] .highlight .nv { color: #40FFFF } /* Name.Variable */ +body[data-theme="dark"] .highlight .ow { color: #6EBF26; font-weight: bold } /* Operator.Word */ +body[data-theme="dark"] .highlight .pm { color: #D0D0D0 } /* Punctuation.Marker */ +body[data-theme="dark"] .highlight .w { color: #666 } /* Text.Whitespace */ +body[data-theme="dark"] .highlight .mb { color: #51B2FD } /* Literal.Number.Bin */ +body[data-theme="dark"] .highlight .mf { color: #51B2FD } /* Literal.Number.Float */ +body[data-theme="dark"] .highlight .mh { color: #51B2FD } /* Literal.Number.Hex */ +body[data-theme="dark"] .highlight .mi { color: #51B2FD } /* Literal.Number.Integer */ +body[data-theme="dark"] .highlight .mo { color: #51B2FD } /* Literal.Number.Oct */ +body[data-theme="dark"] .highlight .sa { color: #ED9D13 } /* Literal.String.Affix */ +body[data-theme="dark"] .highlight .sb { color: #ED9D13 } /* Literal.String.Backtick */ +body[data-theme="dark"] .highlight .sc { color: #ED9D13 } /* Literal.String.Char */ +body[data-theme="dark"] .highlight .dl { color: #ED9D13 } /* Literal.String.Delimiter */ +body[data-theme="dark"] .highlight .sd { color: #ED9D13 } /* Literal.String.Doc */ +body[data-theme="dark"] .highlight .s2 { color: #ED9D13 } /* Literal.String.Double */ +body[data-theme="dark"] .highlight .se { color: #ED9D13 } /* Literal.String.Escape */ +body[data-theme="dark"] .highlight .sh { color: #ED9D13 } /* Literal.String.Heredoc */ +body[data-theme="dark"] .highlight .si { color: #ED9D13 } /* Literal.String.Interpol */ +body[data-theme="dark"] .highlight .sx { color: #FFA500 } /* Literal.String.Other */ +body[data-theme="dark"] .highlight .sr { color: #ED9D13 } /* Literal.String.Regex */ +body[data-theme="dark"] .highlight .s1 { color: #ED9D13 } /* Literal.String.Single */ +body[data-theme="dark"] .highlight .ss { color: #ED9D13 } /* Literal.String.Symbol */ +body[data-theme="dark"] .highlight .bp { color: #2FBCCD } /* Name.Builtin.Pseudo */ +body[data-theme="dark"] .highlight .fm { color: #71ADFF } /* Name.Function.Magic */ +body[data-theme="dark"] .highlight .vc { color: #40FFFF } /* Name.Variable.Class */ +body[data-theme="dark"] .highlight .vg { color: #40FFFF } /* Name.Variable.Global */ +body[data-theme="dark"] .highlight .vi { color: #40FFFF } /* Name.Variable.Instance */ +body[data-theme="dark"] .highlight .vm { color: #40FFFF } /* Name.Variable.Magic */ +body[data-theme="dark"] .highlight .il { color: #51B2FD } /* Literal.Number.Integer.Long */ +@media (prefers-color-scheme: dark) { +body:not([data-theme="light"]) .highlight pre { line-height: 125%; } +body:not([data-theme="light"]) .highlight td.linenos .normal { color: #aaaaaa; background-color: transparent; padding-left: 5px; padding-right: 5px; } +body:not([data-theme="light"]) .highlight span.linenos { color: #aaaaaa; background-color: transparent; padding-left: 5px; padding-right: 5px; } +body:not([data-theme="light"]) .highlight td.linenos .special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } +body:not([data-theme="light"]) .highlight span.linenos.special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } +body:not([data-theme="light"]) .highlight .hll { background-color: #404040 } +body:not([data-theme="light"]) .highlight { background: #202020; color: #D0D0D0 } +body:not([data-theme="light"]) .highlight .c { color: #ABABAB; font-style: italic } /* Comment */ +body:not([data-theme="light"]) .highlight .err { color: #A61717; background-color: #E3D2D2 } /* Error */ +body:not([data-theme="light"]) .highlight .esc { color: #D0D0D0 } /* Escape */ +body:not([data-theme="light"]) .highlight .g { color: #D0D0D0 } /* Generic */ +body:not([data-theme="light"]) .highlight .k { color: #6EBF26; font-weight: bold } /* Keyword */ +body:not([data-theme="light"]) .highlight .l { color: #D0D0D0 } /* Literal */ +body:not([data-theme="light"]) .highlight .n { color: #D0D0D0 } /* Name */ +body:not([data-theme="light"]) .highlight .o { color: #D0D0D0 } /* Operator */ +body:not([data-theme="light"]) .highlight .x { color: #D0D0D0 } /* Other */ +body:not([data-theme="light"]) .highlight .p { color: #D0D0D0 } /* Punctuation */ +body:not([data-theme="light"]) .highlight .ch { color: #ABABAB; font-style: italic } /* Comment.Hashbang */ +body:not([data-theme="light"]) .highlight .cm { color: #ABABAB; font-style: italic } /* Comment.Multiline */ +body:not([data-theme="light"]) .highlight .cp { color: #FF3A3A; font-weight: bold } /* Comment.Preproc */ +body:not([data-theme="light"]) .highlight .cpf { color: #ABABAB; font-style: italic } /* Comment.PreprocFile */ +body:not([data-theme="light"]) .highlight .c1 { color: #ABABAB; font-style: italic } /* Comment.Single */ +body:not([data-theme="light"]) .highlight .cs { color: #E50808; font-weight: bold; background-color: #520000 } /* Comment.Special */ +body:not([data-theme="light"]) .highlight .gd { color: #FF3A3A } /* Generic.Deleted */ +body:not([data-theme="light"]) .highlight .ge { color: #D0D0D0; font-style: italic } /* Generic.Emph */ +body:not([data-theme="light"]) .highlight .ges { color: #D0D0D0; font-weight: bold; font-style: italic } /* Generic.EmphStrong */ +body:not([data-theme="light"]) .highlight .gr { color: #FF3A3A } /* Generic.Error */ +body:not([data-theme="light"]) .highlight .gh { color: #FFF; font-weight: bold } /* Generic.Heading */ +body:not([data-theme="light"]) .highlight .gi { color: #589819 } /* Generic.Inserted */ +body:not([data-theme="light"]) .highlight .go { color: #CCC } /* Generic.Output */ +body:not([data-theme="light"]) .highlight .gp { color: #AAA } /* Generic.Prompt */ +body:not([data-theme="light"]) .highlight .gs { color: #D0D0D0; font-weight: bold } /* Generic.Strong */ +body:not([data-theme="light"]) .highlight .gu { color: #FFF; text-decoration: underline } /* Generic.Subheading */ +body:not([data-theme="light"]) .highlight .gt { color: #FF3A3A } /* Generic.Traceback */ +body:not([data-theme="light"]) .highlight .kc { color: #6EBF26; font-weight: bold } /* Keyword.Constant */ +body:not([data-theme="light"]) .highlight .kd { color: #6EBF26; font-weight: bold } /* Keyword.Declaration */ +body:not([data-theme="light"]) .highlight .kn { color: #6EBF26; font-weight: bold } /* Keyword.Namespace */ +body:not([data-theme="light"]) .highlight .kp { color: #6EBF26 } /* Keyword.Pseudo */ +body:not([data-theme="light"]) .highlight .kr { color: #6EBF26; font-weight: bold } /* Keyword.Reserved */ +body:not([data-theme="light"]) .highlight .kt { color: #6EBF26; font-weight: bold } /* Keyword.Type */ +body:not([data-theme="light"]) .highlight .ld { color: #D0D0D0 } /* Literal.Date */ +body:not([data-theme="light"]) .highlight .m { color: #51B2FD } /* Literal.Number */ +body:not([data-theme="light"]) .highlight .s { color: #ED9D13 } /* Literal.String */ +body:not([data-theme="light"]) .highlight .na { color: #BBB } /* Name.Attribute */ +body:not([data-theme="light"]) .highlight .nb { color: #2FBCCD } /* Name.Builtin */ +body:not([data-theme="light"]) .highlight .nc { color: #71ADFF; text-decoration: underline } /* Name.Class */ +body:not([data-theme="light"]) .highlight .no { color: #40FFFF } /* Name.Constant */ +body:not([data-theme="light"]) .highlight .nd { color: #FFA500 } /* Name.Decorator */ +body:not([data-theme="light"]) .highlight .ni { color: #D0D0D0 } /* Name.Entity */ +body:not([data-theme="light"]) .highlight .ne { color: #BBB } /* Name.Exception */ +body:not([data-theme="light"]) .highlight .nf { color: #71ADFF } /* Name.Function */ +body:not([data-theme="light"]) .highlight .nl { color: #D0D0D0 } /* Name.Label */ +body:not([data-theme="light"]) .highlight .nn { color: #71ADFF; text-decoration: underline } /* Name.Namespace */ +body:not([data-theme="light"]) .highlight .nx { color: #D0D0D0 } /* Name.Other */ +body:not([data-theme="light"]) .highlight .py { color: #D0D0D0 } /* Name.Property */ +body:not([data-theme="light"]) .highlight .nt { color: #6EBF26; font-weight: bold } /* Name.Tag */ +body:not([data-theme="light"]) .highlight .nv { color: #40FFFF } /* Name.Variable */ +body:not([data-theme="light"]) .highlight .ow { color: #6EBF26; font-weight: bold } /* Operator.Word */ +body:not([data-theme="light"]) .highlight .pm { color: #D0D0D0 } /* Punctuation.Marker */ +body:not([data-theme="light"]) .highlight .w { color: #666 } /* Text.Whitespace */ +body:not([data-theme="light"]) .highlight .mb { color: #51B2FD } /* Literal.Number.Bin */ +body:not([data-theme="light"]) .highlight .mf { color: #51B2FD } /* Literal.Number.Float */ +body:not([data-theme="light"]) .highlight .mh { color: #51B2FD } /* Literal.Number.Hex */ +body:not([data-theme="light"]) .highlight .mi { color: #51B2FD } /* Literal.Number.Integer */ +body:not([data-theme="light"]) .highlight .mo { color: #51B2FD } /* Literal.Number.Oct */ +body:not([data-theme="light"]) .highlight .sa { color: #ED9D13 } /* Literal.String.Affix */ +body:not([data-theme="light"]) .highlight .sb { color: #ED9D13 } /* Literal.String.Backtick */ +body:not([data-theme="light"]) .highlight .sc { color: #ED9D13 } /* Literal.String.Char */ +body:not([data-theme="light"]) .highlight .dl { color: #ED9D13 } /* Literal.String.Delimiter */ +body:not([data-theme="light"]) .highlight .sd { color: #ED9D13 } /* Literal.String.Doc */ +body:not([data-theme="light"]) .highlight .s2 { color: #ED9D13 } /* Literal.String.Double */ +body:not([data-theme="light"]) .highlight .se { color: #ED9D13 } /* Literal.String.Escape */ +body:not([data-theme="light"]) .highlight .sh { color: #ED9D13 } /* Literal.String.Heredoc */ +body:not([data-theme="light"]) .highlight .si { color: #ED9D13 } /* Literal.String.Interpol */ +body:not([data-theme="light"]) .highlight .sx { color: #FFA500 } /* Literal.String.Other */ +body:not([data-theme="light"]) .highlight .sr { color: #ED9D13 } /* Literal.String.Regex */ +body:not([data-theme="light"]) .highlight .s1 { color: #ED9D13 } /* Literal.String.Single */ +body:not([data-theme="light"]) .highlight .ss { color: #ED9D13 } /* Literal.String.Symbol */ +body:not([data-theme="light"]) .highlight .bp { color: #2FBCCD } /* Name.Builtin.Pseudo */ +body:not([data-theme="light"]) .highlight .fm { color: #71ADFF } /* Name.Function.Magic */ +body:not([data-theme="light"]) .highlight .vc { color: #40FFFF } /* Name.Variable.Class */ +body:not([data-theme="light"]) .highlight .vg { color: #40FFFF } /* Name.Variable.Global */ +body:not([data-theme="light"]) .highlight .vi { color: #40FFFF } /* Name.Variable.Instance */ +body:not([data-theme="light"]) .highlight .vm { color: #40FFFF } /* Name.Variable.Magic */ +body:not([data-theme="light"]) .highlight .il { color: #51B2FD } /* Literal.Number.Integer.Long */ +} +} \ No newline at end of file diff --git a/docs/_source/_build/html/_static/scripts/furo-extensions.js b/docs/_source/_build/html/_static/scripts/furo-extensions.js new file mode 100644 index 0000000..e69de29 diff --git a/docs/_source/_build/html/_static/scripts/furo.js b/docs/_source/_build/html/_static/scripts/furo.js new file mode 100644 index 0000000..87e1767 --- /dev/null +++ b/docs/_source/_build/html/_static/scripts/furo.js @@ -0,0 +1,3 @@ +/*! For license information please see furo.js.LICENSE.txt */ +(()=>{var t={856:function(t,e,n){var o,r;r=void 0!==n.g?n.g:"undefined"!=typeof window?window:this,o=function(){return function(t){"use strict";var e={navClass:"active",contentClass:"active",nested:!1,nestedClass:"active",offset:0,reflow:!1,events:!0},n=function(t,e,n){if(n.settings.events){var o=new CustomEvent(t,{bubbles:!0,cancelable:!0,detail:n});e.dispatchEvent(o)}},o=function(t){var e=0;if(t.offsetParent)for(;t;)e+=t.offsetTop,t=t.offsetParent;return e>=0?e:0},r=function(t){t&&t.sort(function(t,e){return o(t.content)=Math.max(document.body.scrollHeight,document.documentElement.scrollHeight,document.body.offsetHeight,document.documentElement.offsetHeight,document.body.clientHeight,document.documentElement.clientHeight)},l=function(t,e){var n=t[t.length-1];if(function(t,e){return!(!s()||!c(t.content,e,!0))}(n,e))return n;for(var o=t.length-1;o>=0;o--)if(c(t[o].content,e))return t[o]},a=function(t,e){if(e.nested&&t.parentNode){var n=t.parentNode.closest("li");n&&(n.classList.remove(e.nestedClass),a(n,e))}},i=function(t,e){if(t){var o=t.nav.closest("li");o&&(o.classList.remove(e.navClass),t.content.classList.remove(e.contentClass),a(o,e),n("gumshoeDeactivate",o,{link:t.nav,content:t.content,settings:e}))}},u=function(t,e){if(e.nested){var n=t.parentNode.closest("li");n&&(n.classList.add(e.nestedClass),u(n,e))}};return function(o,c){var s,a,d,f,m,v={setup:function(){s=document.querySelectorAll(o),a=[],Array.prototype.forEach.call(s,function(t){var e=document.getElementById(decodeURIComponent(t.hash.substr(1)));e&&a.push({nav:t,content:e})}),r(a)},detect:function(){var t=l(a,m);t?d&&t.content===d.content||(i(d,m),function(t,e){if(t){var o=t.nav.closest("li");o&&(o.classList.add(e.navClass),t.content.classList.add(e.contentClass),u(o,e),n("gumshoeActivate",o,{link:t.nav,content:t.content,settings:e}))}}(t,m),d=t):d&&(i(d,m),d=null)}},h=function(e){f&&t.cancelAnimationFrame(f),f=t.requestAnimationFrame(v.detect)},g=function(e){f&&t.cancelAnimationFrame(f),f=t.requestAnimationFrame(function(){r(a),v.detect()})};return v.destroy=function(){d&&i(d,m),t.removeEventListener("scroll",h,!1),m.reflow&&t.removeEventListener("resize",g,!1),a=null,s=null,d=null,f=null,m=null},m=function(){var t={};return Array.prototype.forEach.call(arguments,function(e){for(var n in e){if(!e.hasOwnProperty(n))return;t[n]=e[n]}}),t}(e,c||{}),v.setup(),v.detect(),t.addEventListener("scroll",h,!1),m.reflow&&t.addEventListener("resize",g,!1),v}}(r)}.apply(e,[]),void 0===o||(t.exports=o)}},e={};function n(o){var r=e[o];if(void 0!==r)return r.exports;var c=e[o]={exports:{}};return t[o].call(c.exports,c,c.exports,n),c.exports}n.n=t=>{var e=t&&t.__esModule?()=>t.default:()=>t;return n.d(e,{a:e}),e},n.d=(t,e)=>{for(var o in e)n.o(e,o)&&!n.o(t,o)&&Object.defineProperty(t,o,{enumerable:!0,get:e[o]})},n.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(t){if("object"==typeof window)return window}}(),n.o=(t,e)=>Object.prototype.hasOwnProperty.call(t,e),(()=>{"use strict";var t=n(856),e=n.n(t),o=null,r=null,c=document.documentElement.scrollTop;function s(){const t=localStorage.getItem("theme")||"auto";var e;"light"!==(e=window.matchMedia("(prefers-color-scheme: dark)").matches?"auto"===t?"light":"light"==t?"dark":"auto":"auto"===t?"dark":"dark"==t?"light":"auto")&&"dark"!==e&&"auto"!==e&&(console.error(`Got invalid theme mode: ${e}. Resetting to auto.`),e="auto"),document.body.dataset.theme=e,localStorage.setItem("theme",e),console.log(`Changed to ${e} mode.`)}function l(){!function(){const t=document.getElementsByClassName("theme-toggle");Array.from(t).forEach(t=>{t.addEventListener("click",s)})}(),function(){let t=0,e=!1;window.addEventListener("scroll",function(n){t=window.scrollY,e||(window.requestAnimationFrame(function(){var n;(function(t){t>0?r.classList.add("scrolled"):r.classList.remove("scrolled")})(n=t),function(t){t<64?document.documentElement.classList.remove("show-back-to-top"):tc&&document.documentElement.classList.remove("show-back-to-top"),c=t}(n),function(t){null!==o&&(0==t?o.scrollTo(0,0):Math.ceil(t)>=Math.floor(document.documentElement.scrollHeight-window.innerHeight)?o.scrollTo(0,o.scrollHeight):document.querySelector(".scroll-current"))}(n),e=!1}),e=!0)}),window.scroll()}(),null!==o&&new(e())(".toc-tree a",{reflow:!0,recursive:!0,navClass:"scroll-current",offset:()=>{let t=parseFloat(getComputedStyle(document.documentElement).fontSize);const e=r.getBoundingClientRect();return e.top+e.height+2.5*t+1}})}document.addEventListener("DOMContentLoaded",function(){document.body.parentNode.classList.remove("no-js"),r=document.querySelector("header"),o=document.querySelector(".toc-scroll"),l()})})()})(); +//# sourceMappingURL=furo.js.map \ No newline at end of file diff --git a/docs/_source/_build/html/_static/scripts/furo.js.LICENSE.txt b/docs/_source/_build/html/_static/scripts/furo.js.LICENSE.txt new file mode 100644 index 0000000..1632189 --- /dev/null +++ b/docs/_source/_build/html/_static/scripts/furo.js.LICENSE.txt @@ -0,0 +1,7 @@ +/*! + * gumshoejs v5.1.2 (patched by @pradyunsg) + * A simple, framework-agnostic scrollspy script. + * (c) 2019 Chris Ferdinandi + * MIT License + * http://github.com/cferdinandi/gumshoe + */ diff --git a/docs/_source/_build/html/_static/scripts/furo.js.map b/docs/_source/_build/html/_static/scripts/furo.js.map new file mode 100644 index 0000000..3b316f3 --- /dev/null +++ b/docs/_source/_build/html/_static/scripts/furo.js.map @@ -0,0 +1 @@ +{"version":3,"file":"scripts/furo.js","mappings":";iCAAA,MAQWA,SAWS,IAAX,EAAAC,EACH,EAAAA,EACkB,oBAAXC,OACLA,OACAC,KAbO,EAAF,WACP,OAaJ,SAAUD,GACR,aAMA,IAAIE,EAAW,CAEbC,SAAU,SACVC,aAAc,SAGdC,QAAQ,EACRC,YAAa,SAGbC,OAAQ,EACRC,QAAQ,EAGRC,QAAQ,GA6BNC,EAAY,SAAUC,EAAMC,EAAMC,GAEpC,GAAKA,EAAOC,SAASL,OAArB,CAGA,IAAIM,EAAQ,IAAIC,YAAYL,EAAM,CAChCM,SAAS,EACTC,YAAY,EACZL,OAAQA,IAIVD,EAAKO,cAAcJ,EAVgB,CAWrC,EAOIK,EAAe,SAAUR,GAC3B,IAAIS,EAAW,EACf,GAAIT,EAAKU,aACP,KAAOV,GACLS,GAAYT,EAAKW,UACjBX,EAAOA,EAAKU,aAGhB,OAAOD,GAAY,EAAIA,EAAW,CACpC,EAMIG,EAAe,SAAUC,GACvBA,GACFA,EAASC,KAAK,SAAUC,EAAOC,GAG7B,OAFcR,EAAaO,EAAME,SACnBT,EAAaQ,EAAMC,UACF,EACxB,CACT,EAEJ,EAwCIC,EAAW,SAAUlB,EAAME,EAAUiB,GACvC,IAAIC,EAASpB,EAAKqB,wBACd1B,EAnCU,SAAUO,GAExB,MAA+B,mBAApBA,EAASP,OACX2B,WAAWpB,EAASP,UAItB2B,WAAWpB,EAASP,OAC7B,CA2Be4B,CAAUrB,GACvB,OAAIiB,EAEAK,SAASJ,EAAOD,OAAQ,KACvB/B,EAAOqC,aAAeC,SAASC,gBAAgBC,cAG7CJ,SAASJ,EAAOS,IAAK,KAAOlC,CACrC,EAMImC,EAAa,WACf,OACEC,KAAKC,KAAK5C,EAAOqC,YAAcrC,EAAO6C,cAnCjCF,KAAKG,IACVR,SAASS,KAAKC,aACdV,SAASC,gBAAgBS,aACzBV,SAASS,KAAKE,aACdX,SAASC,gBAAgBU,aACzBX,SAASS,KAAKP,aACdF,SAASC,gBAAgBC,aAkC7B,EAmBIU,EAAY,SAAUzB,EAAUX,GAClC,IAAIqC,EAAO1B,EAASA,EAAS2B,OAAS,GACtC,GAbgB,SAAUC,EAAMvC,GAChC,SAAI4B,MAAgBZ,EAASuB,EAAKxB,QAASf,GAAU,GAEvD,CAUMwC,CAAYH,EAAMrC,GAAW,OAAOqC,EACxC,IAAK,IAAII,EAAI9B,EAAS2B,OAAS,EAAGG,GAAK,EAAGA,IACxC,GAAIzB,EAASL,EAAS8B,GAAG1B,QAASf,GAAW,OAAOW,EAAS8B,EAEjE,EAOIC,EAAmB,SAAUC,EAAK3C,GAEpC,GAAKA,EAAST,QAAWoD,EAAIC,WAA7B,CAGA,IAAIC,EAAKF,EAAIC,WAAWE,QAAQ,MAC3BD,IAGLA,EAAGE,UAAUC,OAAOhD,EAASR,aAG7BkD,EAAiBG,EAAI7C,GAV0B,CAWjD,EAOIiD,EAAa,SAAUC,EAAOlD,GAEhC,GAAKkD,EAAL,CAGA,IAAIL,EAAKK,EAAMP,IAAIG,QAAQ,MACtBD,IAGLA,EAAGE,UAAUC,OAAOhD,EAASX,UAC7B6D,EAAMnC,QAAQgC,UAAUC,OAAOhD,EAASV,cAGxCoD,EAAiBG,EAAI7C,GAGrBJ,EAAU,oBAAqBiD,EAAI,CACjCM,KAAMD,EAAMP,IACZ5B,QAASmC,EAAMnC,QACff,SAAUA,IAjBM,CAmBpB,EAOIoD,EAAiB,SAAUT,EAAK3C,GAElC,GAAKA,EAAST,OAAd,CAGA,IAAIsD,EAAKF,EAAIC,WAAWE,QAAQ,MAC3BD,IAGLA,EAAGE,UAAUM,IAAIrD,EAASR,aAG1B4D,EAAeP,EAAI7C,GAVS,CAW9B,EA6LA,OA1JkB,SAAUsD,EAAUC,GAKpC,IACIC,EAAU7C,EAAU8C,EAASC,EAAS1D,EADtC2D,EAAa,CAUjBA,MAAmB,WAEjBH,EAAWhC,SAASoC,iBAAiBN,GAGrC3C,EAAW,GAGXkD,MAAMC,UAAUC,QAAQC,KAAKR,EAAU,SAAUjB,GAE/C,IAAIxB,EAAUS,SAASyC,eACrBC,mBAAmB3B,EAAK4B,KAAKC,OAAO,KAEjCrD,GAGLJ,EAAS0D,KAAK,CACZ1B,IAAKJ,EACLxB,QAASA,GAEb,GAGAL,EAAaC,EACf,EAKAgD,OAAoB,WAElB,IAAIW,EAASlC,EAAUzB,EAAUX,GAG5BsE,EASDb,GAAWa,EAAOvD,UAAY0C,EAAQ1C,UAG1CkC,EAAWQ,EAASzD,GAzFT,SAAUkD,EAAOlD,GAE9B,GAAKkD,EAAL,CAGA,IAAIL,EAAKK,EAAMP,IAAIG,QAAQ,MACtBD,IAGLA,EAAGE,UAAUM,IAAIrD,EAASX,UAC1B6D,EAAMnC,QAAQgC,UAAUM,IAAIrD,EAASV,cAGrC8D,EAAeP,EAAI7C,GAGnBJ,EAAU,kBAAmBiD,EAAI,CAC/BM,KAAMD,EAAMP,IACZ5B,QAASmC,EAAMnC,QACff,SAAUA,IAjBM,CAmBpB,CAqEIuE,CAASD,EAAQtE,GAGjByD,EAAUa,GAfJb,IACFR,EAAWQ,EAASzD,GACpByD,EAAU,KAchB,GAMIe,EAAgB,SAAUvE,GAExByD,GACFxE,EAAOuF,qBAAqBf,GAI9BA,EAAUxE,EAAOwF,sBAAsBf,EAAWgB,OACpD,EAMIC,EAAgB,SAAU3E,GAExByD,GACFxE,EAAOuF,qBAAqBf,GAI9BA,EAAUxE,EAAOwF,sBAAsB,WACrChE,EAAaC,GACbgD,EAAWgB,QACb,EACF,EAkDA,OA7CAhB,EAAWkB,QAAU,WAEfpB,GACFR,EAAWQ,EAASzD,GAItBd,EAAO4F,oBAAoB,SAAUN,GAAe,GAChDxE,EAASN,QACXR,EAAO4F,oBAAoB,SAAUF,GAAe,GAItDjE,EAAW,KACX6C,EAAW,KACXC,EAAU,KACVC,EAAU,KACV1D,EAAW,IACb,EAOEA,EA3XS,WACX,IAAI+E,EAAS,CAAC,EAOd,OANAlB,MAAMC,UAAUC,QAAQC,KAAKgB,UAAW,SAAUC,GAChD,IAAK,IAAIC,KAAOD,EAAK,CACnB,IAAKA,EAAIE,eAAeD,GAAM,OAC9BH,EAAOG,GAAOD,EAAIC,EACpB,CACF,GACOH,CACT,CAkXeK,CAAOhG,EAAUmE,GAAW,CAAC,GAGxCI,EAAW0B,QAGX1B,EAAWgB,SAGXzF,EAAOoG,iBAAiB,SAAUd,GAAe,GAC7CxE,EAASN,QACXR,EAAOoG,iBAAiB,SAAUV,GAAe,GAS9CjB,CACT,CAOF,CArcW4B,CAAQvG,EAChB,UAFM,SAEN,oB,GCXDwG,EAA2B,CAAC,EAGhC,SAASC,EAAoBC,GAE5B,IAAIC,EAAeH,EAAyBE,GAC5C,QAAqBE,IAAjBD,EACH,OAAOA,EAAaE,QAGrB,IAAIC,EAASN,EAAyBE,GAAY,CAGjDG,QAAS,CAAC,GAOX,OAHAE,EAAoBL,GAAU1B,KAAK8B,EAAOD,QAASC,EAAQA,EAAOD,QAASJ,GAGpEK,EAAOD,OACf,CCrBAJ,EAAoBO,EAAKF,IACxB,IAAIG,EAASH,GAAUA,EAAOI,WAC7B,IAAOJ,EAAiB,QACxB,IAAM,EAEP,OADAL,EAAoBU,EAAEF,EAAQ,CAAEG,EAAGH,IAC5BA,GCLRR,EAAoBU,EAAI,CAACN,EAASQ,KACjC,IAAI,IAAInB,KAAOmB,EACXZ,EAAoBa,EAAED,EAAYnB,KAASO,EAAoBa,EAAET,EAASX,IAC5EqB,OAAOC,eAAeX,EAASX,EAAK,CAAEuB,YAAY,EAAMC,IAAKL,EAAWnB,MCJ3EO,EAAoBxG,EAAI,WACvB,GAA0B,iBAAf0H,WAAyB,OAAOA,WAC3C,IACC,OAAOxH,MAAQ,IAAIyH,SAAS,cAAb,EAChB,CAAE,MAAOC,GACR,GAAsB,iBAAX3H,OAAqB,OAAOA,MACxC,CACA,CAPuB,GCAxBuG,EAAoBa,EAAI,CAACrB,EAAK6B,IAAUP,OAAOzC,UAAUqB,eAAenB,KAAKiB,EAAK6B,G,yCCK9EC,EAAY,KACZC,EAAS,KACTC,EAAgBzF,SAASC,gBAAgByF,UA4E7C,SAASC,IACP,MAAMC,EAAeC,aAAaC,QAAQ,UAAY,OAZxD,IAAkBC,EACH,WADGA,EAaIrI,OAAOsI,WAAW,gCAAgCC,QAI/C,SAAjBL,EACO,QACgB,SAAhBA,EACA,OAEA,OAIU,SAAjBA,EACO,OACgB,QAAhBA,EACA,QAEA,SA9BoB,SAATG,GAA4B,SAATA,IACzCG,QAAQC,MAAM,2BAA2BJ,yBACzCA,EAAO,QAGT/F,SAASS,KAAK2F,QAAQC,MAAQN,EAC9BF,aAAaS,QAAQ,QAASP,GAC9BG,QAAQK,IAAI,cAAcR,UA0B5B,CAmDA,SAASlC,KART,WAEE,MAAM2C,EAAUxG,SAASyG,uBAAuB,gBAChDpE,MAAMqE,KAAKF,GAASjE,QAASoE,IAC3BA,EAAI7C,iBAAiB,QAAS6B,IAElC,CAGEiB,GA/CF,WAEE,IAAIC,EAA6B,EAC7BC,GAAU,EAEdpJ,OAAOoG,iBAAiB,SAAU,SAAUuB,GAC1CwB,EAA6BnJ,OAAOqJ,QAE/BD,IACHpJ,OAAOwF,sBAAsB,WAzDnC,IAAuB8D,GArDvB,SAAgCA,GAC1BA,EAAY,EACdxB,EAAOjE,UAAUM,IAAI,YAErB2D,EAAOjE,UAAUC,OAAO,WAE5B,EAgDEyF,CADqBD,EA0DDH,GAvGtB,SAAmCG,GAC7BA,EAXmB,GAYrBhH,SAASC,gBAAgBsB,UAAUC,OAAO,oBAEtCwF,EAAYvB,EACdzF,SAASC,gBAAgBsB,UAAUM,IAAI,oBAC9BmF,EAAYvB,GACrBzF,SAASC,gBAAgBsB,UAAUC,OAAO,oBAG9CiE,EAAgBuB,CAClB,CAoCEE,CAA0BF,GAlC5B,SAA6BA,GACT,OAAdzB,IAKa,GAAbyB,EACFzB,EAAU4B,SAAS,EAAG,GAGtB9G,KAAKC,KAAK0G,IACV3G,KAAK+G,MAAMpH,SAASC,gBAAgBS,aAAehD,OAAOqC,aAE1DwF,EAAU4B,SAAS,EAAG5B,EAAU7E,cAGhBV,SAASqH,cAAc,mBAc3C,CAKEC,CAAoBN,GAwDdF,GAAU,CACZ,GAEAA,GAAU,EAEd,GACApJ,OAAO6J,QACT,CA8BEC,GA3BkB,OAAdjC,GAKJ,IAAI,IAAJ,CAAY,cAAe,CACzBrH,QAAQ,EACRuJ,WAAW,EACX5J,SAAU,iBACVI,OAAQ,KACN,IAAIyJ,EAAM9H,WAAW+H,iBAAiB3H,SAASC,iBAAiB2H,UAChE,MAAMC,EAAarC,EAAO7F,wBAC1B,OAAOkI,EAAW1H,IAAM0H,EAAWC,OAAS,IAAMJ,EAAM,IAiB9D,CAcA1H,SAAS8D,iBAAiB,mBAT1B,WACE9D,SAASS,KAAKW,WAAWG,UAAUC,OAAO,SAE1CgE,EAASxF,SAASqH,cAAc,UAChC9B,EAAYvF,SAASqH,cAAc,eAEnCxD,GACF,E","sources":["webpack:///./src/furo/assets/scripts/gumshoe-patched.js","webpack:///webpack/bootstrap","webpack:///webpack/runtime/compat get default export","webpack:///webpack/runtime/define property getters","webpack:///webpack/runtime/global","webpack:///webpack/runtime/hasOwnProperty shorthand","webpack:///./src/furo/assets/scripts/furo.js"],"sourcesContent":["/*!\n * gumshoejs v5.1.2 (patched by @pradyunsg)\n * A simple, framework-agnostic scrollspy script.\n * (c) 2019 Chris Ferdinandi\n * MIT License\n * http://github.com/cferdinandi/gumshoe\n */\n\n(function (root, factory) {\n if (typeof define === \"function\" && define.amd) {\n define([], function () {\n return factory(root);\n });\n } else if (typeof exports === \"object\") {\n module.exports = factory(root);\n } else {\n root.Gumshoe = factory(root);\n }\n})(\n typeof global !== \"undefined\"\n ? global\n : typeof window !== \"undefined\"\n ? window\n : this,\n function (window) {\n \"use strict\";\n\n //\n // Defaults\n //\n\n var defaults = {\n // Active classes\n navClass: \"active\",\n contentClass: \"active\",\n\n // Nested navigation\n nested: false,\n nestedClass: \"active\",\n\n // Offset & reflow\n offset: 0,\n reflow: false,\n\n // Event support\n events: true,\n };\n\n //\n // Methods\n //\n\n /**\n * Merge two or more objects together.\n * @param {Object} objects The objects to merge together\n * @returns {Object} Merged values of defaults and options\n */\n var extend = function () {\n var merged = {};\n Array.prototype.forEach.call(arguments, function (obj) {\n for (var key in obj) {\n if (!obj.hasOwnProperty(key)) return;\n merged[key] = obj[key];\n }\n });\n return merged;\n };\n\n /**\n * Emit a custom event\n * @param {String} type The event type\n * @param {Node} elem The element to attach the event to\n * @param {Object} detail Any details to pass along with the event\n */\n var emitEvent = function (type, elem, detail) {\n // Make sure events are enabled\n if (!detail.settings.events) return;\n\n // Create a new event\n var event = new CustomEvent(type, {\n bubbles: true,\n cancelable: true,\n detail: detail,\n });\n\n // Dispatch the event\n elem.dispatchEvent(event);\n };\n\n /**\n * Get an element's distance from the top of the Document.\n * @param {Node} elem The element\n * @return {Number} Distance from the top in pixels\n */\n var getOffsetTop = function (elem) {\n var location = 0;\n if (elem.offsetParent) {\n while (elem) {\n location += elem.offsetTop;\n elem = elem.offsetParent;\n }\n }\n return location >= 0 ? location : 0;\n };\n\n /**\n * Sort content from first to last in the DOM\n * @param {Array} contents The content areas\n */\n var sortContents = function (contents) {\n if (contents) {\n contents.sort(function (item1, item2) {\n var offset1 = getOffsetTop(item1.content);\n var offset2 = getOffsetTop(item2.content);\n if (offset1 < offset2) return -1;\n return 1;\n });\n }\n };\n\n /**\n * Get the offset to use for calculating position\n * @param {Object} settings The settings for this instantiation\n * @return {Float} The number of pixels to offset the calculations\n */\n var getOffset = function (settings) {\n // if the offset is a function run it\n if (typeof settings.offset === \"function\") {\n return parseFloat(settings.offset());\n }\n\n // Otherwise, return it as-is\n return parseFloat(settings.offset);\n };\n\n /**\n * Get the document element's height\n * @private\n * @returns {Number}\n */\n var getDocumentHeight = function () {\n return Math.max(\n document.body.scrollHeight,\n document.documentElement.scrollHeight,\n document.body.offsetHeight,\n document.documentElement.offsetHeight,\n document.body.clientHeight,\n document.documentElement.clientHeight,\n );\n };\n\n /**\n * Determine if an element is in view\n * @param {Node} elem The element\n * @param {Object} settings The settings for this instantiation\n * @param {Boolean} bottom If true, check if element is above bottom of viewport instead\n * @return {Boolean} Returns true if element is in the viewport\n */\n var isInView = function (elem, settings, bottom) {\n var bounds = elem.getBoundingClientRect();\n var offset = getOffset(settings);\n if (bottom) {\n return (\n parseInt(bounds.bottom, 10) <\n (window.innerHeight || document.documentElement.clientHeight)\n );\n }\n return parseInt(bounds.top, 10) <= offset;\n };\n\n /**\n * Check if at the bottom of the viewport\n * @return {Boolean} If true, page is at the bottom of the viewport\n */\n var isAtBottom = function () {\n if (\n Math.ceil(window.innerHeight + window.pageYOffset) >=\n getDocumentHeight()\n )\n return true;\n return false;\n };\n\n /**\n * Check if the last item should be used (even if not at the top of the page)\n * @param {Object} item The last item\n * @param {Object} settings The settings for this instantiation\n * @return {Boolean} If true, use the last item\n */\n var useLastItem = function (item, settings) {\n if (isAtBottom() && isInView(item.content, settings, true)) return true;\n return false;\n };\n\n /**\n * Get the active content\n * @param {Array} contents The content areas\n * @param {Object} settings The settings for this instantiation\n * @return {Object} The content area and matching navigation link\n */\n var getActive = function (contents, settings) {\n var last = contents[contents.length - 1];\n if (useLastItem(last, settings)) return last;\n for (var i = contents.length - 1; i >= 0; i--) {\n if (isInView(contents[i].content, settings)) return contents[i];\n }\n };\n\n /**\n * Deactivate parent navs in a nested navigation\n * @param {Node} nav The starting navigation element\n * @param {Object} settings The settings for this instantiation\n */\n var deactivateNested = function (nav, settings) {\n // If nesting isn't activated, bail\n if (!settings.nested || !nav.parentNode) return;\n\n // Get the parent navigation\n var li = nav.parentNode.closest(\"li\");\n if (!li) return;\n\n // Remove the active class\n li.classList.remove(settings.nestedClass);\n\n // Apply recursively to any parent navigation elements\n deactivateNested(li, settings);\n };\n\n /**\n * Deactivate a nav and content area\n * @param {Object} items The nav item and content to deactivate\n * @param {Object} settings The settings for this instantiation\n */\n var deactivate = function (items, settings) {\n // Make sure there are items to deactivate\n if (!items) return;\n\n // Get the parent list item\n var li = items.nav.closest(\"li\");\n if (!li) return;\n\n // Remove the active class from the nav and content\n li.classList.remove(settings.navClass);\n items.content.classList.remove(settings.contentClass);\n\n // Deactivate any parent navs in a nested navigation\n deactivateNested(li, settings);\n\n // Emit a custom event\n emitEvent(\"gumshoeDeactivate\", li, {\n link: items.nav,\n content: items.content,\n settings: settings,\n });\n };\n\n /**\n * Activate parent navs in a nested navigation\n * @param {Node} nav The starting navigation element\n * @param {Object} settings The settings for this instantiation\n */\n var activateNested = function (nav, settings) {\n // If nesting isn't activated, bail\n if (!settings.nested) return;\n\n // Get the parent navigation\n var li = nav.parentNode.closest(\"li\");\n if (!li) return;\n\n // Add the active class\n li.classList.add(settings.nestedClass);\n\n // Apply recursively to any parent navigation elements\n activateNested(li, settings);\n };\n\n /**\n * Activate a nav and content area\n * @param {Object} items The nav item and content to activate\n * @param {Object} settings The settings for this instantiation\n */\n var activate = function (items, settings) {\n // Make sure there are items to activate\n if (!items) return;\n\n // Get the parent list item\n var li = items.nav.closest(\"li\");\n if (!li) return;\n\n // Add the active class to the nav and content\n li.classList.add(settings.navClass);\n items.content.classList.add(settings.contentClass);\n\n // Activate any parent navs in a nested navigation\n activateNested(li, settings);\n\n // Emit a custom event\n emitEvent(\"gumshoeActivate\", li, {\n link: items.nav,\n content: items.content,\n settings: settings,\n });\n };\n\n /**\n * Create the Constructor object\n * @param {String} selector The selector to use for navigation items\n * @param {Object} options User options and settings\n */\n var Constructor = function (selector, options) {\n //\n // Variables\n //\n\n var publicAPIs = {};\n var navItems, contents, current, timeout, settings;\n\n //\n // Methods\n //\n\n /**\n * Set variables from DOM elements\n */\n publicAPIs.setup = function () {\n // Get all nav items\n navItems = document.querySelectorAll(selector);\n\n // Create contents array\n contents = [];\n\n // Loop through each item, get it's matching content, and push to the array\n Array.prototype.forEach.call(navItems, function (item) {\n // Get the content for the nav item\n var content = document.getElementById(\n decodeURIComponent(item.hash.substr(1)),\n );\n if (!content) return;\n\n // Push to the contents array\n contents.push({\n nav: item,\n content: content,\n });\n });\n\n // Sort contents by the order they appear in the DOM\n sortContents(contents);\n };\n\n /**\n * Detect which content is currently active\n */\n publicAPIs.detect = function () {\n // Get the active content\n var active = getActive(contents, settings);\n\n // if there's no active content, deactivate and bail\n if (!active) {\n if (current) {\n deactivate(current, settings);\n current = null;\n }\n return;\n }\n\n // If the active content is the one currently active, do nothing\n if (current && active.content === current.content) return;\n\n // Deactivate the current content and activate the new content\n deactivate(current, settings);\n activate(active, settings);\n\n // Update the currently active content\n current = active;\n };\n\n /**\n * Detect the active content on scroll\n * Debounced for performance\n */\n var scrollHandler = function (event) {\n // If there's a timer, cancel it\n if (timeout) {\n window.cancelAnimationFrame(timeout);\n }\n\n // Setup debounce callback\n timeout = window.requestAnimationFrame(publicAPIs.detect);\n };\n\n /**\n * Update content sorting on resize\n * Debounced for performance\n */\n var resizeHandler = function (event) {\n // If there's a timer, cancel it\n if (timeout) {\n window.cancelAnimationFrame(timeout);\n }\n\n // Setup debounce callback\n timeout = window.requestAnimationFrame(function () {\n sortContents(contents);\n publicAPIs.detect();\n });\n };\n\n /**\n * Destroy the current instantiation\n */\n publicAPIs.destroy = function () {\n // Undo DOM changes\n if (current) {\n deactivate(current, settings);\n }\n\n // Remove event listeners\n window.removeEventListener(\"scroll\", scrollHandler, false);\n if (settings.reflow) {\n window.removeEventListener(\"resize\", resizeHandler, false);\n }\n\n // Reset variables\n contents = null;\n navItems = null;\n current = null;\n timeout = null;\n settings = null;\n };\n\n /**\n * Initialize the current instantiation\n */\n var init = function () {\n // Merge user options into defaults\n settings = extend(defaults, options || {});\n\n // Setup variables based on the current DOM\n publicAPIs.setup();\n\n // Find the currently active content\n publicAPIs.detect();\n\n // Setup event listeners\n window.addEventListener(\"scroll\", scrollHandler, false);\n if (settings.reflow) {\n window.addEventListener(\"resize\", resizeHandler, false);\n }\n };\n\n //\n // Initialize and return the public APIs\n //\n\n init();\n return publicAPIs;\n };\n\n //\n // Return the Constructor\n //\n\n return Constructor;\n },\n);\n","// The module cache\nvar __webpack_module_cache__ = {};\n\n// The require function\nfunction __webpack_require__(moduleId) {\n\t// Check if module is in cache\n\tvar cachedModule = __webpack_module_cache__[moduleId];\n\tif (cachedModule !== undefined) {\n\t\treturn cachedModule.exports;\n\t}\n\t// Create a new module (and put it into the cache)\n\tvar module = __webpack_module_cache__[moduleId] = {\n\t\t// no module.id needed\n\t\t// no module.loaded needed\n\t\texports: {}\n\t};\n\n\t// Execute the module function\n\t__webpack_modules__[moduleId].call(module.exports, module, module.exports, __webpack_require__);\n\n\t// Return the exports of the module\n\treturn module.exports;\n}\n\n","// getDefaultExport function for compatibility with non-harmony modules\n__webpack_require__.n = (module) => {\n\tvar getter = module && module.__esModule ?\n\t\t() => (module['default']) :\n\t\t() => (module);\n\t__webpack_require__.d(getter, { a: getter });\n\treturn getter;\n};","// define getter functions for harmony exports\n__webpack_require__.d = (exports, definition) => {\n\tfor(var key in definition) {\n\t\tif(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {\n\t\t\tObject.defineProperty(exports, key, { enumerable: true, get: definition[key] });\n\t\t}\n\t}\n};","__webpack_require__.g = (function() {\n\tif (typeof globalThis === 'object') return globalThis;\n\ttry {\n\t\treturn this || new Function('return this')();\n\t} catch (e) {\n\t\tif (typeof window === 'object') return window;\n\t}\n})();","__webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop))","import Gumshoe from \"./gumshoe-patched.js\";\n\n////////////////////////////////////////////////////////////////////////////////\n// Scroll Handling\n////////////////////////////////////////////////////////////////////////////////\nvar tocScroll = null;\nvar header = null;\nvar lastScrollTop = document.documentElement.scrollTop;\nconst GO_TO_TOP_OFFSET = 64;\n\nfunction scrollHandlerForHeader(positionY) {\n if (positionY > 0) {\n header.classList.add(\"scrolled\");\n } else {\n header.classList.remove(\"scrolled\");\n }\n}\n\nfunction scrollHandlerForBackToTop(positionY) {\n if (positionY < GO_TO_TOP_OFFSET) {\n document.documentElement.classList.remove(\"show-back-to-top\");\n } else {\n if (positionY < lastScrollTop) {\n document.documentElement.classList.add(\"show-back-to-top\");\n } else if (positionY > lastScrollTop) {\n document.documentElement.classList.remove(\"show-back-to-top\");\n }\n }\n lastScrollTop = positionY;\n}\n\nfunction scrollHandlerForTOC(positionY) {\n if (tocScroll === null) {\n return;\n }\n\n // top of page.\n if (positionY == 0) {\n tocScroll.scrollTo(0, 0);\n } else if (\n // bottom of page.\n Math.ceil(positionY) >=\n Math.floor(document.documentElement.scrollHeight - window.innerHeight)\n ) {\n tocScroll.scrollTo(0, tocScroll.scrollHeight);\n } else {\n // somewhere in the middle.\n const current = document.querySelector(\".scroll-current\");\n if (current == null) {\n return;\n }\n\n // https://github.com/pypa/pip/issues/9159 This breaks scroll behaviours.\n // // scroll the currently \"active\" heading in toc, into view.\n // const rect = current.getBoundingClientRect();\n // if (0 > rect.top) {\n // current.scrollIntoView(true); // the argument is \"alignTop\"\n // } else if (rect.bottom > window.innerHeight) {\n // current.scrollIntoView(false);\n // }\n }\n}\n\nfunction scrollHandler(positionY) {\n scrollHandlerForHeader(positionY);\n scrollHandlerForBackToTop(positionY);\n scrollHandlerForTOC(positionY);\n}\n\n////////////////////////////////////////////////////////////////////////////////\n// Theme Toggle\n////////////////////////////////////////////////////////////////////////////////\nfunction setTheme(mode) {\n if (mode !== \"light\" && mode !== \"dark\" && mode !== \"auto\") {\n console.error(`Got invalid theme mode: ${mode}. Resetting to auto.`);\n mode = \"auto\";\n }\n\n document.body.dataset.theme = mode;\n localStorage.setItem(\"theme\", mode);\n console.log(`Changed to ${mode} mode.`);\n}\n\nfunction cycleThemeOnce() {\n const currentTheme = localStorage.getItem(\"theme\") || \"auto\";\n const prefersDark = window.matchMedia(\"(prefers-color-scheme: dark)\").matches;\n\n if (prefersDark) {\n // Auto (dark) -> Light -> Dark\n if (currentTheme === \"auto\") {\n setTheme(\"light\");\n } else if (currentTheme == \"light\") {\n setTheme(\"dark\");\n } else {\n setTheme(\"auto\");\n }\n } else {\n // Auto (light) -> Dark -> Light\n if (currentTheme === \"auto\") {\n setTheme(\"dark\");\n } else if (currentTheme == \"dark\") {\n setTheme(\"light\");\n } else {\n setTheme(\"auto\");\n }\n }\n}\n\n////////////////////////////////////////////////////////////////////////////////\n// Setup\n////////////////////////////////////////////////////////////////////////////////\nfunction setupScrollHandler() {\n // Taken from https://developer.mozilla.org/en-US/docs/Web/API/Document/scroll_event\n let last_known_scroll_position = 0;\n let ticking = false;\n\n window.addEventListener(\"scroll\", function (e) {\n last_known_scroll_position = window.scrollY;\n\n if (!ticking) {\n window.requestAnimationFrame(function () {\n scrollHandler(last_known_scroll_position);\n ticking = false;\n });\n\n ticking = true;\n }\n });\n window.scroll();\n}\n\nfunction setupScrollSpy() {\n if (tocScroll === null) {\n return;\n }\n\n // Scrollspy -- highlight table on contents, based on scroll\n new Gumshoe(\".toc-tree a\", {\n reflow: true,\n recursive: true,\n navClass: \"scroll-current\",\n offset: () => {\n let rem = parseFloat(getComputedStyle(document.documentElement).fontSize);\n const headerRect = header.getBoundingClientRect();\n return headerRect.top + headerRect.height + 2.5 * rem + 1;\n },\n });\n}\n\nfunction setupTheme() {\n // Attach event handlers for toggling themes\n const buttons = document.getElementsByClassName(\"theme-toggle\");\n Array.from(buttons).forEach((btn) => {\n btn.addEventListener(\"click\", cycleThemeOnce);\n });\n}\n\nfunction setup() {\n setupTheme();\n setupScrollHandler();\n setupScrollSpy();\n}\n\n////////////////////////////////////////////////////////////////////////////////\n// Main entrypoint\n////////////////////////////////////////////////////////////////////////////////\nfunction main() {\n document.body.parentNode.classList.remove(\"no-js\");\n\n header = document.querySelector(\"header\");\n tocScroll = document.querySelector(\".toc-scroll\");\n\n setup();\n}\n\ndocument.addEventListener(\"DOMContentLoaded\", main);\n"],"names":["root","g","window","this","defaults","navClass","contentClass","nested","nestedClass","offset","reflow","events","emitEvent","type","elem","detail","settings","event","CustomEvent","bubbles","cancelable","dispatchEvent","getOffsetTop","location","offsetParent","offsetTop","sortContents","contents","sort","item1","item2","content","isInView","bottom","bounds","getBoundingClientRect","parseFloat","getOffset","parseInt","innerHeight","document","documentElement","clientHeight","top","isAtBottom","Math","ceil","pageYOffset","max","body","scrollHeight","offsetHeight","getActive","last","length","item","useLastItem","i","deactivateNested","nav","parentNode","li","closest","classList","remove","deactivate","items","link","activateNested","add","selector","options","navItems","current","timeout","publicAPIs","querySelectorAll","Array","prototype","forEach","call","getElementById","decodeURIComponent","hash","substr","push","active","activate","scrollHandler","cancelAnimationFrame","requestAnimationFrame","detect","resizeHandler","destroy","removeEventListener","merged","arguments","obj","key","hasOwnProperty","extend","setup","addEventListener","factory","__webpack_module_cache__","__webpack_require__","moduleId","cachedModule","undefined","exports","module","__webpack_modules__","n","getter","__esModule","d","a","definition","o","Object","defineProperty","enumerable","get","globalThis","Function","e","prop","tocScroll","header","lastScrollTop","scrollTop","cycleThemeOnce","currentTheme","localStorage","getItem","mode","matchMedia","matches","console","error","dataset","theme","setItem","log","buttons","getElementsByClassName","from","btn","setupTheme","last_known_scroll_position","ticking","scrollY","positionY","scrollHandlerForHeader","scrollHandlerForBackToTop","scrollTo","floor","querySelector","scrollHandlerForTOC","scroll","setupScrollHandler","recursive","rem","getComputedStyle","fontSize","headerRect","height"],"sourceRoot":""} \ No newline at end of file diff --git a/docs/_source/_build/html/_static/searchtools.js b/docs/_source/_build/html/_static/searchtools.js new file mode 100644 index 0000000..e29b1c7 --- /dev/null +++ b/docs/_source/_build/html/_static/searchtools.js @@ -0,0 +1,693 @@ +/* + * Sphinx JavaScript utilities for the full-text search. + */ +"use strict"; + +/** + * Simple result scoring code. + */ +if (typeof Scorer === "undefined") { + var Scorer = { + // Implement the following function to further tweak the score for each result + // The function takes a result array [docname, title, anchor, descr, score, filename] + // and returns the new score. + /* + score: result => { + const [docname, title, anchor, descr, score, filename, kind] = result + return score + }, + */ + + // query matches the full name of an object + objNameMatch: 11, + // or matches in the last dotted part of the object name + objPartialMatch: 6, + // Additive scores depending on the priority of the object + objPrio: { + 0: 15, // used to be importantResults + 1: 5, // used to be objectResults + 2: -5, // used to be unimportantResults + }, + // Used when the priority is not in the mapping. + objPrioDefault: 0, + + // query found in title + title: 15, + partialTitle: 7, + // query found in terms + term: 5, + partialTerm: 2, + }; +} + +// Global search result kind enum, used by themes to style search results. +// prettier-ignore +class SearchResultKind { + static get index() { return "index"; } + static get object() { return "object"; } + static get text() { return "text"; } + static get title() { return "title"; } +} + +const _removeChildren = (element) => { + while (element && element.lastChild) element.removeChild(element.lastChild); +}; + +/** + * See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping + */ +const _escapeRegExp = (string) => + string.replace(/[.*+\-?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string + +const _escapeHTML = (text) => { + return text + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +}; + +const _displayItem = (item, searchTerms, highlightTerms) => { + const docBuilder = DOCUMENTATION_OPTIONS.BUILDER; + const docFileSuffix = DOCUMENTATION_OPTIONS.FILE_SUFFIX; + const docLinkSuffix = DOCUMENTATION_OPTIONS.LINK_SUFFIX; + const showSearchSummary = DOCUMENTATION_OPTIONS.SHOW_SEARCH_SUMMARY; + const contentRoot = document.documentElement.dataset.content_root; + + const [docName, title, anchor, descr, score, _filename, kind] = item; + + let listItem = document.createElement("li"); + // Add a class representing the item's type: + // can be used by a theme's CSS selector for styling + // See SearchResultKind for the class names. + listItem.classList.add(`kind-${kind}`); + let requestUrl; + let linkUrl; + if (docBuilder === "dirhtml") { + // dirhtml builder + let dirname = docName + "/"; + if (dirname.match(/\/index\/$/)) + dirname = dirname.substring(0, dirname.length - 6); + else if (dirname === "index/") dirname = ""; + requestUrl = contentRoot + dirname; + linkUrl = requestUrl; + } else { + // normal html builders + requestUrl = contentRoot + docName + docFileSuffix; + linkUrl = docName + docLinkSuffix; + } + let linkEl = listItem.appendChild(document.createElement("a")); + linkEl.href = linkUrl + anchor; + linkEl.dataset.score = score; + linkEl.innerHTML = _escapeHTML(title); + if (descr) { + listItem.appendChild(document.createElement("span")).innerHTML = + ` (${_escapeHTML(descr)})`; + // highlight search terms in the description + if (SPHINX_HIGHLIGHT_ENABLED) + // SPHINX_HIGHLIGHT_ENABLED is set in sphinx_highlight.js + highlightTerms.forEach((term) => + _highlightText(listItem, term, "highlighted"), + ); + } else if (showSearchSummary) + fetch(requestUrl) + .then((responseData) => responseData.text()) + .then((data) => { + if (data) + listItem.appendChild( + Search.makeSearchSummary(data, searchTerms, anchor), + ); + // highlight search terms in the summary + if (SPHINX_HIGHLIGHT_ENABLED) + // SPHINX_HIGHLIGHT_ENABLED is set in sphinx_highlight.js + highlightTerms.forEach((term) => + _highlightText(listItem, term, "highlighted"), + ); + }); + Search.output.appendChild(listItem); +}; +const _finishSearch = (resultCount) => { + Search.stopPulse(); + Search.title.innerText = _("Search Results"); + if (!resultCount) + Search.status.innerText = Documentation.gettext( + "Your search did not match any documents. Please make sure that all words are spelled correctly and that you've selected enough categories.", + ); + else + Search.status.innerText = Documentation.ngettext( + "Search finished, found one page matching the search query.", + "Search finished, found ${resultCount} pages matching the search query.", + resultCount, + ).replace("${resultCount}", resultCount); +}; +const _displayNextItem = ( + results, + resultCount, + searchTerms, + highlightTerms, +) => { + // results left, load the summary and display it + // this is intended to be dynamic (don't sub resultsCount) + if (results.length) { + _displayItem(results.pop(), searchTerms, highlightTerms); + setTimeout( + () => _displayNextItem(results, resultCount, searchTerms, highlightTerms), + 5, + ); + } + // search finished, update title and status message + else _finishSearch(resultCount); +}; +// Helper function used by query() to order search results. +// Each input is an array of [docname, title, anchor, descr, score, filename, kind]. +// Order the results by score (in opposite order of appearance, since the +// `_displayNextItem` function uses pop() to retrieve items) and then alphabetically. +const _orderResultsByScoreThenName = (a, b) => { + const leftScore = a[4]; + const rightScore = b[4]; + if (leftScore === rightScore) { + // same score: sort alphabetically + const leftTitle = a[1].toLowerCase(); + const rightTitle = b[1].toLowerCase(); + if (leftTitle === rightTitle) return 0; + return leftTitle > rightTitle ? -1 : 1; // inverted is intentional + } + return leftScore > rightScore ? 1 : -1; +}; + +/** + * Default splitQuery function. Can be overridden in ``sphinx.search`` with a + * custom function per language. + * + * The regular expression works by splitting the string on consecutive characters + * that are not Unicode letters, numbers, underscores, or emoji characters. + * This is the same as ``\W+`` in Python, preserving the surrogate pair area. + */ +if (typeof splitQuery === "undefined") { + var splitQuery = (query) => + query + .split(/[^\p{Letter}\p{Number}_\p{Emoji_Presentation}]+/gu) + .filter((term) => term); // remove remaining empty strings +} + +/** + * Search Module + */ +const Search = { + _index: null, + _queued_query: null, + _pulse_status: -1, + + htmlToText: (htmlString, anchor) => { + const htmlElement = new DOMParser().parseFromString( + htmlString, + "text/html", + ); + for (const removalQuery of [".headerlink", "script", "style"]) { + htmlElement.querySelectorAll(removalQuery).forEach((el) => { + el.remove(); + }); + } + if (anchor) { + const anchorContent = htmlElement.querySelector( + `[role="main"] ${anchor}`, + ); + if (anchorContent) return anchorContent.textContent; + + console.warn( + `Anchored content block not found. Sphinx search tries to obtain it via DOM query '[role=main] ${anchor}'. Check your theme or template.`, + ); + } + + // if anchor not specified or not found, fall back to main content + const docContent = htmlElement.querySelector('[role="main"]'); + if (docContent) return docContent.textContent; + + console.warn( + "Content block not found. Sphinx search tries to obtain it via DOM query '[role=main]'. Check your theme or template.", + ); + return ""; + }, + + init: () => { + const query = new URLSearchParams(window.location.search).get("q"); + document + .querySelectorAll('input[name="q"]') + .forEach((el) => (el.value = query)); + if (query) Search.performSearch(query); + }, + + loadIndex: (url) => + (document.body.appendChild(document.createElement("script")).src = url), + + setIndex: (index) => { + Search._index = index; + if (Search._queued_query !== null) { + const query = Search._queued_query; + Search._queued_query = null; + Search.query(query); + } + }, + + hasIndex: () => Search._index !== null, + + deferQuery: (query) => (Search._queued_query = query), + + stopPulse: () => (Search._pulse_status = -1), + + startPulse: () => { + if (Search._pulse_status >= 0) return; + + const pulse = () => { + Search._pulse_status = (Search._pulse_status + 1) % 4; + Search.dots.innerText = ".".repeat(Search._pulse_status); + if (Search._pulse_status >= 0) window.setTimeout(pulse, 500); + }; + pulse(); + }, + + /** + * perform a search for something (or wait until index is loaded) + */ + performSearch: (query) => { + // create the required interface elements + const searchText = document.createElement("h2"); + searchText.textContent = _("Searching"); + const searchSummary = document.createElement("p"); + searchSummary.classList.add("search-summary"); + searchSummary.innerText = ""; + const searchList = document.createElement("ul"); + searchList.setAttribute("role", "list"); + searchList.classList.add("search"); + + const out = document.getElementById("search-results"); + Search.title = out.appendChild(searchText); + Search.dots = Search.title.appendChild(document.createElement("span")); + Search.status = out.appendChild(searchSummary); + Search.output = out.appendChild(searchList); + + const searchProgress = document.getElementById("search-progress"); + // Some themes don't use the search progress node + if (searchProgress) { + searchProgress.innerText = _("Preparing search..."); + } + Search.startPulse(); + + // index already loaded, the browser was quick! + if (Search.hasIndex()) Search.query(query); + else Search.deferQuery(query); + }, + + _parseQuery: (query) => { + // stem the search terms and add them to the correct list + const stemmer = new Stemmer(); + const searchTerms = new Set(); + const excludedTerms = new Set(); + const highlightTerms = new Set(); + const objectTerms = new Set(splitQuery(query.toLowerCase().trim())); + splitQuery(query.trim()).forEach((queryTerm) => { + const queryTermLower = queryTerm.toLowerCase(); + + // maybe skip this "word" + // stopwords set is from language_data.js + if (stopwords.has(queryTermLower) || queryTerm.match(/^\d+$/)) return; + + // stem the word + let word = stemmer.stemWord(queryTermLower); + // select the correct list + if (word[0] === "-") excludedTerms.add(word.substr(1)); + else { + searchTerms.add(word); + highlightTerms.add(queryTermLower); + } + }); + + if (SPHINX_HIGHLIGHT_ENABLED) { + // SPHINX_HIGHLIGHT_ENABLED is set in sphinx_highlight.js + localStorage.setItem( + "sphinx_highlight_terms", + [...highlightTerms].join(" "), + ); + } + + // console.debug("SEARCH: searching for:"); + // console.info("required: ", [...searchTerms]); + // console.info("excluded: ", [...excludedTerms]); + + return [query, searchTerms, excludedTerms, highlightTerms, objectTerms]; + }, + + /** + * execute search (requires search index to be loaded) + */ + _performSearch: ( + query, + searchTerms, + excludedTerms, + highlightTerms, + objectTerms, + ) => { + const filenames = Search._index.filenames; + const docNames = Search._index.docnames; + const titles = Search._index.titles; + const allTitles = Search._index.alltitles; + const indexEntries = Search._index.indexentries; + + // Collect multiple result groups to be sorted separately and then ordered. + // Each is an array of [docname, title, anchor, descr, score, filename, kind]. + const normalResults = []; + const nonMainIndexResults = []; + + _removeChildren(document.getElementById("search-progress")); + + const queryLower = query.toLowerCase().trim(); + for (const [title, foundTitles] of Object.entries(allTitles)) { + if ( + title.toLowerCase().trim().includes(queryLower) + && queryLower.length >= title.length / 2 + ) { + for (const [file, id] of foundTitles) { + const score = Math.round( + (Scorer.title * queryLower.length) / title.length, + ); + const boost = titles[file] === title ? 1 : 0; // add a boost for document titles + normalResults.push([ + docNames[file], + titles[file] !== title ? `${titles[file]} > ${title}` : title, + id !== null ? "#" + id : "", + null, + score + boost, + filenames[file], + SearchResultKind.title, + ]); + } + } + } + + // search for explicit entries in index directives + for (const [entry, foundEntries] of Object.entries(indexEntries)) { + if (entry.includes(queryLower) && queryLower.length >= entry.length / 2) { + for (const [file, id, isMain] of foundEntries) { + const score = Math.round((100 * queryLower.length) / entry.length); + const result = [ + docNames[file], + titles[file], + id ? "#" + id : "", + null, + score, + filenames[file], + SearchResultKind.index, + ]; + if (isMain) { + normalResults.push(result); + } else { + nonMainIndexResults.push(result); + } + } + } + } + + // lookup as object + objectTerms.forEach((term) => + normalResults.push(...Search.performObjectSearch(term, objectTerms)), + ); + + // lookup as search terms in fulltext + normalResults.push( + ...Search.performTermsSearch(searchTerms, excludedTerms), + ); + + // let the scorer override scores with a custom scoring function + if (Scorer.score) { + normalResults.forEach((item) => (item[4] = Scorer.score(item))); + nonMainIndexResults.forEach((item) => (item[4] = Scorer.score(item))); + } + + // Sort each group of results by score and then alphabetically by name. + normalResults.sort(_orderResultsByScoreThenName); + nonMainIndexResults.sort(_orderResultsByScoreThenName); + + // Combine the result groups in (reverse) order. + // Non-main index entries are typically arbitrary cross-references, + // so display them after other results. + let results = [...nonMainIndexResults, ...normalResults]; + + // remove duplicate search results + // note the reversing of results, so that in the case of duplicates, the highest-scoring entry is kept + let seen = new Set(); + results = results.reverse().reduce((acc, result) => { + let resultStr = result + .slice(0, 4) + .concat([result[5]]) + .map((v) => String(v)) + .join(","); + if (!seen.has(resultStr)) { + acc.push(result); + seen.add(resultStr); + } + return acc; + }, []); + + return results.reverse(); + }, + + query: (query) => { + const [ + searchQuery, + searchTerms, + excludedTerms, + highlightTerms, + objectTerms, + ] = Search._parseQuery(query); + const results = Search._performSearch( + searchQuery, + searchTerms, + excludedTerms, + highlightTerms, + objectTerms, + ); + + // for debugging + //Search.lastresults = results.slice(); // a copy + // console.info("search results:", Search.lastresults); + + // print the results + _displayNextItem(results, results.length, searchTerms, highlightTerms); + }, + + /** + * search for object names + */ + performObjectSearch: (object, objectTerms) => { + const filenames = Search._index.filenames; + const docNames = Search._index.docnames; + const objects = Search._index.objects; + const objNames = Search._index.objnames; + const titles = Search._index.titles; + + const results = []; + + const objectSearchCallback = (prefix, match) => { + const name = match[4]; + const fullname = (prefix ? prefix + "." : "") + name; + const fullnameLower = fullname.toLowerCase(); + if (fullnameLower.indexOf(object) < 0) return; + + let score = 0; + const parts = fullnameLower.split("."); + + // check for different match types: exact matches of full name or + // "last name" (i.e. last dotted part) + if (fullnameLower === object || parts.slice(-1)[0] === object) + score += Scorer.objNameMatch; + else if (parts.slice(-1)[0].indexOf(object) > -1) + score += Scorer.objPartialMatch; // matches in last name + + const objName = objNames[match[1]][2]; + const title = titles[match[0]]; + + // If more than one term searched for, we require other words to be + // found in the name/title/description + const otherTerms = new Set(objectTerms); + otherTerms.delete(object); + if (otherTerms.size > 0) { + const haystack = `${prefix} ${name} ${objName} ${title}`.toLowerCase(); + if ( + [...otherTerms].some((otherTerm) => haystack.indexOf(otherTerm) < 0) + ) + return; + } + + let anchor = match[3]; + if (anchor === "") anchor = fullname; + else if (anchor === "-") anchor = objNames[match[1]][1] + "-" + fullname; + + const descr = objName + _(", in ") + title; + + // add custom score for some objects according to scorer + if (Scorer.objPrio.hasOwnProperty(match[2])) + score += Scorer.objPrio[match[2]]; + else score += Scorer.objPrioDefault; + + results.push([ + docNames[match[0]], + fullname, + "#" + anchor, + descr, + score, + filenames[match[0]], + SearchResultKind.object, + ]); + }; + Object.keys(objects).forEach((prefix) => + objects[prefix].forEach((array) => objectSearchCallback(prefix, array)), + ); + return results; + }, + + /** + * search for full-text terms in the index + */ + performTermsSearch: (searchTerms, excludedTerms) => { + // prepare search + const terms = Search._index.terms; + const titleTerms = Search._index.titleterms; + const filenames = Search._index.filenames; + const docNames = Search._index.docnames; + const titles = Search._index.titles; + + const scoreMap = new Map(); + const fileMap = new Map(); + + // perform the search on the required terms + searchTerms.forEach((word) => { + const files = []; + // find documents, if any, containing the query word in their text/title term indices + // use Object.hasOwnProperty to avoid mismatching against prototype properties + const arr = [ + { + files: terms.hasOwnProperty(word) ? terms[word] : undefined, + score: Scorer.term, + }, + { + files: titleTerms.hasOwnProperty(word) ? titleTerms[word] : undefined, + score: Scorer.title, + }, + ]; + // add support for partial matches + if (word.length > 2) { + const escapedWord = _escapeRegExp(word); + if (!terms.hasOwnProperty(word)) { + Object.keys(terms).forEach((term) => { + if (term.match(escapedWord)) + arr.push({ files: terms[term], score: Scorer.partialTerm }); + }); + } + if (!titleTerms.hasOwnProperty(word)) { + Object.keys(titleTerms).forEach((term) => { + if (term.match(escapedWord)) + arr.push({ files: titleTerms[term], score: Scorer.partialTitle }); + }); + } + } + + // no match but word was a required one + if (arr.every((record) => record.files === undefined)) return; + + // found search word in contents + arr.forEach((record) => { + if (record.files === undefined) return; + + let recordFiles = record.files; + if (recordFiles.length === undefined) recordFiles = [recordFiles]; + files.push(...recordFiles); + + // set score for the word in each file + recordFiles.forEach((file) => { + if (!scoreMap.has(file)) scoreMap.set(file, new Map()); + const fileScores = scoreMap.get(file); + fileScores.set(word, record.score); + }); + }); + + // create the mapping + files.forEach((file) => { + if (!fileMap.has(file)) fileMap.set(file, [word]); + else if (fileMap.get(file).indexOf(word) === -1) + fileMap.get(file).push(word); + }); + }); + + // now check if the files don't contain excluded terms + const results = []; + for (const [file, wordList] of fileMap) { + // check if all requirements are matched + + // as search terms with length < 3 are discarded + const filteredTermCount = [...searchTerms].filter( + (term) => term.length > 2, + ).length; + if ( + wordList.length !== searchTerms.size + && wordList.length !== filteredTermCount + ) + continue; + + // ensure that none of the excluded terms is in the search result + if ( + [...excludedTerms].some( + (term) => + terms[term] === file + || titleTerms[term] === file + || (terms[term] || []).includes(file) + || (titleTerms[term] || []).includes(file), + ) + ) + break; + + // select one (max) score for the file. + const score = Math.max(...wordList.map((w) => scoreMap.get(file).get(w))); + // add result to the result list + results.push([ + docNames[file], + titles[file], + "", + null, + score, + filenames[file], + SearchResultKind.text, + ]); + } + return results; + }, + + /** + * helper function to return a node containing the + * search summary for a given text. keywords is a list + * of stemmed words. + */ + makeSearchSummary: (htmlText, keywords, anchor) => { + const text = Search.htmlToText(htmlText, anchor); + if (text === "") return null; + + const textLower = text.toLowerCase(); + const actualStartPosition = [...keywords] + .map((k) => textLower.indexOf(k.toLowerCase())) + .filter((i) => i > -1) + .slice(-1)[0]; + const startWithContext = Math.max(actualStartPosition - 120, 0); + + const top = startWithContext === 0 ? "" : "..."; + const tail = startWithContext + 240 < text.length ? "..." : ""; + + let summary = document.createElement("p"); + summary.classList.add("context"); + summary.textContent = + top + text.substr(startWithContext, 240).trim() + tail; + + return summary; + }, +}; + +_ready(Search.init); diff --git a/docs/_source/_build/html/_static/skeleton.css b/docs/_source/_build/html/_static/skeleton.css new file mode 100644 index 0000000..467c878 --- /dev/null +++ b/docs/_source/_build/html/_static/skeleton.css @@ -0,0 +1,296 @@ +/* Some sane resets. */ +html { + height: 100%; +} + +body { + margin: 0; + min-height: 100%; +} + +/* All the flexbox magic! */ +body, +.sb-announcement, +.sb-content, +.sb-main, +.sb-container, +.sb-container__inner, +.sb-article-container, +.sb-footer-content, +.sb-header, +.sb-header-secondary, +.sb-footer { + display: flex; +} + +/* These order things vertically */ +body, +.sb-main, +.sb-article-container { + flex-direction: column; +} + +/* Put elements in the center */ +.sb-header, +.sb-header-secondary, +.sb-container, +.sb-content, +.sb-footer, +.sb-footer-content { + justify-content: center; +} +/* Put elements at the ends */ +.sb-article-container { + justify-content: space-between; +} + +/* These elements grow. */ +.sb-main, +.sb-content, +.sb-container, +article { + flex-grow: 1; +} + +/* Because padding making this wider is not fun */ +article { + box-sizing: border-box; +} + +/* The announcements element should never be wider than the page. */ +.sb-announcement { + max-width: 100%; +} + +.sb-sidebar-primary, +.sb-sidebar-secondary { + flex-shrink: 0; + width: 17rem; +} + +.sb-announcement__inner { + justify-content: center; + + box-sizing: border-box; + height: 3rem; + + overflow-x: auto; + white-space: nowrap; +} + +/* Sidebars, with checkbox-based toggle */ +.sb-sidebar-primary, +.sb-sidebar-secondary { + position: fixed; + height: 100%; + top: 0; +} + +.sb-sidebar-primary { + left: -17rem; + transition: left 250ms ease-in-out; +} +.sb-sidebar-secondary { + right: -17rem; + transition: right 250ms ease-in-out; +} + +.sb-sidebar-toggle { + display: none; +} +.sb-sidebar-overlay { + position: fixed; + top: 0; + width: 0; + height: 0; + + transition: width 0ms ease 250ms, height 0ms ease 250ms, opacity 250ms ease; + + opacity: 0; + background-color: rgba(0, 0, 0, 0.54); +} + +#sb-sidebar-toggle--primary:checked + ~ .sb-sidebar-overlay[for="sb-sidebar-toggle--primary"], +#sb-sidebar-toggle--secondary:checked + ~ .sb-sidebar-overlay[for="sb-sidebar-toggle--secondary"] { + width: 100%; + height: 100%; + opacity: 1; + transition: width 0ms ease, height 0ms ease, opacity 250ms ease; +} + +#sb-sidebar-toggle--primary:checked ~ .sb-container .sb-sidebar-primary { + left: 0; +} +#sb-sidebar-toggle--secondary:checked ~ .sb-container .sb-sidebar-secondary { + right: 0; +} + +/* Full-width mode */ +.drop-secondary-sidebar-for-full-width-content + .hide-when-secondary-sidebar-shown { + display: none !important; +} +.drop-secondary-sidebar-for-full-width-content .sb-sidebar-secondary { + display: none !important; +} + +/* Mobile views */ +.sb-page-width { + width: 100%; +} + +.sb-article-container, +.sb-footer-content__inner, +.drop-secondary-sidebar-for-full-width-content .sb-article, +.drop-secondary-sidebar-for-full-width-content .match-content-width { + width: 100vw; +} + +.sb-article, +.match-content-width { + padding: 0 1rem; + box-sizing: border-box; +} + +@media (min-width: 32rem) { + .sb-article, + .match-content-width { + padding: 0 2rem; + } +} + +/* Tablet views */ +@media (min-width: 42rem) { + .sb-article-container { + width: auto; + } + .sb-footer-content__inner, + .drop-secondary-sidebar-for-full-width-content .sb-article, + .drop-secondary-sidebar-for-full-width-content .match-content-width { + width: 42rem; + } + .sb-article, + .match-content-width { + width: 42rem; + } +} +@media (min-width: 46rem) { + .sb-footer-content__inner, + .drop-secondary-sidebar-for-full-width-content .sb-article, + .drop-secondary-sidebar-for-full-width-content .match-content-width { + width: 46rem; + } + .sb-article, + .match-content-width { + width: 46rem; + } +} +@media (min-width: 50rem) { + .sb-footer-content__inner, + .drop-secondary-sidebar-for-full-width-content .sb-article, + .drop-secondary-sidebar-for-full-width-content .match-content-width { + width: 50rem; + } + .sb-article, + .match-content-width { + width: 50rem; + } +} + +/* Tablet views */ +@media (min-width: 59rem) { + .sb-sidebar-secondary { + position: static; + } + .hide-when-secondary-sidebar-shown { + display: none !important; + } + .sb-footer-content__inner, + .drop-secondary-sidebar-for-full-width-content .sb-article, + .drop-secondary-sidebar-for-full-width-content .match-content-width { + width: 59rem; + } + .sb-article, + .match-content-width { + width: 42rem; + } +} +@media (min-width: 63rem) { + .sb-footer-content__inner, + .drop-secondary-sidebar-for-full-width-content .sb-article, + .drop-secondary-sidebar-for-full-width-content .match-content-width { + width: 63rem; + } + .sb-article, + .match-content-width { + width: 46rem; + } +} +@media (min-width: 67rem) { + .sb-footer-content__inner, + .drop-secondary-sidebar-for-full-width-content .sb-article, + .drop-secondary-sidebar-for-full-width-content .match-content-width { + width: 67rem; + } + .sb-article, + .match-content-width { + width: 50rem; + } +} + +/* Desktop views */ +@media (min-width: 76rem) { + .sb-sidebar-primary { + position: static; + } + .hide-when-primary-sidebar-shown { + display: none !important; + } + .sb-footer-content__inner, + .drop-secondary-sidebar-for-full-width-content .sb-article, + .drop-secondary-sidebar-for-full-width-content .match-content-width { + width: 59rem; + } + .sb-article, + .match-content-width { + width: 42rem; + } +} + +/* Full desktop views */ +@media (min-width: 80rem) { + .sb-article, + .match-content-width { + width: 46rem; + } + .sb-footer-content__inner, + .drop-secondary-sidebar-for-full-width-content .sb-article, + .drop-secondary-sidebar-for-full-width-content .match-content-width { + width: 63rem; + } +} + +@media (min-width: 84rem) { + .sb-article, + .match-content-width { + width: 50rem; + } + .sb-footer-content__inner, + .drop-secondary-sidebar-for-full-width-content .sb-article, + .drop-secondary-sidebar-for-full-width-content .match-content-width { + width: 67rem; + } +} + +@media (min-width: 88rem) { + .sb-footer-content__inner, + .drop-secondary-sidebar-for-full-width-content .sb-article, + .drop-secondary-sidebar-for-full-width-content .match-content-width { + width: 67rem; + } + .sb-page-width { + width: 88rem; + } +} diff --git a/docs/_source/_build/html/_static/sphinx_highlight.js b/docs/_source/_build/html/_static/sphinx_highlight.js new file mode 100644 index 0000000..a74e103 --- /dev/null +++ b/docs/_source/_build/html/_static/sphinx_highlight.js @@ -0,0 +1,159 @@ +/* Highlighting utilities for Sphinx HTML documentation. */ +"use strict"; + +const SPHINX_HIGHLIGHT_ENABLED = true; + +/** + * highlight a given string on a node by wrapping it in + * span elements with the given class name. + */ +const _highlight = (node, addItems, text, className) => { + if (node.nodeType === Node.TEXT_NODE) { + const val = node.nodeValue; + const parent = node.parentNode; + const pos = val.toLowerCase().indexOf(text); + if ( + pos >= 0 + && !parent.classList.contains(className) + && !parent.classList.contains("nohighlight") + ) { + let span; + + const closestNode = parent.closest("body, svg, foreignObject"); + const isInSVG = closestNode && closestNode.matches("svg"); + if (isInSVG) { + span = document.createElementNS("http://www.w3.org/2000/svg", "tspan"); + } else { + span = document.createElement("span"); + span.classList.add(className); + } + + span.appendChild(document.createTextNode(val.substr(pos, text.length))); + const rest = document.createTextNode(val.substr(pos + text.length)); + parent.insertBefore(span, parent.insertBefore(rest, node.nextSibling)); + node.nodeValue = val.substr(0, pos); + /* There may be more occurrences of search term in this node. So call this + * function recursively on the remaining fragment. + */ + _highlight(rest, addItems, text, className); + + if (isInSVG) { + const rect = document.createElementNS( + "http://www.w3.org/2000/svg", + "rect", + ); + const bbox = parent.getBBox(); + rect.x.baseVal.value = bbox.x; + rect.y.baseVal.value = bbox.y; + rect.width.baseVal.value = bbox.width; + rect.height.baseVal.value = bbox.height; + rect.setAttribute("class", className); + addItems.push({ parent: parent, target: rect }); + } + } + } else if (node.matches && !node.matches("button, select, textarea")) { + node.childNodes.forEach((el) => _highlight(el, addItems, text, className)); + } +}; +const _highlightText = (thisNode, text, className) => { + let addItems = []; + _highlight(thisNode, addItems, text, className); + addItems.forEach((obj) => + obj.parent.insertAdjacentElement("beforebegin", obj.target), + ); +}; + +/** + * Small JavaScript module for the documentation. + */ +const SphinxHighlight = { + /** + * highlight the search words provided in localstorage in the text + */ + highlightSearchWords: () => { + if (!SPHINX_HIGHLIGHT_ENABLED) return; // bail if no highlight + + // get and clear terms from localstorage + const url = new URL(window.location); + const highlight = + localStorage.getItem("sphinx_highlight_terms") + || url.searchParams.get("highlight") + || ""; + localStorage.removeItem("sphinx_highlight_terms"); + // Update history only if '?highlight' is present; otherwise it + // clears text fragments (not set in window.location by the browser) + if (url.searchParams.has("highlight")) { + url.searchParams.delete("highlight"); + window.history.replaceState({}, "", url); + } + + // get individual terms from highlight string + const terms = highlight + .toLowerCase() + .split(/\s+/) + .filter((x) => x); + if (terms.length === 0) return; // nothing to do + + // There should never be more than one element matching "div.body" + const divBody = document.querySelectorAll("div.body"); + const body = divBody.length ? divBody[0] : document.querySelector("body"); + window.setTimeout(() => { + terms.forEach((term) => _highlightText(body, term, "highlighted")); + }, 10); + + const searchBox = document.getElementById("searchbox"); + if (searchBox === null) return; + searchBox.appendChild( + document + .createRange() + .createContextualFragment( + '", + ), + ); + }, + + /** + * helper function to hide the search marks again + */ + hideSearchWords: () => { + document + .querySelectorAll("#searchbox .highlight-link") + .forEach((el) => el.remove()); + document + .querySelectorAll("span.highlighted") + .forEach((el) => el.classList.remove("highlighted")); + localStorage.removeItem("sphinx_highlight_terms"); + }, + + initEscapeListener: () => { + // only install a listener if it is really needed + if (!DOCUMENTATION_OPTIONS.ENABLE_SEARCH_SHORTCUTS) return; + + document.addEventListener("keydown", (event) => { + // bail for input elements + if (BLACKLISTED_KEY_CONTROL_ELEMENTS.has(document.activeElement.tagName)) + return; + // bail with special keys + if (event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) + return; + if ( + DOCUMENTATION_OPTIONS.ENABLE_SEARCH_SHORTCUTS + && event.key === "Escape" + ) { + SphinxHighlight.hideSearchWords(); + event.preventDefault(); + } + }); + }, +}; + +_ready(() => { + /* Do not call highlightSearchWords() when we are on the search page. + * It will highlight words from the *previous* search query. + */ + if (typeof Search === "undefined") SphinxHighlight.highlightSearchWords(); + SphinxHighlight.initEscapeListener(); +}); diff --git a/docs/_source/_build/html/_static/styles/furo-extensions.css b/docs/_source/_build/html/_static/styles/furo-extensions.css new file mode 100644 index 0000000..2d74267 --- /dev/null +++ b/docs/_source/_build/html/_static/styles/furo-extensions.css @@ -0,0 +1,2 @@ +#furo-sidebar-ad-placement{padding:var(--sidebar-item-spacing-vertical) var(--sidebar-item-spacing-horizontal)}#furo-sidebar-ad-placement .ethical-sidebar{background:var(--color-background-secondary);border:none;box-shadow:none}#furo-sidebar-ad-placement .ethical-sidebar:hover{background:var(--color-background-hover)}#furo-sidebar-ad-placement .ethical-sidebar a{color:var(--color-foreground-primary)}#furo-sidebar-ad-placement .ethical-callout a{color:var(--color-foreground-secondary)!important}#furo-readthedocs-versions{background:transparent;display:block;position:static;width:100%}#furo-readthedocs-versions .rst-versions{background:#1a1c1e}#furo-readthedocs-versions .rst-current-version{background:var(--color-sidebar-item-background);cursor:unset}#furo-readthedocs-versions .rst-current-version:hover{background:var(--color-sidebar-item-background)}#furo-readthedocs-versions .rst-current-version .fa-book{color:var(--color-foreground-primary)}#furo-readthedocs-versions>.rst-other-versions{padding:0}#furo-readthedocs-versions>.rst-other-versions small{opacity:1}#furo-readthedocs-versions .injected .rst-versions{position:unset}#furo-readthedocs-versions:focus-within,#furo-readthedocs-versions:hover{box-shadow:0 0 0 1px var(--color-sidebar-background-border)}#furo-readthedocs-versions:focus-within .rst-current-version,#furo-readthedocs-versions:hover .rst-current-version{background:#1a1c1e;font-size:inherit;height:auto;line-height:inherit;padding:12px;text-align:right}#furo-readthedocs-versions:focus-within .rst-current-version .fa-book,#furo-readthedocs-versions:hover .rst-current-version .fa-book{color:#fff;float:left}#furo-readthedocs-versions:focus-within .fa-caret-down,#furo-readthedocs-versions:hover .fa-caret-down{display:none}#furo-readthedocs-versions:focus-within .injected,#furo-readthedocs-versions:focus-within .rst-current-version,#furo-readthedocs-versions:focus-within .rst-other-versions,#furo-readthedocs-versions:hover .injected,#furo-readthedocs-versions:hover .rst-current-version,#furo-readthedocs-versions:hover .rst-other-versions{display:block}#furo-readthedocs-versions:focus-within>.rst-current-version,#furo-readthedocs-versions:hover>.rst-current-version{display:none}.highlight:hover button.copybtn{color:var(--color-code-foreground)}.highlight button.copybtn{align-items:center;background-color:var(--color-code-background);border:none;color:var(--color-background-item);cursor:pointer;height:1.25em;right:.5rem;top:.625rem;transition:color .3s,opacity .3s;width:1.25em}.highlight button.copybtn:hover{background-color:var(--color-code-background);color:var(--color-brand-content)}.highlight button.copybtn:after{background-color:transparent;color:var(--color-code-foreground);display:none}.highlight button.copybtn.success{color:#22863a;transition:color 0s}.highlight button.copybtn.success:after{display:block}.highlight button.copybtn svg{padding:0}body{--sd-color-primary:var(--color-brand-primary);--sd-color-primary-highlight:var(--color-brand-content);--sd-color-primary-text:var(--color-background-primary);--sd-color-shadow:rgba(0,0,0,.05);--sd-color-card-border:var(--color-card-border);--sd-color-card-border-hover:var(--color-brand-content);--sd-color-card-background:var(--color-card-background);--sd-color-card-text:var(--color-foreground-primary);--sd-color-card-header:var(--color-card-marginals-background);--sd-color-card-footer:var(--color-card-marginals-background);--sd-color-tabs-label-active:var(--color-brand-content);--sd-color-tabs-label-hover:var(--color-foreground-muted);--sd-color-tabs-label-inactive:var(--color-foreground-muted);--sd-color-tabs-underline-active:var(--color-brand-content);--sd-color-tabs-underline-hover:var(--color-foreground-border);--sd-color-tabs-underline-inactive:var(--color-background-border);--sd-color-tabs-overline:var(--color-background-border);--sd-color-tabs-underline:var(--color-background-border)}.sd-tab-content{box-shadow:0 -2px var(--sd-color-tabs-overline),0 1px var(--sd-color-tabs-underline)}.sd-card{box-shadow:0 .1rem .25rem var(--sd-color-shadow),0 0 .0625rem rgba(0,0,0,.1)}.sd-shadow-sm{box-shadow:0 .1rem .25rem var(--sd-color-shadow),0 0 .0625rem rgba(0,0,0,.1)!important}.sd-shadow-md{box-shadow:0 .3rem .75rem var(--sd-color-shadow),0 0 .0625rem rgba(0,0,0,.1)!important}.sd-shadow-lg{box-shadow:0 .6rem 1.5rem var(--sd-color-shadow),0 0 .0625rem rgba(0,0,0,.1)!important}.sd-card-hover:hover{transform:none}.sd-cards-carousel{gap:.25rem;padding:.25rem}body{--tabs--label-text:var(--color-foreground-muted);--tabs--label-text--hover:var(--color-foreground-muted);--tabs--label-text--active:var(--color-brand-content);--tabs--label-text--active--hover:var(--color-brand-content);--tabs--label-background:transparent;--tabs--label-background--hover:transparent;--tabs--label-background--active:transparent;--tabs--label-background--active--hover:transparent;--tabs--padding-x:0.25em;--tabs--margin-x:1em;--tabs--border:var(--color-background-border);--tabs--label-border:transparent;--tabs--label-border--hover:var(--color-foreground-muted);--tabs--label-border--active:var(--color-brand-content);--tabs--label-border--active--hover:var(--color-brand-content)}[role=main] .container{max-width:none;padding-left:0;padding-right:0}.shadow.docutils{border:none;box-shadow:0 .2rem .5rem rgba(0,0,0,.05),0 0 .0625rem rgba(0,0,0,.1)!important}.sphinx-bs .card{background-color:var(--color-background-secondary);color:var(--color-foreground)} +/*# sourceMappingURL=furo-extensions.css.map*/ \ No newline at end of file diff --git a/docs/_source/_build/html/_static/styles/furo-extensions.css.map b/docs/_source/_build/html/_static/styles/furo-extensions.css.map new file mode 100644 index 0000000..68fb7fd --- /dev/null +++ b/docs/_source/_build/html/_static/styles/furo-extensions.css.map @@ -0,0 +1 @@ +{"version":3,"file":"styles/furo-extensions.css","mappings":"AAGA,2BACE,oFACA,4CAKE,6CAHA,YACA,eAEA,CACA,kDACE,yCAEF,8CACE,sCAEJ,8CACE,kDAEJ,2BAGE,uBACA,cAHA,gBACA,UAEA,CAGA,yCACE,mBAEF,gDAEE,gDADA,YACA,CACA,sDACE,gDACF,yDACE,sCAEJ,+CACE,UACA,qDACE,UAGF,mDACE,eAEJ,yEAEE,4DAEA,mHASE,mBAPA,kBAEA,YADA,oBAGA,aADA,gBAIA,CAEA,qIAEE,WADA,UACA,CAEJ,uGACE,aAEF,iUAGE,cAEF,mHACE,aC1EJ,gCACE,mCAEF,0BAEE,mBAUA,8CACA,YAFA,mCAKA,eAZA,cAIA,YADA,YAYA,iCAdA,YAcA,CAEA,gCAEE,8CADA,gCACA,CAEF,gCAGE,6BADA,mCADA,YAEA,CAEF,kCAEE,cADA,mBACA,CACA,wCACE,cAEJ,8BACE,UCzCN,KAEE,6CAA8C,CAC9C,uDAAwD,CACxD,uDAAwD,CAGxD,iCAAsC,CAGtC,+CAAgD,CAChD,uDAAwD,CACxD,uDAAwD,CACxD,oDAAqD,CACrD,6DAA8D,CAC9D,6DAA8D,CAG9D,uDAAwD,CACxD,yDAA0D,CAC1D,4DAA6D,CAC7D,2DAA4D,CAC5D,8DAA+D,CAC/D,iEAAkE,CAClE,uDAAwD,CACxD,wDAAyD,CAG3D,gBACE,qFAGF,SACE,6EAEF,cACE,uFAEF,cACE,uFAEF,cACE,uFAGF,qBACE,eAEF,mBACE,WACA,eChDF,KACE,gDAAiD,CACjD,uDAAwD,CACxD,qDAAsD,CACtD,4DAA6D,CAC7D,oCAAqC,CACrC,2CAA4C,CAC5C,4CAA6C,CAC7C,mDAAoD,CACpD,wBAAyB,CACzB,oBAAqB,CACrB,6CAA8C,CAC9C,gCAAiC,CACjC,yDAA0D,CAC1D,uDAAwD,CACxD,8DAA+D,CCbjE,uBACE,eACA,eACA,gBAGF,iBACE,YACA,+EAGF,iBACE,mDACA","sources":["webpack:///./src/furo/assets/styles/extensions/_readthedocs.sass","webpack:///./src/furo/assets/styles/extensions/_copybutton.sass","webpack:///./src/furo/assets/styles/extensions/_sphinx-design.sass","webpack:///./src/furo/assets/styles/extensions/_sphinx-inline-tabs.sass","webpack:///./src/furo/assets/styles/extensions/_sphinx-panels.sass"],"sourcesContent":["// This file contains the styles used for tweaking how ReadTheDoc's embedded\n// contents would show up inside the theme.\n\n#furo-sidebar-ad-placement\n padding: var(--sidebar-item-spacing-vertical) var(--sidebar-item-spacing-horizontal)\n .ethical-sidebar\n // Remove the border and box-shadow.\n border: none\n box-shadow: none\n // Manage the background colors.\n background: var(--color-background-secondary)\n &:hover\n background: var(--color-background-hover)\n // Ensure the text is legible.\n a\n color: var(--color-foreground-primary)\n\n .ethical-callout a\n color: var(--color-foreground-secondary) !important\n\n#furo-readthedocs-versions\n position: static\n width: 100%\n background: transparent\n display: block\n\n // Make the background color fit with the theme's aesthetic.\n .rst-versions\n background: rgb(26, 28, 30)\n\n .rst-current-version\n cursor: unset\n background: var(--color-sidebar-item-background)\n &:hover\n background: var(--color-sidebar-item-background)\n .fa-book\n color: var(--color-foreground-primary)\n\n > .rst-other-versions\n padding: 0\n small\n opacity: 1\n\n .injected\n .rst-versions\n position: unset\n\n &:hover,\n &:focus-within\n box-shadow: 0 0 0 1px var(--color-sidebar-background-border)\n\n .rst-current-version\n // Undo the tweaks done in RTD's CSS\n font-size: inherit\n line-height: inherit\n height: auto\n text-align: right\n padding: 12px\n\n // Match the rest of the body\n background: #1a1c1e\n\n .fa-book\n float: left\n color: white\n\n .fa-caret-down\n display: none\n\n .rst-current-version,\n .rst-other-versions,\n .injected\n display: block\n\n > .rst-current-version\n display: none\n",".highlight\n &:hover button.copybtn\n color: var(--color-code-foreground)\n\n button.copybtn\n // Align things correctly\n align-items: center\n\n height: 1.25em\n width: 1.25em\n\n top: 0.625rem // $code-spacing-vertical\n right: 0.5rem\n\n // Make it look better\n color: var(--color-background-item)\n background-color: var(--color-code-background)\n border: none\n\n // Change to cursor to make it obvious that you can click on it\n cursor: pointer\n\n // Transition smoothly, for aesthetics\n transition: color 300ms, opacity 300ms\n\n &:hover\n color: var(--color-brand-content)\n background-color: var(--color-code-background)\n\n &::after\n display: none\n color: var(--color-code-foreground)\n background-color: transparent\n\n &.success\n transition: color 0ms\n color: #22863a\n &::after\n display: block\n\n svg\n padding: 0\n","body\n // Colors\n --sd-color-primary: var(--color-brand-primary)\n --sd-color-primary-highlight: var(--color-brand-content)\n --sd-color-primary-text: var(--color-background-primary)\n\n // Shadows\n --sd-color-shadow: rgba(0, 0, 0, 0.05)\n\n // Cards\n --sd-color-card-border: var(--color-card-border)\n --sd-color-card-border-hover: var(--color-brand-content)\n --sd-color-card-background: var(--color-card-background)\n --sd-color-card-text: var(--color-foreground-primary)\n --sd-color-card-header: var(--color-card-marginals-background)\n --sd-color-card-footer: var(--color-card-marginals-background)\n\n // Tabs\n --sd-color-tabs-label-active: var(--color-brand-content)\n --sd-color-tabs-label-hover: var(--color-foreground-muted)\n --sd-color-tabs-label-inactive: var(--color-foreground-muted)\n --sd-color-tabs-underline-active: var(--color-brand-content)\n --sd-color-tabs-underline-hover: var(--color-foreground-border)\n --sd-color-tabs-underline-inactive: var(--color-background-border)\n --sd-color-tabs-overline: var(--color-background-border)\n --sd-color-tabs-underline: var(--color-background-border)\n\n// Tabs\n.sd-tab-content\n box-shadow: 0 -2px var(--sd-color-tabs-overline), 0 1px var(--sd-color-tabs-underline)\n\n// Shadows\n.sd-card // Have a shadow by default\n box-shadow: 0 0.1rem 0.25rem var(--sd-color-shadow), 0 0 0.0625rem rgba(0, 0, 0, 0.1)\n\n.sd-shadow-sm\n box-shadow: 0 0.1rem 0.25rem var(--sd-color-shadow), 0 0 0.0625rem rgba(0, 0, 0, 0.1) !important\n\n.sd-shadow-md\n box-shadow: 0 0.3rem 0.75rem var(--sd-color-shadow), 0 0 0.0625rem rgba(0, 0, 0, 0.1) !important\n\n.sd-shadow-lg\n box-shadow: 0 0.6rem 1.5rem var(--sd-color-shadow), 0 0 0.0625rem rgba(0, 0, 0, 0.1) !important\n\n// Cards\n.sd-card-hover:hover // Don't change scale on hover\n transform: none\n\n.sd-cards-carousel // Have a bit of gap in the carousel by default\n gap: 0.25rem\n padding: 0.25rem\n","// This file contains styles to tweak sphinx-inline-tabs to work well with Furo.\n\nbody\n --tabs--label-text: var(--color-foreground-muted)\n --tabs--label-text--hover: var(--color-foreground-muted)\n --tabs--label-text--active: var(--color-brand-content)\n --tabs--label-text--active--hover: var(--color-brand-content)\n --tabs--label-background: transparent\n --tabs--label-background--hover: transparent\n --tabs--label-background--active: transparent\n --tabs--label-background--active--hover: transparent\n --tabs--padding-x: 0.25em\n --tabs--margin-x: 1em\n --tabs--border: var(--color-background-border)\n --tabs--label-border: transparent\n --tabs--label-border--hover: var(--color-foreground-muted)\n --tabs--label-border--active: var(--color-brand-content)\n --tabs--label-border--active--hover: var(--color-brand-content)\n","// This file contains styles to tweak sphinx-panels to work well with Furo.\n\n// sphinx-panels includes Bootstrap 4, which uses .container which can conflict\n// with docutils' `.. container::` directive.\n[role=\"main\"] .container\n max-width: initial\n padding-left: initial\n padding-right: initial\n\n// Make the panels look nicer!\n.shadow.docutils\n border: none\n box-shadow: 0 0.2rem 0.5rem rgba(0, 0, 0, 0.05), 0 0 0.0625rem rgba(0, 0, 0, 0.1) !important\n\n// Make panel colors respond to dark mode\n.sphinx-bs .card\n background-color: var(--color-background-secondary)\n color: var(--color-foreground)\n"],"names":[],"sourceRoot":""} \ No newline at end of file diff --git a/docs/_source/_build/html/_static/styles/furo.css b/docs/_source/_build/html/_static/styles/furo.css new file mode 100644 index 0000000..a5b614d --- /dev/null +++ b/docs/_source/_build/html/_static/styles/furo.css @@ -0,0 +1,2 @@ +/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */html{line-height:1.15;-webkit-text-size-adjust:100%}body{margin:0}main{display:block}h1{font-size:2em;margin:.67em 0}hr{box-sizing:content-box;height:0;overflow:visible}pre{font-family:monospace,monospace;font-size:1em}a{background-color:transparent}abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}b,strong{font-weight:bolder}code,kbd,samp{font-family:monospace,monospace;font-size:1em}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}img{border-style:none}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;line-height:1.15;margin:0}button,input{overflow:visible}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring,button:-moz-focusring{outline:1px dotted ButtonText}fieldset{padding:.35em .75em .625em}legend{box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}progress{vertical-align:baseline}textarea{overflow:auto}[type=checkbox],[type=radio]{box-sizing:border-box;padding:0}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}details{display:block}summary{display:list-item}[hidden],template{display:none}@media print{.content-icon-container,.headerlink,.mobile-header,.related-pages{display:none!important}.highlight{border:.1pt solid var(--color-foreground-border)}a,blockquote,dl,ol,p,pre,table,ul{page-break-inside:avoid}caption,figure,h1,h2,h3,h4,h5,h6,img{page-break-after:avoid;page-break-inside:avoid}dl,ol,ul{page-break-before:avoid}}.visually-hidden{height:1px!important;margin:-1px!important;overflow:hidden!important;padding:0!important;position:absolute!important;width:1px!important;clip:rect(0,0,0,0)!important;background:var(--color-background-primary);border:0!important;color:var(--color-foreground-primary);white-space:nowrap!important}:-moz-focusring{outline:auto}body{--font-stack:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji;--font-stack--monospace:"SFMono-Regular",Menlo,Consolas,Monaco,Liberation Mono,Lucida Console,monospace;--font-stack--headings:var(--font-stack);--font-size--normal:100%;--font-size--small:87.5%;--font-size--small--2:81.25%;--font-size--small--3:75%;--font-size--small--4:62.5%;--sidebar-caption-font-size:var(--font-size--small--2);--sidebar-item-font-size:var(--font-size--small);--sidebar-search-input-font-size:var(--font-size--small);--toc-font-size:var(--font-size--small--3);--toc-font-size--mobile:var(--font-size--normal);--toc-title-font-size:var(--font-size--small--4);--admonition-font-size:0.8125rem;--admonition-title-font-size:0.8125rem;--code-font-size:var(--font-size--small--2);--api-font-size:var(--font-size--small);--header-height:calc(var(--sidebar-item-line-height) + var(--sidebar-item-spacing-vertical)*4);--header-padding:0.5rem;--sidebar-tree-space-above:1.5rem;--sidebar-caption-space-above:1rem;--sidebar-item-line-height:1rem;--sidebar-item-spacing-vertical:0.5rem;--sidebar-item-spacing-horizontal:1rem;--sidebar-item-height:calc(var(--sidebar-item-line-height) + var(--sidebar-item-spacing-vertical)*2);--sidebar-expander-width:var(--sidebar-item-height);--sidebar-search-space-above:0.5rem;--sidebar-search-input-spacing-vertical:0.5rem;--sidebar-search-input-spacing-horizontal:0.5rem;--sidebar-search-input-height:1rem;--sidebar-search-icon-size:var(--sidebar-search-input-height);--toc-title-padding:0.25rem 0;--toc-spacing-vertical:1.5rem;--toc-spacing-horizontal:1.5rem;--toc-item-spacing-vertical:0.4rem;--toc-item-spacing-horizontal:1rem;--icon-search:url('data:image/svg+xml;charset=utf-8,');--icon-pencil:url('data:image/svg+xml;charset=utf-8,');--icon-abstract:url('data:image/svg+xml;charset=utf-8,');--icon-info:url('data:image/svg+xml;charset=utf-8,');--icon-flame:url('data:image/svg+xml;charset=utf-8,');--icon-question:url('data:image/svg+xml;charset=utf-8,');--icon-warning:url('data:image/svg+xml;charset=utf-8,');--icon-failure:url('data:image/svg+xml;charset=utf-8,');--icon-spark:url('data:image/svg+xml;charset=utf-8,');--color-admonition-title--caution:#ff9100;--color-admonition-title-background--caution:rgba(255,145,0,.2);--color-admonition-title--warning:#ff9100;--color-admonition-title-background--warning:rgba(255,145,0,.2);--color-admonition-title--danger:#ff5252;--color-admonition-title-background--danger:rgba(255,82,82,.2);--color-admonition-title--attention:#ff5252;--color-admonition-title-background--attention:rgba(255,82,82,.2);--color-admonition-title--error:#ff5252;--color-admonition-title-background--error:rgba(255,82,82,.2);--color-admonition-title--hint:#00c852;--color-admonition-title-background--hint:rgba(0,200,82,.2);--color-admonition-title--tip:#00c852;--color-admonition-title-background--tip:rgba(0,200,82,.2);--color-admonition-title--important:#00bfa5;--color-admonition-title-background--important:rgba(0,191,165,.2);--color-admonition-title--note:#00b0ff;--color-admonition-title-background--note:rgba(0,176,255,.2);--color-admonition-title--seealso:#448aff;--color-admonition-title-background--seealso:rgba(68,138,255,.2);--color-admonition-title--admonition-todo:grey;--color-admonition-title-background--admonition-todo:hsla(0,0%,50%,.2);--color-admonition-title:#651fff;--color-admonition-title-background:rgba(101,31,255,.2);--icon-admonition-default:var(--icon-abstract);--color-topic-title:#14b8a6;--color-topic-title-background:rgba(20,184,166,.2);--icon-topic-default:var(--icon-pencil);--color-problematic:#b30000;--color-foreground-primary:#000;--color-foreground-secondary:#5a5c63;--color-foreground-muted:#6b6f76;--color-foreground-border:#878787;--color-background-primary:#fff;--color-background-secondary:#f8f9fb;--color-background-hover:#efeff4;--color-background-hover--transparent:#efeff400;--color-background-border:#eeebee;--color-background-item:#ccc;--color-announcement-background:#000000dd;--color-announcement-text:#eeebee;--color-brand-primary:#0a4bff;--color-brand-content:#2757dd;--color-brand-visited:#872ee0;--color-api-background:var(--color-background-hover--transparent);--color-api-background-hover:var(--color-background-hover);--color-api-overall:var(--color-foreground-secondary);--color-api-name:var(--color-problematic);--color-api-pre-name:var(--color-problematic);--color-api-paren:var(--color-foreground-secondary);--color-api-keyword:var(--color-foreground-primary);--color-api-added:#21632c;--color-api-added-border:#38a84d;--color-api-changed:#046172;--color-api-changed-border:#06a1bc;--color-api-deprecated:#605706;--color-api-deprecated-border:#f0d90f;--color-api-removed:#b30000;--color-api-removed-border:#ff5c5c;--color-highlight-on-target:#ffc;--color-inline-code-background:var(--color-background-secondary);--color-highlighted-background:#def;--color-highlighted-text:var(--color-foreground-primary);--color-guilabel-background:#ddeeff80;--color-guilabel-border:#bedaf580;--color-guilabel-text:var(--color-foreground-primary);--color-admonition-background:transparent;--color-table-header-background:var(--color-background-secondary);--color-table-border:var(--color-background-border);--color-card-border:var(--color-background-secondary);--color-card-background:transparent;--color-card-marginals-background:var(--color-background-secondary);--color-header-background:var(--color-background-primary);--color-header-border:var(--color-background-border);--color-header-text:var(--color-foreground-primary);--color-sidebar-background:var(--color-background-secondary);--color-sidebar-background-border:var(--color-background-border);--color-sidebar-brand-text:var(--color-foreground-primary);--color-sidebar-caption-text:var(--color-foreground-muted);--color-sidebar-link-text:var(--color-foreground-secondary);--color-sidebar-link-text--top-level:var(--color-brand-primary);--color-sidebar-item-background:var(--color-sidebar-background);--color-sidebar-item-background--current:var( --color-sidebar-item-background );--color-sidebar-item-background--hover:linear-gradient(90deg,var(--color-background-hover--transparent) 0%,var(--color-background-hover) var(--sidebar-item-spacing-horizontal),var(--color-background-hover) 100%);--color-sidebar-item-expander-background:transparent;--color-sidebar-item-expander-background--hover:var( --color-background-hover );--color-sidebar-search-text:var(--color-foreground-primary);--color-sidebar-search-background:var(--color-background-secondary);--color-sidebar-search-background--focus:var(--color-background-primary);--color-sidebar-search-border:var(--color-background-border);--color-sidebar-search-icon:var(--color-foreground-muted);--color-toc-background:var(--color-background-primary);--color-toc-title-text:var(--color-foreground-muted);--color-toc-item-text:var(--color-foreground-secondary);--color-toc-item-text--hover:var(--color-foreground-primary);--color-toc-item-text--active:var(--color-brand-primary);--color-content-foreground:var(--color-foreground-primary);--color-content-background:transparent;--color-link:var(--color-brand-content);--color-link-underline:var(--color-background-border);--color-link--hover:var(--color-brand-content);--color-link-underline--hover:var(--color-foreground-border);--color-link--visited:var(--color-brand-visited);--color-link-underline--visited:var(--color-background-border);--color-link--visited--hover:var(--color-brand-visited);--color-link-underline--visited--hover:var(--color-foreground-border)}.only-light{display:block!important}html body .only-dark{display:none!important}@media not print{body[data-theme=dark]{--color-problematic:#ee5151;--color-foreground-primary:#cfd0d0;--color-foreground-secondary:#9ca0a5;--color-foreground-muted:#81868d;--color-foreground-border:#666;--color-background-primary:#131416;--color-background-secondary:#1a1c1e;--color-background-hover:#1e2124;--color-background-hover--transparent:#1e212400;--color-background-border:#303335;--color-background-item:#444;--color-announcement-background:#000000dd;--color-announcement-text:#eeebee;--color-brand-primary:#3d94ff;--color-brand-content:#5ca5ff;--color-brand-visited:#b27aeb;--color-highlighted-background:#083563;--color-guilabel-background:#08356380;--color-guilabel-border:#13395f80;--color-api-keyword:var(--color-foreground-secondary);--color-highlight-on-target:#330;--color-api-added:#3db854;--color-api-added-border:#267334;--color-api-changed:#09b0ce;--color-api-changed-border:#056d80;--color-api-deprecated:#b1a10b;--color-api-deprecated-border:#6e6407;--color-api-removed:#ff7575;--color-api-removed-border:#b03b3b;--color-admonition-background:#18181a;--color-card-border:var(--color-background-secondary);--color-card-background:#18181a;--color-card-marginals-background:var(--color-background-hover)}html body[data-theme=dark] .only-light{display:none!important}body[data-theme=dark] .only-dark{display:block!important}@media(prefers-color-scheme:dark){body:not([data-theme=light]){--color-problematic:#ee5151;--color-foreground-primary:#cfd0d0;--color-foreground-secondary:#9ca0a5;--color-foreground-muted:#81868d;--color-foreground-border:#666;--color-background-primary:#131416;--color-background-secondary:#1a1c1e;--color-background-hover:#1e2124;--color-background-hover--transparent:#1e212400;--color-background-border:#303335;--color-background-item:#444;--color-announcement-background:#000000dd;--color-announcement-text:#eeebee;--color-brand-primary:#3d94ff;--color-brand-content:#5ca5ff;--color-brand-visited:#b27aeb;--color-highlighted-background:#083563;--color-guilabel-background:#08356380;--color-guilabel-border:#13395f80;--color-api-keyword:var(--color-foreground-secondary);--color-highlight-on-target:#330;--color-api-added:#3db854;--color-api-added-border:#267334;--color-api-changed:#09b0ce;--color-api-changed-border:#056d80;--color-api-deprecated:#b1a10b;--color-api-deprecated-border:#6e6407;--color-api-removed:#ff7575;--color-api-removed-border:#b03b3b;--color-admonition-background:#18181a;--color-card-border:var(--color-background-secondary);--color-card-background:#18181a;--color-card-marginals-background:var(--color-background-hover)}html body:not([data-theme=light]) .only-light{display:none!important}body:not([data-theme=light]) .only-dark{display:block!important}}}body[data-theme=auto] .theme-toggle svg.theme-icon-when-auto-light{display:block}@media(prefers-color-scheme:dark){body[data-theme=auto] .theme-toggle svg.theme-icon-when-auto-dark{display:block}body[data-theme=auto] .theme-toggle svg.theme-icon-when-auto-light{display:none}}body[data-theme=dark] .theme-toggle svg.theme-icon-when-dark,body[data-theme=light] .theme-toggle svg.theme-icon-when-light{display:block}body{font-family:var(--font-stack)}code,kbd,pre,samp{font-family:var(--font-stack--monospace)}body{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}article{line-height:1.5}h1,h2,h3,h4,h5,h6{border-radius:.5rem;font-family:var(--font-stack--headings);font-weight:700;line-height:1.25;margin:.5rem -.5rem;padding-left:.5rem;padding-right:.5rem}h1+p,h2+p,h3+p,h4+p,h5+p,h6+p{margin-top:0}h1{font-size:2.5em;margin-bottom:1rem}h1,h2{margin-top:1.75rem}h2{font-size:2em}h3{font-size:1.5em}h4{font-size:1.25em}h5{font-size:1.125em}h6{font-size:1em}small{font-size:80%;opacity:75%}p{margin-bottom:.75rem;margin-top:.5rem}hr.docutils{background-color:var(--color-background-border);border:0;height:1px;margin:2rem 0;padding:0}.centered{text-align:center}a{color:var(--color-link);text-decoration:underline;text-decoration-color:var(--color-link-underline)}a:visited{color:var(--color-link--visited);text-decoration-color:var(--color-link-underline--visited)}a:visited:hover{color:var(--color-link--visited--hover);text-decoration-color:var(--color-link-underline--visited--hover)}a:hover{color:var(--color-link--hover);text-decoration-color:var(--color-link-underline--hover)}a.muted-link{color:inherit}a.muted-link:hover{color:var(--color-link--hover);text-decoration-color:var(--color-link-underline--hover)}a.muted-link:hover:visited{color:var(--color-link--visited--hover);text-decoration-color:var(--color-link-underline--visited--hover)}html{overflow-x:hidden;overflow-y:scroll;scroll-behavior:smooth}.sidebar-scroll,.toc-scroll,article[role=main] *{scrollbar-color:var(--color-foreground-border) transparent;scrollbar-width:thin}body,html{height:100%}.skip-to-content,body,html{background:var(--color-background-primary);color:var(--color-foreground-primary)}.skip-to-content{border-radius:1rem;left:.25rem;padding:1rem;position:fixed;top:.25rem;transform:translateY(-200%);transition:transform .3s ease-in-out;z-index:40}.skip-to-content:focus-within{transform:translateY(0)}article{background:var(--color-content-background);color:var(--color-content-foreground);overflow-wrap:break-word}.page{display:flex;min-height:100%}.mobile-header{background-color:var(--color-header-background);border-bottom:1px solid var(--color-header-border);color:var(--color-header-text);display:none;height:var(--header-height);width:100%;z-index:10}.mobile-header.scrolled{border-bottom:none;box-shadow:0 0 .2rem rgba(0,0,0,.1),0 .2rem .4rem rgba(0,0,0,.2)}.mobile-header .header-center a{color:var(--color-header-text);text-decoration:none}.main{display:flex;flex:1}.sidebar-drawer{background:var(--color-sidebar-background);border-right:1px solid var(--color-sidebar-background-border);box-sizing:border-box;display:flex;justify-content:flex-end;min-width:15em;width:calc(50% - 26em)}.sidebar-container,.toc-drawer{box-sizing:border-box;width:15em}.toc-drawer{background:var(--color-toc-background);padding-right:1rem}.sidebar-sticky,.toc-sticky{display:flex;flex-direction:column;height:min(100%,100vh);height:100vh;position:sticky;top:0}.sidebar-scroll,.toc-scroll{flex-grow:1;flex-shrink:1;overflow:auto;scroll-behavior:smooth}.content{display:flex;flex-direction:column;justify-content:space-between;padding:0 3em;width:46em}.icon{display:inline-block;height:1rem;width:1rem}.icon svg{height:100%;width:100%}.announcement{align-items:center;background-color:var(--color-announcement-background);color:var(--color-announcement-text);display:flex;height:var(--header-height);overflow-x:auto}.announcement+.page{min-height:calc(100% - var(--header-height))}.announcement-content{box-sizing:border-box;min-width:100%;padding:.5rem;text-align:center;white-space:nowrap}.announcement-content a{color:var(--color-announcement-text);text-decoration-color:var(--color-announcement-text)}.announcement-content a:hover{color:var(--color-announcement-text);text-decoration-color:var(--color-link--hover)}.no-js .theme-toggle-container{display:none}.theme-toggle-container{display:flex}.theme-toggle{background:transparent;border:none;cursor:pointer;display:flex;padding:0}.theme-toggle svg{color:var(--color-foreground-primary);display:none;height:1.25rem;width:1.25rem}.theme-toggle-header{align-items:center;display:flex;justify-content:center}.nav-overlay-icon,.toc-overlay-icon{cursor:pointer;display:none}.nav-overlay-icon .icon,.toc-overlay-icon .icon{color:var(--color-foreground-secondary);height:1.5rem;width:1.5rem}.nav-overlay-icon,.toc-header-icon{align-items:center;justify-content:center}.toc-content-icon{height:1.5rem;width:1.5rem}.content-icon-container{display:flex;float:right;gap:.5rem;margin-bottom:1rem;margin-left:1rem;margin-top:1.5rem}.content-icon-container .edit-this-page svg,.content-icon-container .view-this-page svg{color:inherit;height:1.25rem;width:1.25rem}.sidebar-toggle{display:none;position:absolute}.sidebar-toggle[name=__toc]{left:20px}.sidebar-toggle:checked{left:40px}.overlay{background-color:rgba(0,0,0,.54);height:0;opacity:0;position:fixed;top:0;transition:width 0s,height 0s,opacity .25s ease-out;width:0}.sidebar-overlay{z-index:20}.toc-overlay{z-index:40}.sidebar-drawer{transition:left .25s ease-in-out;z-index:30}.toc-drawer{transition:right .25s ease-in-out;z-index:50}#__navigation:checked~.sidebar-overlay{height:100%;opacity:1;width:100%}#__navigation:checked~.page .sidebar-drawer{left:0;top:0}#__toc:checked~.toc-overlay{height:100%;opacity:1;width:100%}#__toc:checked~.page .toc-drawer{right:0;top:0}.back-to-top{background:var(--color-background-primary);border-radius:1rem;box-shadow:0 .2rem .5rem rgba(0,0,0,.05),0 0 1px 0 hsla(220,9%,46%,.502);display:none;font-size:.8125rem;left:0;margin-left:50%;padding:.5rem .75rem .5rem .5rem;position:fixed;text-decoration:none;top:1rem;transform:translateX(-50%);z-index:10}.back-to-top svg{height:1rem;width:1rem;fill:currentColor;display:inline-block}.back-to-top span{margin-left:.25rem}.show-back-to-top .back-to-top{align-items:center;display:flex}@media(min-width:97em){html{font-size:110%}}@media(max-width:82em){.toc-content-icon{display:flex}.toc-drawer{border-left:1px solid var(--color-background-muted);height:100vh;position:fixed;right:-15em;top:0}.toc-tree{border-left:none;font-size:var(--toc-font-size--mobile)}.sidebar-drawer{width:calc(50% - 18.5em)}}@media(max-width:67em){.content{margin-left:auto;margin-right:auto;padding:0 1em}}@media(max-width:63em){.nav-overlay-icon{display:flex}.sidebar-drawer{height:100vh;left:-15em;position:fixed;top:0;width:15em}.theme-toggle-header,.toc-header-icon{display:flex}.theme-toggle-content,.toc-content-icon{display:none}.mobile-header{align-items:center;display:flex;justify-content:space-between;position:sticky;top:0}.mobile-header .header-left,.mobile-header .header-right{display:flex;height:var(--header-height);padding:0 var(--header-padding)}.mobile-header .header-left label,.mobile-header .header-right label{height:100%;-webkit-user-select:none;-moz-user-select:none;user-select:none;width:100%}.nav-overlay-icon .icon,.theme-toggle svg{height:1.5rem;width:1.5rem}:target{scroll-margin-top:calc(var(--header-height) + 2.5rem)}.back-to-top{top:calc(var(--header-height) + .5rem)}.page{flex-direction:column;justify-content:center}}@media(max-width:48em){.content{overflow-x:auto;width:100%}}@media(max-width:46em){article[role=main] aside.sidebar{float:none;margin:1rem 0;width:100%}}.admonition,.topic{background:var(--color-admonition-background);border-radius:.2rem;box-shadow:0 .2rem .5rem rgba(0,0,0,.05),0 0 .0625rem rgba(0,0,0,.1);font-size:var(--admonition-font-size);margin:1rem auto;overflow:hidden;padding:0 .5rem .5rem;page-break-inside:avoid}.admonition>:nth-child(2),.topic>:nth-child(2){margin-top:0}.admonition>:last-child,.topic>:last-child{margin-bottom:0}.admonition p.admonition-title,p.topic-title{font-size:var(--admonition-title-font-size);font-weight:500;line-height:1.3;margin:0 -.5rem .5rem;padding:.4rem .5rem .4rem 2rem;position:relative}.admonition p.admonition-title:before,p.topic-title:before{content:"";height:1rem;left:.5rem;position:absolute;width:1rem}p.admonition-title{background-color:var(--color-admonition-title-background)}p.admonition-title:before{background-color:var(--color-admonition-title);-webkit-mask-image:var(--icon-admonition-default);mask-image:var(--icon-admonition-default);-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat}p.topic-title{background-color:var(--color-topic-title-background)}p.topic-title:before{background-color:var(--color-topic-title);-webkit-mask-image:var(--icon-topic-default);mask-image:var(--icon-topic-default);-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat}.admonition{border-left:.2rem solid var(--color-admonition-title)}.admonition.caution{border-left-color:var(--color-admonition-title--caution)}.admonition.caution>.admonition-title{background-color:var(--color-admonition-title-background--caution)}.admonition.caution>.admonition-title:before{background-color:var(--color-admonition-title--caution);-webkit-mask-image:var(--icon-spark);mask-image:var(--icon-spark)}.admonition.warning{border-left-color:var(--color-admonition-title--warning)}.admonition.warning>.admonition-title{background-color:var(--color-admonition-title-background--warning)}.admonition.warning>.admonition-title:before{background-color:var(--color-admonition-title--warning);-webkit-mask-image:var(--icon-warning);mask-image:var(--icon-warning)}.admonition.danger{border-left-color:var(--color-admonition-title--danger)}.admonition.danger>.admonition-title{background-color:var(--color-admonition-title-background--danger)}.admonition.danger>.admonition-title:before{background-color:var(--color-admonition-title--danger);-webkit-mask-image:var(--icon-spark);mask-image:var(--icon-spark)}.admonition.attention{border-left-color:var(--color-admonition-title--attention)}.admonition.attention>.admonition-title{background-color:var(--color-admonition-title-background--attention)}.admonition.attention>.admonition-title:before{background-color:var(--color-admonition-title--attention);-webkit-mask-image:var(--icon-warning);mask-image:var(--icon-warning)}.admonition.error{border-left-color:var(--color-admonition-title--error)}.admonition.error>.admonition-title{background-color:var(--color-admonition-title-background--error)}.admonition.error>.admonition-title:before{background-color:var(--color-admonition-title--error);-webkit-mask-image:var(--icon-failure);mask-image:var(--icon-failure)}.admonition.hint{border-left-color:var(--color-admonition-title--hint)}.admonition.hint>.admonition-title{background-color:var(--color-admonition-title-background--hint)}.admonition.hint>.admonition-title:before{background-color:var(--color-admonition-title--hint);-webkit-mask-image:var(--icon-question);mask-image:var(--icon-question)}.admonition.tip{border-left-color:var(--color-admonition-title--tip)}.admonition.tip>.admonition-title{background-color:var(--color-admonition-title-background--tip)}.admonition.tip>.admonition-title:before{background-color:var(--color-admonition-title--tip);-webkit-mask-image:var(--icon-info);mask-image:var(--icon-info)}.admonition.important{border-left-color:var(--color-admonition-title--important)}.admonition.important>.admonition-title{background-color:var(--color-admonition-title-background--important)}.admonition.important>.admonition-title:before{background-color:var(--color-admonition-title--important);-webkit-mask-image:var(--icon-flame);mask-image:var(--icon-flame)}.admonition.note{border-left-color:var(--color-admonition-title--note)}.admonition.note>.admonition-title{background-color:var(--color-admonition-title-background--note)}.admonition.note>.admonition-title:before{background-color:var(--color-admonition-title--note);-webkit-mask-image:var(--icon-pencil);mask-image:var(--icon-pencil)}.admonition.seealso{border-left-color:var(--color-admonition-title--seealso)}.admonition.seealso>.admonition-title{background-color:var(--color-admonition-title-background--seealso)}.admonition.seealso>.admonition-title:before{background-color:var(--color-admonition-title--seealso);-webkit-mask-image:var(--icon-info);mask-image:var(--icon-info)}.admonition.admonition-todo{border-left-color:var(--color-admonition-title--admonition-todo)}.admonition.admonition-todo>.admonition-title{background-color:var(--color-admonition-title-background--admonition-todo)}.admonition.admonition-todo>.admonition-title:before{background-color:var(--color-admonition-title--admonition-todo);-webkit-mask-image:var(--icon-pencil);mask-image:var(--icon-pencil)}.admonition-todo>.admonition-title{text-transform:uppercase}dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple) dd{margin-left:2rem}dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple) dd>:first-child{margin-top:.125rem}dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple) .field-list,dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple) dd>:last-child{margin-bottom:.75rem}dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple) .field-list>dt{font-size:var(--font-size--small);text-transform:uppercase}dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple) .field-list dd:empty{margin-bottom:.5rem}dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple) .field-list dd>ul{margin-left:-1.2rem}dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple) .field-list dd>ul>li>p:nth-child(2){margin-top:0}dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple) .field-list dd>ul>li>p+p:last-child:empty{margin-bottom:0;margin-top:0}dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple)>dt{color:var(--color-api-overall)}.sig:not(.sig-inline){background:var(--color-api-background);border-radius:.25rem;font-family:var(--font-stack--monospace);font-size:var(--api-font-size);font-weight:700;margin-left:-.25rem;margin-right:-.25rem;padding:.25rem .5rem .25rem 3em;text-indent:-2.5em;transition:background .1s ease-out}.sig:not(.sig-inline):hover{background:var(--color-api-background-hover)}.sig:not(.sig-inline) a.reference .viewcode-link{font-weight:400;width:4.25rem}em.property,span.property{font-style:normal}em.property:first-child,span.property:first-child{color:var(--color-api-keyword)}.sig-name{color:var(--color-api-name)}.sig-prename{color:var(--color-api-pre-name);font-weight:400}.sig-paren{color:var(--color-api-paren)}.sig-param{font-style:normal}div.deprecated,div.versionadded,div.versionchanged,div.versionremoved{border-left:.1875rem solid;border-radius:.125rem;padding-left:.75rem}div.deprecated p,div.versionadded p,div.versionchanged p,div.versionremoved p{margin-bottom:.125rem;margin-top:.125rem}div.versionadded{border-color:var(--color-api-added-border)}div.versionadded .versionmodified{color:var(--color-api-added)}div.versionchanged{border-color:var(--color-api-changed-border)}div.versionchanged .versionmodified{color:var(--color-api-changed)}div.deprecated{border-color:var(--color-api-deprecated-border)}div.deprecated .versionmodified{color:var(--color-api-deprecated)}div.versionremoved{border-color:var(--color-api-removed-border)}div.versionremoved .versionmodified{color:var(--color-api-removed)}.viewcode-back,.viewcode-link{float:right;text-align:right}.line-block{margin-bottom:.75rem;margin-top:.5rem}.line-block .line-block{margin-bottom:0;margin-top:0;padding-left:1rem}.code-block-caption,article p.caption,table>caption{font-size:var(--font-size--small);text-align:center}.toctree-wrapper.compound .caption,.toctree-wrapper.compound :not(.caption)>.caption-text{font-size:var(--font-size--small);margin-bottom:0;text-align:initial;text-transform:uppercase}.toctree-wrapper.compound>ul{margin-bottom:0;margin-top:0}.sig-inline,code.literal{background:var(--color-inline-code-background);border-radius:.2em;font-size:var(--font-size--small--2);padding:.1em .2em}pre.literal-block .sig-inline,pre.literal-block code.literal{font-size:inherit;padding:0}p .sig-inline,p code.literal{border:1px solid var(--color-background-border)}.sig-inline{font-family:var(--font-stack--monospace)}div[class*=" highlight-"],div[class^=highlight-]{display:flex;margin:1em 0}div[class*=" highlight-"] .table-wrapper,div[class^=highlight-] .table-wrapper,pre{margin:0;padding:0}pre{overflow:auto}article[role=main] .highlight pre{line-height:1.5}.highlight pre,pre.literal-block{font-size:var(--code-font-size);padding:.625rem .875rem}pre.literal-block{background-color:var(--color-code-background);border-radius:.2rem;color:var(--color-code-foreground);margin-bottom:1rem;margin-top:1rem}.highlight{border-radius:.2rem;width:100%}.highlight .gp,.highlight span.linenos{pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none}.highlight .hll{display:block;margin-left:-.875rem;margin-right:-.875rem;padding-left:.875rem;padding-right:.875rem}.code-block-caption{background-color:var(--color-code-background);border-bottom:1px solid;border-radius:.25rem;border-bottom-left-radius:0;border-bottom-right-radius:0;border-color:var(--color-background-border);color:var(--color-code-foreground);display:flex;font-weight:300;padding:.625rem .875rem}.code-block-caption+div[class]{margin-top:0}.code-block-caption+div[class]>.highlight{border-top-left-radius:0;border-top-right-radius:0}.highlighttable{display:block;width:100%}.highlighttable tbody{display:block}.highlighttable tr{display:flex}.highlighttable td.linenos{background-color:var(--color-code-background);border-bottom-left-radius:.2rem;border-top-left-radius:.2rem;color:var(--color-code-foreground);padding:.625rem 0 .625rem .875rem}.highlighttable .linenodiv{box-shadow:-.0625rem 0 var(--color-foreground-border) inset;font-size:var(--code-font-size);padding-right:.875rem}.highlighttable td.code{display:block;flex:1;overflow:hidden;padding:0}.highlighttable td.code .highlight{border-bottom-left-radius:0;border-top-left-radius:0}.highlight span.linenos{box-shadow:-.0625rem 0 var(--color-foreground-border) inset;display:inline-block;margin-right:.875rem;padding-left:0;padding-right:.875rem}.footnote-reference{font-size:var(--font-size--small--4);vertical-align:super}dl.footnote.brackets{color:var(--color-foreground-secondary);display:grid;font-size:var(--font-size--small);grid-template-columns:max-content auto}dl.footnote.brackets dt{margin:0}dl.footnote.brackets dt>.fn-backref{margin-left:.25rem}dl.footnote.brackets dt:after{content:":"}dl.footnote.brackets dt .brackets:before{content:"["}dl.footnote.brackets dt .brackets:after{content:"]"}dl.footnote.brackets dd{margin:0;padding:0 1rem}aside.footnote{color:var(--color-foreground-secondary);font-size:var(--font-size--small)}aside.footnote>span,div.citation>span{float:left;font-weight:500;padding-right:.25rem}aside.footnote>:not(span),div.citation>p{margin-left:2rem}img{box-sizing:border-box;height:auto;max-width:100%}article .figure,article figure{border-radius:.2rem;margin:0}article .figure :last-child,article figure :last-child{margin-bottom:0}article .align-left{clear:left;float:left;margin:0 1rem 1rem}article .align-right{clear:right;float:right;margin:0 1rem 1rem}article .align-center,article .align-default{display:block;margin-left:auto;margin-right:auto;text-align:center}article table.align-default{display:table;text-align:initial}.domainindex-jumpbox,.genindex-jumpbox{border-bottom:1px solid var(--color-background-border);border-top:1px solid var(--color-background-border);padding:.25rem}.domainindex-section h2,.genindex-section h2{margin-bottom:.5rem;margin-top:.75rem}.domainindex-section ul,.genindex-section ul{margin-bottom:0;margin-top:0}ol,ul{margin-bottom:1rem;margin-top:1rem;padding-left:1.2rem}ol li>p:first-child,ul li>p:first-child{margin-bottom:.25rem;margin-top:.25rem}ol li>p:last-child,ul li>p:last-child{margin-top:.25rem}ol li>ol,ol li>ul,ul li>ol,ul li>ul{margin-bottom:.5rem;margin-top:.5rem}ol.arabic{list-style:decimal}ol.loweralpha{list-style:lower-alpha}ol.upperalpha{list-style:upper-alpha}ol.lowerroman{list-style:lower-roman}ol.upperroman{list-style:upper-roman}.simple li>ol,.simple li>ul,.toctree-wrapper li>ol,.toctree-wrapper li>ul{margin-bottom:0;margin-top:0}.field-list dt,.option-list dt,dl.footnote dt,dl.glossary dt,dl.simple dt,dl:not([class]) dt{font-weight:500;margin-top:.25rem}.field-list dt+dt,.option-list dt+dt,dl.footnote dt+dt,dl.glossary dt+dt,dl.simple dt+dt,dl:not([class]) dt+dt{margin-top:0}.field-list dt .classifier:before,.option-list dt .classifier:before,dl.footnote dt .classifier:before,dl.glossary dt .classifier:before,dl.simple dt .classifier:before,dl:not([class]) dt .classifier:before{content:":";margin-left:.2rem;margin-right:.2rem}.field-list dd ul,.field-list dd>p:first-child,.option-list dd ul,.option-list dd>p:first-child,dl.footnote dd ul,dl.footnote dd>p:first-child,dl.glossary dd ul,dl.glossary dd>p:first-child,dl.simple dd ul,dl.simple dd>p:first-child,dl:not([class]) dd ul,dl:not([class]) dd>p:first-child{margin-top:.125rem}.field-list dd ul,.option-list dd ul,dl.footnote dd ul,dl.glossary dd ul,dl.simple dd ul,dl:not([class]) dd ul{margin-bottom:.125rem}.math-wrapper{overflow-x:auto;width:100%}div.math{position:relative;text-align:center}div.math .headerlink,div.math:focus .headerlink{display:none}div.math:hover .headerlink{display:inline-block}div.math span.eqno{position:absolute;right:.5rem;top:50%;transform:translateY(-50%);z-index:1}abbr[title]{cursor:help}.problematic{color:var(--color-problematic)}kbd:not(.compound){background-color:var(--color-background-secondary);border:1px solid var(--color-foreground-border);border-radius:.2rem;box-shadow:0 .0625rem 0 rgba(0,0,0,.2),inset 0 0 0 .125rem var(--color-background-primary);color:var(--color-foreground-primary);display:inline-block;font-size:var(--font-size--small--3);margin:0 .2rem;padding:0 .2rem;vertical-align:text-bottom}blockquote{background:var(--color-background-secondary);border-left:4px solid var(--color-background-border);margin-left:0;margin-right:0;padding:.5rem 1rem}blockquote .attribution{font-weight:600;text-align:right}blockquote.highlights,blockquote.pull-quote{font-size:1.25em}blockquote.epigraph,blockquote.pull-quote{border-left-width:0;border-radius:.5rem}blockquote.highlights{background:transparent;border-left-width:0}p .reference img{vertical-align:middle}p.rubric{font-size:1.125em;font-weight:700;line-height:1.25}dd p.rubric{font-size:var(--font-size--small);font-weight:inherit;line-height:inherit;text-transform:uppercase}article .sidebar{background-color:var(--color-background-secondary);border:1px solid var(--color-background-border);border-radius:.2rem;clear:right;float:right;margin-left:1rem;margin-right:0;width:30%}article .sidebar>*{padding-left:1rem;padding-right:1rem}article .sidebar>ol,article .sidebar>ul{padding-left:2.2rem}article .sidebar .sidebar-title{border-bottom:1px solid var(--color-background-border);font-weight:500;margin:0;padding:.5rem 1rem}[role=main] .table-wrapper.container{margin-bottom:.5rem;margin-top:1rem;overflow-x:auto;padding:.2rem .2rem .75rem;width:100%}table.docutils{border-collapse:collapse;border-radius:.2rem;border-spacing:0;box-shadow:0 .2rem .5rem rgba(0,0,0,.05),0 0 .0625rem rgba(0,0,0,.1)}table.docutils th{background:var(--color-table-header-background)}table.docutils td,table.docutils th{border-bottom:1px solid var(--color-table-border);border-left:1px solid var(--color-table-border);border-right:1px solid var(--color-table-border);padding:0 .25rem}table.docutils td p,table.docutils th p{margin:.25rem}table.docutils td:first-child,table.docutils th:first-child{border-left:none}table.docutils td:last-child,table.docutils th:last-child{border-right:none}table.docutils td.text-left,table.docutils th.text-left{text-align:left}table.docutils td.text-right,table.docutils th.text-right{text-align:right}table.docutils td.text-center,table.docutils th.text-center{text-align:center}:target{scroll-margin-top:2.5rem}@media(max-width:67em){:target{scroll-margin-top:calc(2.5rem + var(--header-height))}section>span:target{scroll-margin-top:calc(2.8rem + var(--header-height))}}.headerlink{font-weight:100;-webkit-user-select:none;-moz-user-select:none;user-select:none}.code-block-caption>.headerlink,dl dt>.headerlink,figcaption p>.headerlink,h1>.headerlink,h2>.headerlink,h3>.headerlink,h4>.headerlink,h5>.headerlink,h6>.headerlink,p.caption>.headerlink,table>caption>.headerlink{margin-left:.5rem;visibility:hidden}.code-block-caption:hover>.headerlink,dl dt:hover>.headerlink,figcaption p:hover>.headerlink,h1:hover>.headerlink,h2:hover>.headerlink,h3:hover>.headerlink,h4:hover>.headerlink,h5:hover>.headerlink,h6:hover>.headerlink,p.caption:hover>.headerlink,table>caption:hover>.headerlink{visibility:visible}.code-block-caption>.toc-backref,dl dt>.toc-backref,figcaption p>.toc-backref,h1>.toc-backref,h2>.toc-backref,h3>.toc-backref,h4>.toc-backref,h5>.toc-backref,h6>.toc-backref,p.caption>.toc-backref,table>caption>.toc-backref{color:inherit;text-decoration-line:none}figure:hover>figcaption>p>.headerlink,table:hover>caption>.headerlink{visibility:visible}:target>h1:first-of-type,:target>h2:first-of-type,:target>h3:first-of-type,:target>h4:first-of-type,:target>h5:first-of-type,:target>h6:first-of-type,span:target~h1:first-of-type,span:target~h2:first-of-type,span:target~h3:first-of-type,span:target~h4:first-of-type,span:target~h5:first-of-type,span:target~h6:first-of-type{background-color:var(--color-highlight-on-target)}:target>h1:first-of-type code.literal,:target>h2:first-of-type code.literal,:target>h3:first-of-type code.literal,:target>h4:first-of-type code.literal,:target>h5:first-of-type code.literal,:target>h6:first-of-type code.literal,span:target~h1:first-of-type code.literal,span:target~h2:first-of-type code.literal,span:target~h3:first-of-type code.literal,span:target~h4:first-of-type code.literal,span:target~h5:first-of-type code.literal,span:target~h6:first-of-type code.literal{background-color:transparent}.literal-block-wrapper:target .code-block-caption,.this-will-duplicate-information-and-it-is-still-useful-here li :target,figure:target,table:target>caption{background-color:var(--color-highlight-on-target)}dt:target{background-color:var(--color-highlight-on-target)!important}.footnote-reference:target,.footnote>dt:target+dd{background-color:var(--color-highlight-on-target)}.guilabel{background-color:var(--color-guilabel-background);border:1px solid var(--color-guilabel-border);border-radius:.5em;color:var(--color-guilabel-text);font-size:.9em;padding:0 .3em}footer{display:flex;flex-direction:column;font-size:var(--font-size--small);margin-top:2rem}.bottom-of-page{align-items:center;border-top:1px solid var(--color-background-border);color:var(--color-foreground-secondary);display:flex;justify-content:space-between;line-height:1.5;margin-top:1rem;padding-bottom:1rem;padding-top:1rem}@media(max-width:46em){.bottom-of-page{flex-direction:column-reverse;gap:.25rem;text-align:center}}.bottom-of-page .left-details{font-size:var(--font-size--small)}.bottom-of-page .right-details{display:flex;flex-direction:column;gap:.25rem;text-align:right}.bottom-of-page .icons{display:flex;font-size:1rem;gap:.25rem;justify-content:flex-end}.bottom-of-page .icons a{text-decoration:none}.bottom-of-page .icons img,.bottom-of-page .icons svg{font-size:1.125rem;height:1em;width:1em}.related-pages a{align-items:center;display:flex;text-decoration:none}.related-pages a:hover .page-info .title{color:var(--color-link);text-decoration:underline;text-decoration-color:var(--color-link-underline)}.related-pages a svg.furo-related-icon,.related-pages a svg.furo-related-icon>use{color:var(--color-foreground-border);flex-shrink:0;height:.75rem;margin:0 .5rem;width:.75rem}.related-pages a.next-page{clear:right;float:right;max-width:50%;text-align:right}.related-pages a.prev-page{clear:left;float:left;max-width:50%}.related-pages a.prev-page svg{transform:rotate(180deg)}.page-info{display:flex;flex-direction:column;overflow-wrap:anywhere}.next-page .page-info{align-items:flex-end}.page-info .context{align-items:center;color:var(--color-foreground-muted);display:flex;font-size:var(--font-size--small);padding-bottom:.1rem;text-decoration:none}ul.search{list-style:none;padding-left:0}ul.search li{border-bottom:1px solid var(--color-background-border);padding:1rem 0}[role=main] .highlighted{background-color:var(--color-highlighted-background);color:var(--color-highlighted-text)}.sidebar-brand{display:flex;flex-direction:column;flex-shrink:0;padding:var(--sidebar-item-spacing-vertical) var(--sidebar-item-spacing-horizontal);text-decoration:none}.sidebar-brand-text{color:var(--color-sidebar-brand-text);font-size:1.5rem;overflow-wrap:break-word}.sidebar-brand-text,.sidebar-logo-container{margin:var(--sidebar-item-spacing-vertical) 0}.sidebar-logo{display:block;margin:0 auto;max-width:100%}.sidebar-search-container{align-items:center;background:var(--color-sidebar-search-background);display:flex;margin-top:var(--sidebar-search-space-above);position:relative}.sidebar-search-container:focus-within,.sidebar-search-container:hover{background:var(--color-sidebar-search-background--focus)}.sidebar-search-container:before{background-color:var(--color-sidebar-search-icon);content:"";height:var(--sidebar-search-icon-size);left:var(--sidebar-item-spacing-horizontal);-webkit-mask-image:var(--icon-search);mask-image:var(--icon-search);position:absolute;width:var(--sidebar-search-icon-size)}.sidebar-search{background:transparent;border:none;border-bottom:1px solid var(--color-sidebar-search-border);border-top:1px solid var(--color-sidebar-search-border);box-sizing:border-box;color:var(--color-sidebar-search-foreground);padding:var(--sidebar-search-input-spacing-vertical) var(--sidebar-search-input-spacing-horizontal) var(--sidebar-search-input-spacing-vertical) calc(var(--sidebar-item-spacing-horizontal) + var(--sidebar-search-input-spacing-horizontal) + var(--sidebar-search-icon-size));width:100%;z-index:10}.sidebar-search:focus{outline:none}.sidebar-search::-moz-placeholder{font-size:var(--sidebar-search-input-font-size)}.sidebar-search::placeholder{font-size:var(--sidebar-search-input-font-size)}#searchbox .highlight-link{margin:0;padding:var(--sidebar-item-spacing-vertical) var(--sidebar-item-spacing-horizontal) 0;text-align:center}#searchbox .highlight-link a{color:var(--color-sidebar-search-icon);font-size:var(--font-size--small--2)}.sidebar-tree{font-size:var(--sidebar-item-font-size);margin-bottom:var(--sidebar-item-spacing-vertical);margin-top:var(--sidebar-tree-space-above)}.sidebar-tree ul{display:flex;flex-direction:column;list-style:none;margin-bottom:0;margin-top:0;padding:0}.sidebar-tree li{margin:0;position:relative}.sidebar-tree li>ul{margin-left:var(--sidebar-item-spacing-horizontal)}.sidebar-tree .icon,.sidebar-tree .reference{color:var(--color-sidebar-link-text)}.sidebar-tree .reference{box-sizing:border-box;display:inline-block;height:100%;line-height:var(--sidebar-item-line-height);overflow-wrap:anywhere;padding:var(--sidebar-item-spacing-vertical) var(--sidebar-item-spacing-horizontal);text-decoration:none;width:100%}.sidebar-tree .reference:hover{background:var(--color-sidebar-item-background--hover);color:var(--color-sidebar-link-text)}.sidebar-tree .reference.external:after{color:var(--color-sidebar-link-text);content:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%23607d8b' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' viewBox='0 0 24 24'%3E%3Cpath stroke='none' d='M0 0h24v24H0z'/%3E%3Cpath d='M11 7H6a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2-2v-5M10 14 20 4M15 4h5v5'/%3E%3C/svg%3E");margin:0 .25rem;vertical-align:middle}.sidebar-tree .current-page>.reference{font-weight:700}.sidebar-tree label{align-items:center;cursor:pointer;display:flex;height:var(--sidebar-item-height);justify-content:center;position:absolute;right:0;top:0;-webkit-user-select:none;-moz-user-select:none;user-select:none;width:var(--sidebar-expander-width)}.sidebar-tree .caption,.sidebar-tree :not(.caption)>.caption-text{color:var(--color-sidebar-caption-text);font-size:var(--sidebar-caption-font-size);font-weight:700;margin:var(--sidebar-caption-space-above) 0 0 0;padding:var(--sidebar-item-spacing-vertical) var(--sidebar-item-spacing-horizontal);text-transform:uppercase}.sidebar-tree li.has-children>.reference{padding-right:var(--sidebar-expander-width)}.sidebar-tree .toctree-l1>.reference,.sidebar-tree .toctree-l1>label .icon{color:var(--color-sidebar-link-text--top-level)}.sidebar-tree label{background:var(--color-sidebar-item-expander-background)}.sidebar-tree label:hover{background:var(--color-sidebar-item-expander-background--hover)}.sidebar-tree .current>.reference{background:var(--color-sidebar-item-background--current)}.sidebar-tree .current>.reference:hover{background:var(--color-sidebar-item-background--hover)}.toctree-checkbox{display:none;position:absolute}.toctree-checkbox~ul{display:none}.toctree-checkbox~label .icon svg{transform:rotate(90deg)}.toctree-checkbox:checked~ul{display:block}.toctree-checkbox:checked~label .icon svg{transform:rotate(-90deg)}.toc-title-container{padding:var(--toc-title-padding);padding-top:var(--toc-spacing-vertical)}.toc-title{color:var(--color-toc-title-text);font-size:var(--toc-title-font-size);padding-left:var(--toc-spacing-horizontal);text-transform:uppercase}.no-toc{display:none}.toc-tree-container{padding-bottom:var(--toc-spacing-vertical)}.toc-tree{border-left:1px solid var(--color-background-border);font-size:var(--toc-font-size);line-height:1.3;padding-left:calc(var(--toc-spacing-horizontal) - var(--toc-item-spacing-horizontal))}.toc-tree>ul>li:first-child{padding-top:0}.toc-tree>ul>li:first-child>ul{padding-left:0}.toc-tree>ul>li:first-child>a{display:none}.toc-tree ul{list-style-type:none;margin-bottom:0;margin-top:0;padding-left:var(--toc-item-spacing-horizontal)}.toc-tree li{padding-top:var(--toc-item-spacing-vertical)}.toc-tree li.scroll-current>.reference{color:var(--color-toc-item-text--active);font-weight:700}.toc-tree a.reference{color:var(--color-toc-item-text);overflow-wrap:anywhere;text-decoration:none}.toc-scroll{max-height:100vh;overflow-y:scroll}.contents:not(.this-will-duplicate-information-and-it-is-still-useful-here){background:rgba(255,0,0,.25);color:var(--color-problematic)}.contents:not(.this-will-duplicate-information-and-it-is-still-useful-here):before{content:"ERROR: Adding a table of contents in Furo-based documentation is unnecessary, and does not work well with existing styling. Add a 'this-will-duplicate-information-and-it-is-still-useful-here' class, if you want an escape hatch."}.text-align\:left>p{text-align:left}.text-align\:center>p{text-align:center}.text-align\:right>p{text-align:right} +/*# sourceMappingURL=furo.css.map*/ \ No newline at end of file diff --git a/docs/_source/_build/html/_static/styles/furo.css.map b/docs/_source/_build/html/_static/styles/furo.css.map new file mode 100644 index 0000000..db1dec1 --- /dev/null +++ b/docs/_source/_build/html/_static/styles/furo.css.map @@ -0,0 +1 @@ +{"version":3,"file":"styles/furo.css","mappings":"AAAA,2EAA2E,CAU3E,KACE,gBAAiB,CACjB,6BACF,CASA,KACE,QACF,CAMA,KACE,aACF,CAOA,GACE,aAAc,CACd,cACF,CAUA,GACE,sBAAuB,CACvB,QAAS,CACT,gBACF,CAOA,IACE,+BAAiC,CACjC,aACF,CASA,EACE,4BACF,CAOA,YACE,kBAAmB,CACnB,yBAA0B,CAC1B,gCACF,CAMA,SAEE,kBACF,CAOA,cAGE,+BAAiC,CACjC,aACF,CAeA,QAEE,aAAc,CACd,aAAc,CACd,iBAAkB,CAClB,uBACF,CAEA,IACE,aACF,CAEA,IACE,SACF,CASA,IACE,iBACF,CAUA,sCAKE,mBAAoB,CACpB,cAAe,CACf,gBAAiB,CACjB,QACF,CAOA,aAEE,gBACF,CAOA,cAEE,mBACF,CAMA,gDAIE,yBACF,CAMA,wHAIE,iBAAkB,CAClB,SACF,CAMA,4GAIE,6BACF,CAMA,SACE,0BACF,CASA,OACE,qBAAsB,CACtB,aAAc,CACd,aAAc,CACd,cAAe,CACf,SAAU,CACV,kBACF,CAMA,SACE,uBACF,CAMA,SACE,aACF,CAOA,6BAEE,qBAAsB,CACtB,SACF,CAMA,kFAEE,WACF,CAOA,cACE,4BAA6B,CAC7B,mBACF,CAMA,yCACE,uBACF,CAOA,6BACE,yBAA0B,CAC1B,YACF,CASA,QACE,aACF,CAMA,QACE,iBACF,CAiBA,kBACE,YACF,CCvVA,aAcE,kEACE,uBAOF,WACE,iDAMF,kCACE,wBAEF,qCAEE,uBADA,uBACA,CAEF,SACE,wBAtBA,CCpBJ,iBAGE,qBAEA,sBACA,0BAFA,oBAHA,4BACA,oBAKA,6BAIA,2CAFA,mBACA,sCAFA,4BAGA,CAEF,gBACE,aCPF,KCCE,mHAGA,wGAGA,wCAAyC,CAEzC,wBAAyB,CACzB,wBAAyB,CACzB,4BAA6B,CAC7B,yBAA0B,CAC1B,2BAA4B,CAG5B,sDAAuD,CACvD,gDAAiD,CACjD,wDAAyD,CAGzD,0CAA2C,CAC3C,gDAAiD,CACjD,gDAAiD,CAKjD,gCAAiC,CACjC,sCAAuC,CAGvC,2CAA4C,CAG5C,uCAAwC,CCnCxC,+FAIA,uBAAwB,CAGxB,iCAAkC,CAClC,kCAAmC,CAEnC,+BAAgC,CAChC,sCAAuC,CACvC,sCAAuC,CACvC,qGAIA,mDAAoD,CAEpD,mCAAoC,CACpC,8CAA+C,CAC/C,gDAAiD,CACjD,kCAAmC,CACnC,6DAA8D,CAG9D,6BAA8B,CAC9B,6BAA8B,CAC9B,+BAAgC,CAChC,kCAAmC,CACnC,kCAAmC,CCRjC,+jBCaA,iqCAZF,iaCXA,8KAOA,4SAWA,4SAUA,0CACA,gEAGA,0CAGA,gEAGA,yCACA,+DAIA,4CACA,kEAGA,wCAUA,8DACA,uCAGA,4DACA,sCACA,2DAGA,4CACA,kEACA,uCAGA,6DACA,2GAGA,sHAEA,yFAEA,+CACA,+EAGA,4MAOA,gCACA,sHAIA,kCACA,uEACA,gEACA,4DACA,kEAGA,2DACA,sDACA,0CACA,8CACA,wGAGA,0BACA,iCAGA,+DACA,+BACA,sCACA,+DAEA,kGACA,oCACA,yDACA,sCL3HF,kCAEA,sDAIA,0CKyHE,kEAIA,oDACA,sDAGA,oCACA,oEAEA,0DACA,qDAIA,oDACA,6DAIA,iEAIA,2DAIA,2DAGA,4DACA,gEAIA,gEAEA,gFAEA,oNASA,qDLtKE,gFAGE,4DAIF,oEKgHF,yEAEA,6DAGA,0DAEA,uDACA,qDACA,wDAIA,6DAIA,yDACA,2DAIA,uCAGA,wCACA,sDAGA,+CAGA,6DAEA,iDACA,+DAEA,wDAEA,sEAMA,0DACA,sBACA,mEL5JI,wEAEA,iCACE,+BAMN,wEAGA,iCACE,kFAEA,uEAIF,gEACE,8BAGF,qEMzDA,sCAKA,wFAKA,iCAIA,0BAWA,iCACA,4BACA,mCAGA,+BAEA,sCACA,4BAEA,mCAEA,sCAKA,sDAIA,gCAEA,gEAQF,wCAME,sBACA,kCAKA,uBAEA,gEAIA,2BAIA,mCAEA,qCACA,iCAGE,+BACA,wEAEE,iCACA,kFAGF,6BACA,0CACF,kCAEE,8BACE,8BACA,qEAEE,sCACA,wFClFN,iCAGF,2DACE,4BACA,oCAKF,8BAGE,sCACA,+DAIA,sCAEA,sDAGA,gCACA,gEAGA,+CAEA,sBACE,yCAGF,uBACA,sEAIA,aAEA,mCAIA,kEACA,aACA,oEACA,YAIA,EAQE,4HAGA,gDACE,mBACA,wCAON,wCAGE,0DACA,mBAKA,mBACA,CANA,uCAKA,iBALA,iBAWA,mBAGF,mBACE,mDAIF,+BAEE,CAEA,yBAFA,kBAMA,CAJA,GACA,aAGA,mBAEF,wBAEE,iBACA,iBAEA,OACA,aAGF,CAHE,WAGF,GAEE,oBAEA,CAJF,gBAIE,aAEA,+CAKA,UANA,WACA,cADA,SAMA,WACA,iBAEE,GAMF,wBANE,yBAMF,kDACA,WAEA,gCACA,2DAGA,iBACE,uCAEJ,kEAIE,uCAGA,yDACE,cACA,+DAEA,yDAEE,mEAMJ,kEAMA,uBACA,kBAEA,uBACA,kDAKA,0DAIA,CALA,oBAKA,WACA,WAQA,4BAFF,0CAEE,CARA,qCAsBA,CAdA,iBAEA,kBACE,aADF,4BACE,WAMF,2BAGF,qCAEE,CAXE,UAWF,+BAGA,uBAEA,SAEA,0CAIE,CANF,qCAEA,CAIE,2DACE,gBAIN,+CAIA,CAEA,kDAKE,CAPF,8BAEA,CAOE,YACA,CAjBI,2BAGN,CAHM,WAcJ,UAGA,CAEA,2GAIF,iCAGE,8BAIA,qBACA,oBACF,uBAOI,0CAIA,CATF,6DAKE,CALF,sBASE,qCAKF,CACE,cACA,CAFF,sBAEE,CACA,+BAEA,qBAEE,WAKN,aACE,sCAGA,mBAEA,6BAMA,kCACA,CAJA,sBACA,aAEA,CAJA,eACA,MAIA,2FAEA,UAGA,YACA,sBACE,8BAEA,CALF,aACA,WAIE,OACA,oBAEF,uBACE,WAEF,YAFE,UAEF,eAgBA,kBACE,CAhBA,qDAQF,qCAGF,CAGI,YACF,CAJF,2BAGI,CAEA,eACA,qBAGA,mEAEA,qBACA,8BAIA,kBADF,kBACE,yBAEJ,oCAGI,qDAIJ,+BAGI,oCAEA,+CAQF,4CACE,yBACF,2BAOE,sBACA,CAHA,WACA,CAFF,cACE,CAJA,YAGF,CAEE,SAEA,mBAGA,kDAEE,CAJF,cAEA,cAEE,sBAEA,mBADA,YACA,uBACA,mDACE,CADF,YACE,iDAEA,uCAEN,+DAOE,mBADF,sBACE,mBAGF,aACE,sCAIA,aADF,WACE,CAKF,SACE,CAHJ,kBAEE,CAJE,gBAEJ,CAHI,iBAMA,yFAKA,aACA,eACA,cCxaJ,iBAEE,aADA,iBACA,6BAEA,kCAEA,SACA,UAIA,gCACA,CALA,SAEA,SAEA,CAJA,wEAEA,CAFA,OAKA,CAGA,mDACE,iBAGF,gCACE,CADF,UACE,aAEJ,iCAEE,CAFF,UAEE,wCAEA,WACA,WADA,UACA,CACA,4CAGA,MACA,CADA,KACA,wCACA,UAGA,CAJA,UAIA,6DAUA,0CACE,CAFF,mBAEE,wEACA,CAVA,YACA,CAMF,mBAJE,OAOA,gBAJJ,gCACE,CANE,cACA,CAHA,oBACA,CAGA,QAGJ,CAII,0BACA,CADA,UACA,wCAEJ,kBACE,0DACA,gCACE,kBACA,CADA,YACA,oEACA,2CAMF,mDAII,CALN,YACE,CANE,cAKJ,CACE,iBAII,kEACA,yCACE,kDACA,yDACE,+CACA,uBANN,CAMM,+BANN,uCACE,qDACA,4BAEE,mBADA,0CACA,CADA,qBACA,0DACE,wCACA,sGALJ,oCACA,sBACE,kBAFF,UAEE,2CACA,wFACE,cACA,kEANN,uBACE,iDACA,CADA,UACA,0DACE,wDAEE,iEACA,qEANN,sCACE,CAGE,iBAHF,gBAGE,qBACE,CAJJ,uBACA,gDACE,wDACA,6DAHF,2CACA,CADA,gBACA,eACE,CAGE,sBANN,8BACE,CAII,iBAFF,4DACA,WACE,YADF,uCACE,6EACA,2BANN,8CACE,kDACA,0CACE,8BACA,yFACE,sBACA,sFALJ,mEACA,sBACE,kEACA,6EACE,uCACA,kEALJ,qGAEE,kEACA,6EACE,uCACA,kEALJ,8CACA,uDACE,sEACA,2EACE,sCACA,iEALJ,mGACA,qCACE,oDACA,0DACE,6GACA,gDAGR,yDCvEA,sEACE,CACA,6GACE,gEACF,iGAIF,wFACE,qDAGA,mGAEE,2CAEF,4FACE,gCACF,wGACE,8DAEE,6FAIA,iJAKN,6GACE,gDAKF,yDACA,qCAGA,6BACA,kBACA,qDAKA,oCAEA,+DAGA,2CAGE,oDAIA,oEAEE,qBAEN,wDAEE,uCACE,kEAGJ,CACE,6CACA,uDAGF,CACE,mCAEF,yDAIE,gEAGA,CAEA,wHAIF,sDACE,+DAEE,sCAGF,8BACA,oCACE,oHAIF,gBACE,yGAIF,mBChHA,2MCDF,4HAQE,wKAOA,8HCbA,mBAEA,6HAIE,YACA,mIAaJ,gBAPE,YAOF,4FAKE,qDAuBE,sCACA,CAHA,oBAEA,CAbF,wCACE,CALF,8BAIA,CARE,eAIF,CAKE,mBAEF,qBAEE,CAIF,+BACE,mBACA,CAGA,kCACA,6BAIF,4CAIA,kDACE,6BACA,2BAGF,iBACE,mDAGA,8BACA,WAGJ,2BACE,cAGA,+BACA,CAHA,eAGA,wCACA,YACA,iBACA,uEAGA,0BACA,2CAEA,8EAGI,qBACA,CAFF,kBAEE,4DAMJ,mCACE,4BAGA,oBAGF,4CACE,qCACA,8BACA,gBACA,+CAEA,iCAEF,iCACE,oBACA,4CACA,qCAGF,8BAEE,+BAEA,WAEA,8BACE,oBACA,CADA,gBACA,yBAKF,gBADF,YACE,CACA,iBACA,qDAEA,mDCvIJ,2FAMA,iCACE,CACA,eAEA,CAFA,mBADA,wBAIA,8BACA,gBADA,YACA,0BAEE,8CAGA,wDAIE,gFAGE,iBAEN,wCAKF,+CACE,CACA,oDAEF,kDAIE,YAEF,CAHE,YAGF,CCpCE,mFAFA,QACA,UAIA,CAHA,IAGA,gDAGE,eACA,iEAGF,wBAEE,mBAMA,6CAEF,CAJE,mBACA,CAGF,kCAGE,CARF,kBACE,CAHA,eAUA,YACA,mBACA,CAFA,UAEA,wCC/BJ,mBACE,CDkCE,wBACA,sBCpCJ,iBACE,mDACA,2CACA,sBAGA,qBCDA,6CAIE,CATJ,uBAKE,CDGE,oBACF,yDAEE,CCDE,2CAGF,CAJA,kCACE,CDJJ,aAKE,eCXJ,CDME,uBCOE,gCACE,YAEF,2CAEE,wBACA,0BAIF,iBAEA,cADF,UACE,uBAEA,iCAEA,wCAEA,6CAMA,CAYF,gCATI,4BASJ,CAZE,mCAEE,iCAUJ,4BAGE,4DADA,+BACA,CAHF,qBAGE,sCACE,OAEF,iBAHA,SAGA,iHACE,2DAKF,CANA,8EAMA,uSAEE,kBAEF,+FACE,yCCjEJ,WACA,yBAGA,uBACA,gBAEA,uCAIA,CAJA,iCAIA,uCAGA,UACE,gBACA,qBAEA,0CClBJ,gBACE,KAGF,qBACE,YAGF,CAHE,cAGF,gCAEE,mBACA,iEAEA,oCACA,wCAEA,sBACA,WAEA,CAFA,YAEA,8EAEA,mCAFA,iBAEA,6BAIA,wEAKA,sDAIE,CARF,mDAIA,CAIE,cAEF,8CAIA,oBAFE,iBAEF,8CAGE,eAEF,CAFE,YAEF,OAEE,kBAGJ,CAJI,eACA,CAFF,mBAKF,yCCjDE,oBACA,CAFA,iBAEA,uCAKE,iBACA,qCAGA,mBCZJ,CDWI,gBCXJ,6BAEE,eACA,sBAGA,eAEA,sBACA,oDACA,iGAMA,gBAFE,YAEF,8FAME,iJCnBF,YACA,gNAWE,gDAEF,iSAaE,kBACE,gHAKF,oCACE,eACF,CADE,UACF,8CACE,gDACF,wCACE,oBCtCJ,oBAEF,6BACE,QACE,kDAGF,yBACE,kDAmBA,kDAEF,CAhBA,+CAaA,CAbA,oBAaA,0FACE,CADF,gGAfF,cACE,gBACA,CAaA,0BAGA,mQACE,gBAGF,oMACE,iBACA,CAFF,eACE,CADF,gBAEE,aAGJ,iCAEE,CAFF,wCAEE,wBAUE,+VAIE,uEAHA,2BAGA,wXAKJ,iDAGF,CARM,+CACE,iDAIN,CALI,gBAQN,mHACE,gBAGF,2DACE,0EAOA,0EAGF,gBAEE,6DCjFA,kDACA,gCACA,qDAGA,qBACA,qDCDA,cACA,eAEA,yBAGF,sBAEE,iBACA,sNAWA,iBACE,kBACA,wRAgBA,kBAEA,iOAgBA,uCACE,uEAEA,kBAEF,qUAuBE,iDAIJ,CACA,geCzFF,4BAEE,CAQA,6JACA,iDAIA,sEAGA,mDAOF,iDAGE,4DAIA,8CACA,qDAEE,eAFF,cAEE,oBAEF,uBAFE,kCAGA,eACA,iBACA,mBAIA,mDACA,CAHA,uCAEA,CAJA,0CACA,CAIA,gBAJA,gBACA,oBADA,gBAIA,wBAEJ,gBAGE,6BACA,YAHA,iBAGA,gCACA,iEAEA,6CACA,sDACA,0BADA,wBACA,0BACA,oIAIA,mBAFA,YAEA,qBACA,0CAIE,uBAEF,CAHA,yBACE,CAEF,iDACE,mFAKJ,oCACE,CANE,aAKJ,CACE,qEAIA,YAFA,WAEA,CAHA,aACA,CAEA,gBACE,4BACA,sBADA,aACA,gCAMF,oCACA,yDACA,2CAEA,qBAGE,kBAEA,CACA,mCAIF,CARE,YACA,CAOF,iCAEE,CAPA,oBACA,CAQA,oBACE,uDAEJ,sDAGA,CAHA,cAGA,0BACE,oDAIA,oCACA,4BACA,sBAGA,cAEA,oFAGA,sBAEA,yDACE,CAIF,iBAJE,wBAIF,6CAHE,6CAKA,eACA,aACA,CADA,cACA,yCAGJ,kBACE,CAKA,iDAEA,CARF,aACE,4CAGA,kBAIA,wEAGA,wDAGA,kCAOA,iDAGA,CAPF,WAEE,sCAEA,CAJF,2CACE,CAMA,qCACA,+BARF,kBACE,qCAOA,iBAsBA,sBACE,CAvBF,WAKA,CACE,0DAIF,CALA,uDACE,CANF,sBAqBA,4CACA,CALA,gRAIA,YAEE,6CAEN,mCAEE,+CASA,6EAIA,4BChNA,SDmNA,qFCnNA,gDACA,sCAGA,qCACA,sDACA,CAKA,kDAGA,CARA,0CAQA,kBAGA,YACA,sBACA,iBAFA,gBADF,YACE,CAHA,SAKA,kBAEA,SAFA,iBAEA,uEAGA,CAEE,6CAFF,oCAgBI,CAdF,yBACE,qBACF,CAGF,oBACE,CAIF,WACE,CALA,2CAGA,uBACF,CACE,mFAGE,CALF,qBAEA,UAGE,gCAIF,sDAEA,CALE,oCAKF,yCC7CJ,oCACE,CD+CA,yXAQE,sCCrDJ,wCAGA,oCACE","sources":["webpack:///./node_modules/normalize.css/normalize.css","webpack:///./src/furo/assets/styles/base/_print.sass","webpack:///./src/furo/assets/styles/base/_screen-readers.sass","webpack:///./src/furo/assets/styles/base/_theme.sass","webpack:///./src/furo/assets/styles/variables/_fonts.scss","webpack:///./src/furo/assets/styles/variables/_spacing.scss","webpack:///./src/furo/assets/styles/variables/_icons.scss","webpack:///./src/furo/assets/styles/variables/_admonitions.scss","webpack:///./src/furo/assets/styles/variables/_colors.scss","webpack:///./src/furo/assets/styles/base/_typography.sass","webpack:///./src/furo/assets/styles/_scaffold.sass","webpack:///./src/furo/assets/styles/content/_admonitions.sass","webpack:///./src/furo/assets/styles/content/_api.sass","webpack:///./src/furo/assets/styles/content/_blocks.sass","webpack:///./src/furo/assets/styles/content/_captions.sass","webpack:///./src/furo/assets/styles/content/_code.sass","webpack:///./src/furo/assets/styles/content/_footnotes.sass","webpack:///./src/furo/assets/styles/content/_images.sass","webpack:///./src/furo/assets/styles/content/_indexes.sass","webpack:///./src/furo/assets/styles/content/_lists.sass","webpack:///./src/furo/assets/styles/content/_math.sass","webpack:///./src/furo/assets/styles/content/_misc.sass","webpack:///./src/furo/assets/styles/content/_rubrics.sass","webpack:///./src/furo/assets/styles/content/_sidebar.sass","webpack:///./src/furo/assets/styles/content/_tables.sass","webpack:///./src/furo/assets/styles/content/_target.sass","webpack:///./src/furo/assets/styles/content/_gui-labels.sass","webpack:///./src/furo/assets/styles/components/_footer.sass","webpack:///./src/furo/assets/styles/components/_sidebar.sass","webpack:///./src/furo/assets/styles/components/_table_of_contents.sass","webpack:///./src/furo/assets/styles/_shame.sass"],"sourcesContent":["/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */\n\n/* Document\n ========================================================================== */\n\n/**\n * 1. Correct the line height in all browsers.\n * 2. Prevent adjustments of font size after orientation changes in iOS.\n */\n\nhtml {\n line-height: 1.15; /* 1 */\n -webkit-text-size-adjust: 100%; /* 2 */\n}\n\n/* Sections\n ========================================================================== */\n\n/**\n * Remove the margin in all browsers.\n */\n\nbody {\n margin: 0;\n}\n\n/**\n * Render the `main` element consistently in IE.\n */\n\nmain {\n display: block;\n}\n\n/**\n * Correct the font size and margin on `h1` elements within `section` and\n * `article` contexts in Chrome, Firefox, and Safari.\n */\n\nh1 {\n font-size: 2em;\n margin: 0.67em 0;\n}\n\n/* Grouping content\n ========================================================================== */\n\n/**\n * 1. Add the correct box sizing in Firefox.\n * 2. Show the overflow in Edge and IE.\n */\n\nhr {\n box-sizing: content-box; /* 1 */\n height: 0; /* 1 */\n overflow: visible; /* 2 */\n}\n\n/**\n * 1. Correct the inheritance and scaling of font size in all browsers.\n * 2. Correct the odd `em` font sizing in all browsers.\n */\n\npre {\n font-family: monospace, monospace; /* 1 */\n font-size: 1em; /* 2 */\n}\n\n/* Text-level semantics\n ========================================================================== */\n\n/**\n * Remove the gray background on active links in IE 10.\n */\n\na {\n background-color: transparent;\n}\n\n/**\n * 1. Remove the bottom border in Chrome 57-\n * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.\n */\n\nabbr[title] {\n border-bottom: none; /* 1 */\n text-decoration: underline; /* 2 */\n text-decoration: underline dotted; /* 2 */\n}\n\n/**\n * Add the correct font weight in Chrome, Edge, and Safari.\n */\n\nb,\nstrong {\n font-weight: bolder;\n}\n\n/**\n * 1. Correct the inheritance and scaling of font size in all browsers.\n * 2. Correct the odd `em` font sizing in all browsers.\n */\n\ncode,\nkbd,\nsamp {\n font-family: monospace, monospace; /* 1 */\n font-size: 1em; /* 2 */\n}\n\n/**\n * Add the correct font size in all browsers.\n */\n\nsmall {\n font-size: 80%;\n}\n\n/**\n * Prevent `sub` and `sup` elements from affecting the line height in\n * all browsers.\n */\n\nsub,\nsup {\n font-size: 75%;\n line-height: 0;\n position: relative;\n vertical-align: baseline;\n}\n\nsub {\n bottom: -0.25em;\n}\n\nsup {\n top: -0.5em;\n}\n\n/* Embedded content\n ========================================================================== */\n\n/**\n * Remove the border on images inside links in IE 10.\n */\n\nimg {\n border-style: none;\n}\n\n/* Forms\n ========================================================================== */\n\n/**\n * 1. Change the font styles in all browsers.\n * 2. Remove the margin in Firefox and Safari.\n */\n\nbutton,\ninput,\noptgroup,\nselect,\ntextarea {\n font-family: inherit; /* 1 */\n font-size: 100%; /* 1 */\n line-height: 1.15; /* 1 */\n margin: 0; /* 2 */\n}\n\n/**\n * Show the overflow in IE.\n * 1. Show the overflow in Edge.\n */\n\nbutton,\ninput { /* 1 */\n overflow: visible;\n}\n\n/**\n * Remove the inheritance of text transform in Edge, Firefox, and IE.\n * 1. Remove the inheritance of text transform in Firefox.\n */\n\nbutton,\nselect { /* 1 */\n text-transform: none;\n}\n\n/**\n * Correct the inability to style clickable types in iOS and Safari.\n */\n\nbutton,\n[type=\"button\"],\n[type=\"reset\"],\n[type=\"submit\"] {\n -webkit-appearance: button;\n}\n\n/**\n * Remove the inner border and padding in Firefox.\n */\n\nbutton::-moz-focus-inner,\n[type=\"button\"]::-moz-focus-inner,\n[type=\"reset\"]::-moz-focus-inner,\n[type=\"submit\"]::-moz-focus-inner {\n border-style: none;\n padding: 0;\n}\n\n/**\n * Restore the focus styles unset by the previous rule.\n */\n\nbutton:-moz-focusring,\n[type=\"button\"]:-moz-focusring,\n[type=\"reset\"]:-moz-focusring,\n[type=\"submit\"]:-moz-focusring {\n outline: 1px dotted ButtonText;\n}\n\n/**\n * Correct the padding in Firefox.\n */\n\nfieldset {\n padding: 0.35em 0.75em 0.625em;\n}\n\n/**\n * 1. Correct the text wrapping in Edge and IE.\n * 2. Correct the color inheritance from `fieldset` elements in IE.\n * 3. Remove the padding so developers are not caught out when they zero out\n * `fieldset` elements in all browsers.\n */\n\nlegend {\n box-sizing: border-box; /* 1 */\n color: inherit; /* 2 */\n display: table; /* 1 */\n max-width: 100%; /* 1 */\n padding: 0; /* 3 */\n white-space: normal; /* 1 */\n}\n\n/**\n * Add the correct vertical alignment in Chrome, Firefox, and Opera.\n */\n\nprogress {\n vertical-align: baseline;\n}\n\n/**\n * Remove the default vertical scrollbar in IE 10+.\n */\n\ntextarea {\n overflow: auto;\n}\n\n/**\n * 1. Add the correct box sizing in IE 10.\n * 2. Remove the padding in IE 10.\n */\n\n[type=\"checkbox\"],\n[type=\"radio\"] {\n box-sizing: border-box; /* 1 */\n padding: 0; /* 2 */\n}\n\n/**\n * Correct the cursor style of increment and decrement buttons in Chrome.\n */\n\n[type=\"number\"]::-webkit-inner-spin-button,\n[type=\"number\"]::-webkit-outer-spin-button {\n height: auto;\n}\n\n/**\n * 1. Correct the odd appearance in Chrome and Safari.\n * 2. Correct the outline style in Safari.\n */\n\n[type=\"search\"] {\n -webkit-appearance: textfield; /* 1 */\n outline-offset: -2px; /* 2 */\n}\n\n/**\n * Remove the inner padding in Chrome and Safari on macOS.\n */\n\n[type=\"search\"]::-webkit-search-decoration {\n -webkit-appearance: none;\n}\n\n/**\n * 1. Correct the inability to style clickable types in iOS and Safari.\n * 2. Change font properties to `inherit` in Safari.\n */\n\n::-webkit-file-upload-button {\n -webkit-appearance: button; /* 1 */\n font: inherit; /* 2 */\n}\n\n/* Interactive\n ========================================================================== */\n\n/*\n * Add the correct display in Edge, IE 10+, and Firefox.\n */\n\ndetails {\n display: block;\n}\n\n/*\n * Add the correct display in all browsers.\n */\n\nsummary {\n display: list-item;\n}\n\n/* Misc\n ========================================================================== */\n\n/**\n * Add the correct display in IE 10+.\n */\n\ntemplate {\n display: none;\n}\n\n/**\n * Add the correct display in IE 10.\n */\n\n[hidden] {\n display: none;\n}\n","// This file contains styles for managing print media.\n\n////////////////////////////////////////////////////////////////////////////////\n// Hide elements not relevant to print media.\n////////////////////////////////////////////////////////////////////////////////\n@media print\n // Hide icon container.\n .content-icon-container\n display: none !important\n\n // Hide showing header links if hovering over when printing.\n .headerlink\n display: none !important\n\n // Hide mobile header.\n .mobile-header\n display: none !important\n\n // Hide navigation links.\n .related-pages\n display: none !important\n\n////////////////////////////////////////////////////////////////////////////////\n// Tweaks related to decolorization.\n////////////////////////////////////////////////////////////////////////////////\n@media print\n // Apply a border around code which no longer have a color background.\n .highlight\n border: 0.1pt solid var(--color-foreground-border)\n\n////////////////////////////////////////////////////////////////////////////////\n// Avoid page break in some relevant cases.\n////////////////////////////////////////////////////////////////////////////////\n@media print\n ul, ol, dl, a, table, pre, blockquote, p\n page-break-inside: avoid\n\n h1, h2, h3, h4, h5, h6, img, figure, caption\n page-break-inside: avoid\n page-break-after: avoid\n\n ul, ol, dl\n page-break-before: avoid\n",".visually-hidden\n position: absolute !important\n width: 1px !important\n height: 1px !important\n padding: 0 !important\n margin: -1px !important\n overflow: hidden !important\n clip: rect(0,0,0,0) !important\n white-space: nowrap !important\n border: 0 !important\n color: var(--color-foreground-primary)\n background: var(--color-background-primary)\n\n:-moz-focusring\n outline: auto\n","// This file serves as the \"skeleton\" of the theming logic.\n//\n// This contains the bulk of the logic for handling dark mode, color scheme\n// toggling and the handling of color-scheme-specific hiding of elements.\n\n@use \"../variables\" as *\n\nbody\n @include fonts\n @include spacing\n @include icons\n @include admonitions\n @include default-admonition(#651fff, \"abstract\")\n @include default-topic(#14B8A6, \"pencil\")\n\n @include colors\n\n.only-light\n display: block !important\nhtml body .only-dark\n display: none !important\n\n// Ignore dark-mode hints if print media.\n@media not print\n // Enable dark-mode, if requested.\n body[data-theme=\"dark\"]\n @include colors-dark\n\n html & .only-light\n display: none !important\n .only-dark\n display: block !important\n\n // Enable dark mode, unless explicitly told to avoid.\n @media (prefers-color-scheme: dark)\n body:not([data-theme=\"light\"])\n @include colors-dark\n\n html & .only-light\n display: none !important\n .only-dark\n display: block !important\n\n//\n// Theme toggle presentation\n//\nbody[data-theme=\"auto\"]\n .theme-toggle svg.theme-icon-when-auto-light\n display: block\n\n @media (prefers-color-scheme: dark)\n .theme-toggle svg.theme-icon-when-auto-dark\n display: block\n .theme-toggle svg.theme-icon-when-auto-light\n display: none\n\nbody[data-theme=\"dark\"]\n .theme-toggle svg.theme-icon-when-dark\n display: block\n\nbody[data-theme=\"light\"]\n .theme-toggle svg.theme-icon-when-light\n display: block\n","// Fonts used by this theme.\n//\n// There are basically two things here -- using the system font stack and\n// defining sizes for various elements in %ages. We could have also used `em`\n// but %age is easier to reason about for me.\n\n@mixin fonts {\n // These are adapted from https://systemfontstack.com/\n --font-stack:\n -apple-system, BlinkMacSystemFont, Segoe UI, Helvetica, Arial, sans-serif,\n Apple Color Emoji, Segoe UI Emoji;\n --font-stack--monospace:\n \"SFMono-Regular\", Menlo, Consolas, Monaco, Liberation Mono, Lucida Console,\n monospace;\n --font-stack--headings: var(--font-stack);\n\n --font-size--normal: 100%;\n --font-size--small: 87.5%;\n --font-size--small--2: 81.25%;\n --font-size--small--3: 75%;\n --font-size--small--4: 62.5%;\n\n // Sidebar\n --sidebar-caption-font-size: var(--font-size--small--2);\n --sidebar-item-font-size: var(--font-size--small);\n --sidebar-search-input-font-size: var(--font-size--small);\n\n // Table of Contents\n --toc-font-size: var(--font-size--small--3);\n --toc-font-size--mobile: var(--font-size--normal);\n --toc-title-font-size: var(--font-size--small--4);\n\n // Admonitions\n //\n // These aren't defined in terms of %ages, since nesting these is permitted.\n --admonition-font-size: 0.8125rem;\n --admonition-title-font-size: 0.8125rem;\n\n // Code\n --code-font-size: var(--font-size--small--2);\n\n // API\n --api-font-size: var(--font-size--small);\n}\n","// Spacing for various elements on the page\n//\n// If the user wants to tweak things in a certain way, they are permitted to.\n// They also have to deal with the consequences though!\n\n@mixin spacing {\n // Header!\n --header-height: calc(\n var(--sidebar-item-line-height) + 4 *\n #{var(--sidebar-item-spacing-vertical)}\n );\n --header-padding: 0.5rem;\n\n // Sidebar\n --sidebar-tree-space-above: 1.5rem;\n --sidebar-caption-space-above: 1rem;\n\n --sidebar-item-line-height: 1rem;\n --sidebar-item-spacing-vertical: 0.5rem;\n --sidebar-item-spacing-horizontal: 1rem;\n --sidebar-item-height: calc(\n var(--sidebar-item-line-height) + 2 *#{var(--sidebar-item-spacing-vertical)}\n );\n\n --sidebar-expander-width: var(--sidebar-item-height); // be square\n\n --sidebar-search-space-above: 0.5rem;\n --sidebar-search-input-spacing-vertical: 0.5rem;\n --sidebar-search-input-spacing-horizontal: 0.5rem;\n --sidebar-search-input-height: 1rem;\n --sidebar-search-icon-size: var(--sidebar-search-input-height);\n\n // Table of Contents\n --toc-title-padding: 0.25rem 0;\n --toc-spacing-vertical: 1.5rem;\n --toc-spacing-horizontal: 1.5rem;\n --toc-item-spacing-vertical: 0.4rem;\n --toc-item-spacing-horizontal: 1rem;\n}\n","// Expose theme icons as CSS variables.\n\n$icons: (\n // Adapted from tabler-icons\n // url: https://tablericons.com/\n \"search\":\n url('data:image/svg+xml;charset=utf-8,'),\n // Factored out from mkdocs-material on 24-Aug-2020.\n // url: https://squidfunk.github.io/mkdocs-material/reference/admonitions/\n \"pencil\":\n url('data:image/svg+xml;charset=utf-8,'),\n \"abstract\":\n url('data:image/svg+xml;charset=utf-8,'),\n \"info\":\n url('data:image/svg+xml;charset=utf-8,'),\n \"flame\":\n url('data:image/svg+xml;charset=utf-8,'),\n \"question\":\n url('data:image/svg+xml;charset=utf-8,'),\n \"warning\":\n url('data:image/svg+xml;charset=utf-8,'),\n \"failure\":\n url('data:image/svg+xml;charset=utf-8,'),\n \"spark\":\n url('data:image/svg+xml;charset=utf-8,')\n);\n\n@mixin icons {\n @each $name, $glyph in $icons {\n --icon-#{$name}: #{$glyph};\n }\n}\n","@use \"sass:list\";\n// Admonitions\n\n// Structure of these is:\n// admonition-class: color \"icon-name\";\n//\n// The colors are translated into CSS variables below. The icons are\n// used directly in the main declarations to set the `mask-image` in\n// the title.\n\n// prettier-ignore\n$admonitions: (\n // Each of these has an reST directives for it.\n \"caution\": #ff9100 \"spark\",\n \"warning\": #ff9100 \"warning\",\n \"danger\": #ff5252 \"spark\",\n \"attention\": #ff5252 \"warning\",\n \"error\": #ff5252 \"failure\",\n \"hint\": #00c852 \"question\",\n \"tip\": #00c852 \"info\",\n \"important\": #00bfa5 \"flame\",\n \"note\": #00b0ff \"pencil\",\n \"seealso\": #448aff \"info\",\n \"admonition-todo\": #808080 \"pencil\"\n);\n\n@mixin default-admonition($color, $icon-name) {\n --color-admonition-title: #{$color};\n --color-admonition-title-background: #{rgba($color, 0.2)};\n\n --icon-admonition-default: var(--icon-#{$icon-name});\n}\n\n@mixin default-topic($color, $icon-name) {\n --color-topic-title: #{$color};\n --color-topic-title-background: #{rgba($color, 0.2)};\n\n --icon-topic-default: var(--icon-#{$icon-name});\n}\n\n@mixin admonitions {\n @each $name, $values in $admonitions {\n --color-admonition-title--#{$name}: #{list.nth($values, 1)};\n --color-admonition-title-background--#{$name}: #{rgba(\n list.nth($values, 1),\n 0.2\n )};\n }\n}\n","// Colors used throughout this theme.\n//\n// The aim is to give the user more control. Thus, instead of hard-coding colors\n// in various parts of the stylesheet, the approach taken is to define all\n// colors as CSS variables and reusing them in all the places.\n//\n// `colors-dark` depends on `colors` being included at a lower specificity.\n\n@mixin colors {\n --color-problematic: #b30000;\n\n // Base Colors\n --color-foreground-primary: black; // for main text and headings\n --color-foreground-secondary: #5a5c63; // for secondary text\n --color-foreground-muted: #6b6f76; // for muted text\n --color-foreground-border: #878787; // for content borders\n\n --color-background-primary: white; // for content\n --color-background-secondary: #f8f9fb; // for navigation + ToC\n --color-background-hover: #efeff4ff; // for navigation-item hover\n --color-background-hover--transparent: #efeff400;\n --color-background-border: #eeebee; // for UI borders\n --color-background-item: #ccc; // for \"background\" items (eg: copybutton)\n\n // Announcements\n --color-announcement-background: #000000dd;\n --color-announcement-text: #eeebee;\n\n // Brand colors\n --color-brand-primary: #0a4bff;\n --color-brand-content: #2757dd;\n --color-brand-visited: #872ee0;\n\n // API documentation\n --color-api-background: var(--color-background-hover--transparent);\n --color-api-background-hover: var(--color-background-hover);\n --color-api-overall: var(--color-foreground-secondary);\n --color-api-name: var(--color-problematic);\n --color-api-pre-name: var(--color-problematic);\n --color-api-paren: var(--color-foreground-secondary);\n --color-api-keyword: var(--color-foreground-primary);\n\n --color-api-added: #21632c;\n --color-api-added-border: #38a84d;\n --color-api-changed: #046172;\n --color-api-changed-border: #06a1bc;\n --color-api-deprecated: #605706;\n --color-api-deprecated-border: #f0d90f;\n --color-api-removed: #b30000;\n --color-api-removed-border: #ff5c5c;\n\n --color-highlight-on-target: #ffffcc;\n\n // Inline code background\n --color-inline-code-background: var(--color-background-secondary);\n\n // Highlighted text (search)\n --color-highlighted-background: #ddeeff;\n --color-highlighted-text: var(--color-foreground-primary);\n\n // GUI Labels\n --color-guilabel-background: #ddeeff80;\n --color-guilabel-border: #bedaf580;\n --color-guilabel-text: var(--color-foreground-primary);\n\n // Admonitions!\n --color-admonition-background: transparent;\n\n //////////////////////////////////////////////////////////////////////////////\n // Everything below this should be one of:\n // - var(...)\n // - *-gradient(...)\n // - special literal values (eg: transparent, none)\n //////////////////////////////////////////////////////////////////////////////\n\n // Tables\n --color-table-header-background: var(--color-background-secondary);\n --color-table-border: var(--color-background-border);\n\n // Cards\n --color-card-border: var(--color-background-secondary);\n --color-card-background: transparent;\n --color-card-marginals-background: var(--color-background-secondary);\n\n // Header\n --color-header-background: var(--color-background-primary);\n --color-header-border: var(--color-background-border);\n --color-header-text: var(--color-foreground-primary);\n\n // Sidebar (left)\n --color-sidebar-background: var(--color-background-secondary);\n --color-sidebar-background-border: var(--color-background-border);\n\n --color-sidebar-brand-text: var(--color-foreground-primary);\n --color-sidebar-caption-text: var(--color-foreground-muted);\n --color-sidebar-link-text: var(--color-foreground-secondary);\n --color-sidebar-link-text--top-level: var(--color-brand-primary);\n\n --color-sidebar-item-background: var(--color-sidebar-background);\n --color-sidebar-item-background--current: var(\n --color-sidebar-item-background\n );\n --color-sidebar-item-background--hover: linear-gradient(\n 90deg,\n var(--color-background-hover--transparent) 0%,\n var(--color-background-hover) var(--sidebar-item-spacing-horizontal),\n var(--color-background-hover) 100%\n );\n\n --color-sidebar-item-expander-background: transparent;\n --color-sidebar-item-expander-background--hover: var(\n --color-background-hover\n );\n\n --color-sidebar-search-text: var(--color-foreground-primary);\n --color-sidebar-search-background: var(--color-background-secondary);\n --color-sidebar-search-background--focus: var(--color-background-primary);\n --color-sidebar-search-border: var(--color-background-border);\n --color-sidebar-search-icon: var(--color-foreground-muted);\n\n // Table of Contents (right)\n --color-toc-background: var(--color-background-primary);\n --color-toc-title-text: var(--color-foreground-muted);\n --color-toc-item-text: var(--color-foreground-secondary);\n --color-toc-item-text--hover: var(--color-foreground-primary);\n --color-toc-item-text--active: var(--color-brand-primary);\n\n // Actual page contents\n --color-content-foreground: var(--color-foreground-primary);\n --color-content-background: transparent;\n\n // Links\n --color-link: var(--color-brand-content);\n --color-link-underline: var(--color-background-border);\n --color-link--hover: var(--color-brand-content);\n --color-link-underline--hover: var(--color-foreground-border);\n\n --color-link--visited: var(--color-brand-visited);\n --color-link-underline--visited: var(--color-background-border);\n --color-link--visited--hover: var(--color-brand-visited);\n --color-link-underline--visited--hover: var(--color-foreground-border);\n}\n\n@mixin colors-dark {\n --color-problematic: #ee5151;\n\n // Base Colors\n --color-foreground-primary: #cfd0d0; // for main text and headings\n --color-foreground-secondary: #9ca0a5; // for secondary text\n --color-foreground-muted: #81868d; // for muted text\n --color-foreground-border: #666666; // for content borders\n\n --color-background-primary: #131416; // for content\n --color-background-secondary: #1a1c1e; // for navigation + ToC\n --color-background-hover: #1e2124ff; // for navigation-item hover\n --color-background-hover--transparent: #1e212400;\n --color-background-border: #303335; // for UI borders\n --color-background-item: #444; // for \"background\" items (eg: copybutton)\n\n // Announcements\n --color-announcement-background: #000000dd;\n --color-announcement-text: #eeebee;\n\n // Brand colors\n --color-brand-primary: #3d94ff;\n --color-brand-content: #5ca5ff;\n --color-brand-visited: #b27aeb;\n\n // Highlighted text (search)\n --color-highlighted-background: #083563;\n\n // GUI Labels\n --color-guilabel-background: #08356380;\n --color-guilabel-border: #13395f80;\n\n // API documentation\n --color-api-keyword: var(--color-foreground-secondary);\n --color-highlight-on-target: #333300;\n\n --color-api-added: #3db854;\n --color-api-added-border: #267334;\n --color-api-changed: #09b0ce;\n --color-api-changed-border: #056d80;\n --color-api-deprecated: #b1a10b;\n --color-api-deprecated-border: #6e6407;\n --color-api-removed: #ff7575;\n --color-api-removed-border: #b03b3b;\n\n // Admonitions\n --color-admonition-background: #18181a;\n\n // Cards\n --color-card-border: var(--color-background-secondary);\n --color-card-background: #18181a;\n --color-card-marginals-background: var(--color-background-hover);\n}\n","// This file contains the styling for making the content throughout the page,\n// including fonts, paragraphs, headings and spacing among these elements.\n\nbody\n font-family: var(--font-stack)\npre,\ncode,\nkbd,\nsamp\n font-family: var(--font-stack--monospace)\n\n// Make fonts look slightly nicer.\nbody\n -webkit-font-smoothing: antialiased\n -moz-osx-font-smoothing: grayscale\n\n// Line height from Bootstrap 4.1\narticle\n line-height: 1.5\n\n//\n// Headings\n//\nh1,\nh2,\nh3,\nh4,\nh5,\nh6\n line-height: 1.25\n font-family: var(--font-stack--headings)\n font-weight: bold\n\n border-radius: 0.5rem\n margin-top: 0.5rem\n margin-bottom: 0.5rem\n margin-left: -0.5rem\n margin-right: -0.5rem\n padding-left: 0.5rem\n padding-right: 0.5rem\n\n + p\n margin-top: 0\n\nh1\n font-size: 2.5em\n margin-top: 1.75rem\n margin-bottom: 1rem\nh2\n font-size: 2em\n margin-top: 1.75rem\nh3\n font-size: 1.5em\nh4\n font-size: 1.25em\nh5\n font-size: 1.125em\nh6\n font-size: 1em\n\nsmall\n opacity: 75%\n font-size: 80%\n\n// Paragraph\np\n margin-top: 0.5rem\n margin-bottom: 0.75rem\n\n// Horizontal rules\nhr.docutils\n height: 1px\n padding: 0\n margin: 2rem 0\n background-color: var(--color-background-border)\n border: 0\n\n.centered\n text-align: center\n\n// Links\na\n text-decoration: underline\n\n color: var(--color-link)\n text-decoration-color: var(--color-link-underline)\n\n &:visited\n color: var(--color-link--visited)\n text-decoration-color: var(--color-link-underline--visited)\n &:hover\n color: var(--color-link--visited--hover)\n text-decoration-color: var(--color-link-underline--visited--hover)\n\n &:hover\n color: var(--color-link--hover)\n text-decoration-color: var(--color-link-underline--hover)\n &.muted-link\n color: inherit\n &:hover\n color: var(--color-link--hover)\n text-decoration-color: var(--color-link-underline--hover)\n &:visited\n color: var(--color-link--visited--hover)\n text-decoration-color: var(--color-link-underline--visited--hover)\n","// This file contains the styles for the overall layouting of the documentation\n// skeleton, including the responsive changes as well as sidebar toggles.\n//\n// This is implemented as a mobile-last design, which isn't ideal, but it is\n// reasonably good-enough and I got pretty tired by the time I'd finished this\n// to move the rules around to fix this. Shouldn't take more than 3-4 hours,\n// if you know what you're doing tho.\n\n// HACK: Not all browsers account for the scrollbar width in media queries.\n// This results in horizontal scrollbars in the breakpoint where we go\n// from displaying everything to hiding the ToC. We accomodate for this by\n// adding a bit of padding to the TOC drawer, disabling the horizontal\n// scrollbar and allowing the scrollbars to cover the padding.\n// https://www.456bereastreet.com/archive/201301/media_query_width_and_vertical_scrollbars/\n\n// HACK: Always having the scrollbar visible, prevents certain browsers from\n// causing the content to stutter horizontally between taller-than-viewport and\n// not-taller-than-viewport pages.\n@use \"variables\" as *\n\nhtml\n overflow-x: hidden\n overflow-y: scroll\n scroll-behavior: smooth\n\n.sidebar-scroll, .toc-scroll, article[role=main] *\n scrollbar-width: thin\n scrollbar-color: var(--color-foreground-border) transparent\n\n//\n// Overalls\n//\nhtml,\nbody\n height: 100%\n color: var(--color-foreground-primary)\n background: var(--color-background-primary)\n\n.skip-to-content\n position: fixed\n padding: 1rem\n border-radius: 1rem\n left: 0.25rem\n top: 0.25rem\n z-index: 40\n background: var(--color-background-primary)\n color: var(--color-foreground-primary)\n\n transform: translateY(-200%)\n transition: transform 300ms ease-in-out\n\n &:focus-within\n transform: translateY(0%)\n\narticle\n color: var(--color-content-foreground)\n background: var(--color-content-background)\n overflow-wrap: break-word\n\n.page\n display: flex\n // fill the viewport for pages with little content.\n min-height: 100%\n\n.mobile-header\n width: 100%\n height: var(--header-height)\n background-color: var(--color-header-background)\n color: var(--color-header-text)\n border-bottom: 1px solid var(--color-header-border)\n\n // Looks like sub-script/super-script have this, and we need this to\n // be \"on top\" of those.\n z-index: 10\n\n // We don't show the header on large screens.\n display: none\n\n // Add shadow when scrolled\n &.scrolled\n border-bottom: none\n box-shadow: 0 0 0.2rem rgba(0, 0, 0, 0.1), 0 0.2rem 0.4rem rgba(0, 0, 0, 0.2)\n\n .header-center\n a\n color: var(--color-header-text)\n text-decoration: none\n\n.main\n display: flex\n flex: 1\n\n// Sidebar (left) also covers the entire left portion of screen.\n.sidebar-drawer\n box-sizing: border-box\n\n border-right: 1px solid var(--color-sidebar-background-border)\n background: var(--color-sidebar-background)\n\n display: flex\n justify-content: flex-end\n // These next two lines took me two days to figure out.\n width: calc((100% - #{$full-width}) / 2 + #{$sidebar-width})\n min-width: $sidebar-width\n\n// Scroll-along sidebars\n.sidebar-container,\n.toc-drawer\n box-sizing: border-box\n width: $sidebar-width\n\n.toc-drawer\n background: var(--color-toc-background)\n // See HACK described on top of this document\n padding-right: 1rem\n\n.sidebar-sticky,\n.toc-sticky\n position: sticky\n top: 0\n height: min(100%, 100vh)\n height: 100vh\n\n display: flex\n flex-direction: column\n\n.sidebar-scroll,\n.toc-scroll\n flex-grow: 1\n flex-shrink: 1\n\n overflow: auto\n scroll-behavior: smooth\n\n// Central items.\n.content\n padding: 0 $content-padding\n width: $content-width\n\n display: flex\n flex-direction: column\n justify-content: space-between\n\n.icon\n display: inline-block\n height: 1rem\n width: 1rem\n svg\n width: 100%\n height: 100%\n\n//\n// Accommodate announcement banner\n//\n.announcement\n background-color: var(--color-announcement-background)\n color: var(--color-announcement-text)\n\n height: var(--header-height)\n display: flex\n align-items: center\n overflow-x: auto\n & + .page\n min-height: calc(100% - var(--header-height))\n\n.announcement-content\n box-sizing: border-box\n padding: 0.5rem\n min-width: 100%\n white-space: nowrap\n text-align: center\n\n a\n color: var(--color-announcement-text)\n text-decoration-color: var(--color-announcement-text)\n\n &:hover\n color: var(--color-announcement-text)\n text-decoration-color: var(--color-link--hover)\n\n////////////////////////////////////////////////////////////////////////////////\n// Toggles for theme\n////////////////////////////////////////////////////////////////////////////////\n.no-js .theme-toggle-container // don't show theme toggle if there's no JS\n display: none\n\n.theme-toggle-container\n display: flex\n\n.theme-toggle\n display: flex\n cursor: pointer\n border: none\n padding: 0\n background: transparent\n\n.theme-toggle svg\n height: 1.25rem\n width: 1.25rem\n color: var(--color-foreground-primary)\n display: none\n\n.theme-toggle-header\n display: flex\n align-items: center\n justify-content: center\n\n////////////////////////////////////////////////////////////////////////////////\n// Toggles for elements\n////////////////////////////////////////////////////////////////////////////////\n.toc-overlay-icon, .nav-overlay-icon\n display: none\n cursor: pointer\n\n .icon\n color: var(--color-foreground-secondary)\n height: 1.5rem\n width: 1.5rem\n\n.toc-header-icon, .nav-overlay-icon\n // for when we set display: flex\n justify-content: center\n align-items: center\n\n.toc-content-icon\n height: 1.5rem\n width: 1.5rem\n\n.content-icon-container\n float: right\n display: flex\n margin-top: 1.5rem\n margin-left: 1rem\n margin-bottom: 1rem\n gap: 0.5rem\n\n .edit-this-page, .view-this-page\n svg\n color: inherit\n height: 1.25rem\n width: 1.25rem\n\n.sidebar-toggle\n position: absolute\n display: none\n// \n.sidebar-toggle[name=\"__toc\"]\n left: 20px\n.sidebar-toggle:checked\n left: 40px\n// \n\n.overlay\n position: fixed\n top: 0\n width: 0\n height: 0\n\n transition: width 0ms, height 0ms, opacity 250ms ease-out\n\n opacity: 0\n background-color: rgba(0, 0, 0, 0.54)\n.sidebar-overlay\n z-index: 20\n.toc-overlay\n z-index: 40\n\n// Keep things on top and smooth.\n.sidebar-drawer\n z-index: 30\n transition: left 250ms ease-in-out\n.toc-drawer\n z-index: 50\n transition: right 250ms ease-in-out\n\n// Show the Sidebar\n#__navigation:checked\n & ~ .sidebar-overlay\n width: 100%\n height: 100%\n opacity: 1\n & ~ .page\n .sidebar-drawer\n top: 0\n left: 0\n // Show the toc sidebar\n#__toc:checked\n & ~ .toc-overlay\n width: 100%\n height: 100%\n opacity: 1\n & ~ .page\n .toc-drawer\n top: 0\n right: 0\n\n////////////////////////////////////////////////////////////////////////////////\n// Back to top\n////////////////////////////////////////////////////////////////////////////////\n.back-to-top\n text-decoration: none\n\n display: none\n position: fixed\n left: 0\n top: 1rem\n padding: 0.5rem\n padding-right: 0.75rem\n border-radius: 1rem\n font-size: 0.8125rem\n\n background: var(--color-background-primary)\n box-shadow: 0 0.2rem 0.5rem rgba(0, 0, 0, 0.05), #6b728080 0px 0px 1px 0px\n\n z-index: 10\n\n margin-left: 50%\n transform: translateX(-50%)\n svg\n height: 1rem\n width: 1rem\n fill: currentColor\n display: inline-block\n\n span\n margin-left: 0.25rem\n\n .show-back-to-top &\n display: flex\n align-items: center\n\n////////////////////////////////////////////////////////////////////////////////\n// Responsive layouting\n////////////////////////////////////////////////////////////////////////////////\n// Make things a bit bigger on bigger screens.\n@media (min-width: $full-width + $sidebar-width)\n html\n font-size: 110%\n\n@media (max-width: $full-width)\n // Collapse \"toc\" into the icon.\n .toc-content-icon\n display: flex\n .toc-drawer\n position: fixed\n height: 100vh\n top: 0\n right: -$sidebar-width\n border-left: 1px solid var(--color-background-muted)\n .toc-tree\n border-left: none\n font-size: var(--toc-font-size--mobile)\n\n // Accomodate for a changed content width.\n .sidebar-drawer\n width: calc((100% - #{$full-width - $sidebar-width}) / 2 + #{$sidebar-width})\n\n@media (max-width: $content-padded-width + $sidebar-width)\n // Center the page\n .content\n margin-left: auto\n margin-right: auto\n padding: 0 $content-padding--small\n\n@media (max-width: $content-padded-width--small + $sidebar-width)\n // Collapse \"navigation\".\n .nav-overlay-icon\n display: flex\n .sidebar-drawer\n position: fixed\n height: 100vh\n width: $sidebar-width\n\n top: 0\n left: -$sidebar-width\n\n // Swap which icon is visible.\n .toc-header-icon, .theme-toggle-header\n display: flex\n .toc-content-icon, .theme-toggle-content\n display: none\n\n // Show the header.\n .mobile-header\n position: sticky\n top: 0\n display: flex\n justify-content: space-between\n align-items: center\n\n .header-left,\n .header-right\n display: flex\n height: var(--header-height)\n padding: 0 var(--header-padding)\n label\n height: 100%\n width: 100%\n user-select: none\n\n .nav-overlay-icon .icon,\n .theme-toggle svg\n height: 1.5rem\n width: 1.5rem\n\n // Add a scroll margin for the content\n :target\n scroll-margin-top: calc(var(--header-height) + 2.5rem)\n\n // Show back-to-top below the header\n .back-to-top\n top: calc(var(--header-height) + 0.5rem)\n\n // Accommodate for the header.\n .page\n flex-direction: column\n justify-content: center\n\n@media (max-width: $content-width + 2* $content-padding--small)\n // Content should respect window limits.\n .content\n width: 100%\n overflow-x: auto\n\n@media (max-width: $content-width)\n article[role=main] aside.sidebar\n float: none\n width: 100%\n margin: 1rem 0\n","@use \"sass:list\"\n@use \"../variables\" as *\n\n// The design here is strongly inspired by mkdocs-material.\n.admonition, .topic\n margin: 1rem auto\n padding: 0 0.5rem 0.5rem 0.5rem\n\n background: var(--color-admonition-background)\n\n border-radius: 0.2rem\n box-shadow: 0 0.2rem 0.5rem rgba(0, 0, 0, 0.05), 0 0 0.0625rem rgba(0, 0, 0, 0.1)\n\n font-size: var(--admonition-font-size)\n\n overflow: hidden\n page-break-inside: avoid\n\n // First element should have no margin, since the title has it.\n > :nth-child(2)\n margin-top: 0\n\n // Last item should have no margin, since we'll control that w/ padding\n > :last-child\n margin-bottom: 0\n\n.admonition p.admonition-title,\np.topic-title\n position: relative\n margin: 0 -0.5rem 0.5rem\n padding-left: 2rem\n padding-right: .5rem\n padding-top: .4rem\n padding-bottom: .4rem\n\n font-weight: 500\n font-size: var(--admonition-title-font-size)\n line-height: 1.3\n\n // Our fancy icon\n &::before\n content: \"\"\n position: absolute\n left: 0.5rem\n width: 1rem\n height: 1rem\n\n// Default styles\np.admonition-title\n background-color: var(--color-admonition-title-background)\n &::before\n background-color: var(--color-admonition-title)\n mask-image: var(--icon-admonition-default)\n mask-repeat: no-repeat\n\np.topic-title\n background-color: var(--color-topic-title-background)\n &::before\n background-color: var(--color-topic-title)\n mask-image: var(--icon-topic-default)\n mask-repeat: no-repeat\n\n//\n// Variants\n//\n.admonition\n border-left: 0.2rem solid var(--color-admonition-title)\n\n @each $type, $value in $admonitions\n &.#{$type}\n border-left-color: var(--color-admonition-title--#{$type})\n > .admonition-title\n background-color: var(--color-admonition-title-background--#{$type})\n &::before\n background-color: var(--color-admonition-title--#{$type})\n mask-image: var(--icon-#{list.nth($value, 2)})\n\n.admonition-todo > .admonition-title\n text-transform: uppercase\n","// This file stylizes the API documentation (stuff generated by autodoc). It's\n// deeply nested due to how autodoc structures the HTML without enough classes\n// to select the relevant items.\n\n// API docs!\ndl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple)\n // Tweak the spacing of all the things!\n dd\n margin-left: 2rem\n > :first-child\n margin-top: 0.125rem\n > :last-child\n margin-bottom: 0.75rem\n\n // This is used for the arguments\n .field-list\n margin-bottom: 0.75rem\n\n // \"Headings\" (like \"Parameters\" and \"Return\")\n > dt\n text-transform: uppercase\n font-size: var(--font-size--small)\n\n dd:empty\n margin-bottom: 0.5rem\n dd > ul\n margin-left: -1.2rem\n > li\n > p:nth-child(2)\n margin-top: 0\n // When the last-empty-paragraph follows a paragraph, it doesn't need\n // to augument the existing spacing.\n > p + p:last-child:empty\n margin-top: 0\n margin-bottom: 0\n\n // Colorize the elements\n > dt\n color: var(--color-api-overall)\n\n.sig:not(.sig-inline)\n font-weight: bold\n\n font-size: var(--api-font-size)\n font-family: var(--font-stack--monospace)\n\n margin-left: -0.25rem\n margin-right: -0.25rem\n padding-top: 0.25rem\n padding-bottom: 0.25rem\n padding-right: 0.5rem\n\n // These are intentionally em, to properly match the font size.\n padding-left: 3em\n text-indent: -2.5em\n\n border-radius: 0.25rem\n\n background: var(--color-api-background)\n transition: background 100ms ease-out\n\n &:hover\n background: var(--color-api-background-hover)\n\n // adjust the size of the [source] link on the right.\n a.reference\n .viewcode-link\n font-weight: normal\n width: 4.25rem\n\nem.property, span.property\n font-style: normal\n &:first-child\n color: var(--color-api-keyword)\n.sig-name\n color: var(--color-api-name)\n.sig-prename\n font-weight: normal\n color: var(--color-api-pre-name)\n.sig-paren\n color: var(--color-api-paren)\n.sig-param\n font-style: normal\n\ndiv.versionadded,\ndiv.versionchanged,\ndiv.deprecated,\ndiv.versionremoved\n border-left: 0.1875rem solid\n border-radius: 0.125rem\n\n padding-left: 0.75rem\n\n p\n margin-top: 0.125rem\n margin-bottom: 0.125rem\n\ndiv.versionadded\n border-color: var(--color-api-added-border)\n .versionmodified\n color: var(--color-api-added)\n\ndiv.versionchanged\n border-color: var(--color-api-changed-border)\n .versionmodified\n color: var(--color-api-changed)\n\ndiv.deprecated\n border-color: var(--color-api-deprecated-border)\n .versionmodified\n color: var(--color-api-deprecated)\n\ndiv.versionremoved\n border-color: var(--color-api-removed-border)\n .versionmodified\n color: var(--color-api-removed)\n\n// Align the [docs] and [source] to the right.\n.viewcode-link, .viewcode-back\n float: right\n text-align: right\n",".line-block\n margin-top: 0.5rem\n margin-bottom: 0.75rem\n .line-block\n margin-top: 0rem\n margin-bottom: 0rem\n padding-left: 1rem\n","// Captions\narticle p.caption,\ntable > caption,\n.code-block-caption\n font-size: var(--font-size--small)\n text-align: center\n\n// Caption above a TOCTree\n.toctree-wrapper.compound\n .caption, :not(.caption) > .caption-text\n font-size: var(--font-size--small)\n text-transform: uppercase\n\n text-align: initial\n margin-bottom: 0\n\n > ul\n margin-top: 0\n margin-bottom: 0\n","// Inline code\ncode.literal, .sig-inline\n background: var(--color-inline-code-background)\n border-radius: 0.2em\n // Make the font smaller, and use padding to recover.\n font-size: var(--font-size--small--2)\n padding: 0.1em 0.2em\n\n pre.literal-block &\n font-size: inherit\n padding: 0\n\n p &\n border: 1px solid var(--color-background-border)\n\n.sig-inline\n font-family: var(--font-stack--monospace)\n\n// Code and Literal Blocks\n$code-spacing-vertical: 0.625rem\n$code-spacing-horizontal: 0.875rem\n\n// Wraps every literal block + line numbers.\ndiv[class*=\" highlight-\"],\ndiv[class^=\"highlight-\"]\n margin: 1em 0\n display: flex\n\n .table-wrapper\n margin: 0\n padding: 0\n\npre\n margin: 0\n padding: 0\n overflow: auto\n\n // Needed to have more specificity than pygments' \"pre\" selector. :(\n article[role=\"main\"] .highlight &\n line-height: 1.5\n\n &.literal-block,\n .highlight &\n font-size: var(--code-font-size)\n padding: $code-spacing-vertical $code-spacing-horizontal\n\n // Make it look like all the other blocks.\n &.literal-block\n margin-top: 1rem\n margin-bottom: 1rem\n\n border-radius: 0.2rem\n background-color: var(--color-code-background)\n color: var(--color-code-foreground)\n\n// All code is always contained in this.\n.highlight\n width: 100%\n border-radius: 0.2rem\n\n // Make line numbers and prompts un-selectable.\n .gp, span.linenos\n user-select: none\n pointer-events: none\n\n // Expand the line-highlighting.\n .hll\n display: block\n margin-left: -$code-spacing-horizontal\n margin-right: -$code-spacing-horizontal\n padding-left: $code-spacing-horizontal\n padding-right: $code-spacing-horizontal\n\n/* Make code block captions be nicely integrated */\n.code-block-caption\n display: flex\n padding: $code-spacing-vertical $code-spacing-horizontal\n\n border-radius: 0.25rem\n border-bottom-left-radius: 0\n border-bottom-right-radius: 0\n font-weight: 300\n border-bottom: 1px solid\n\n background-color: var(--color-code-background)\n color: var(--color-code-foreground)\n border-color: var(--color-background-border)\n\n + div[class]\n margin-top: 0\n > .highlight\n border-top-left-radius: 0\n border-top-right-radius: 0\n\n// When `html_codeblock_linenos_style` is table.\n.highlighttable\n width: 100%\n display: block\n tbody\n display: block\n\n tr\n display: flex\n\n // Line numbers\n td.linenos\n background-color: var(--color-code-background)\n color: var(--color-code-foreground)\n padding: $code-spacing-vertical $code-spacing-horizontal\n padding-right: 0\n border-top-left-radius: 0.2rem\n border-bottom-left-radius: 0.2rem\n\n .linenodiv\n padding-right: $code-spacing-horizontal\n font-size: var(--code-font-size)\n box-shadow: -0.0625rem 0 var(--color-foreground-border) inset\n\n // Actual code\n td.code\n padding: 0\n display: block\n flex: 1\n overflow: hidden\n\n .highlight\n border-top-left-radius: 0\n border-bottom-left-radius: 0\n\n// When `html_codeblock_linenos_style` is inline.\n.highlight\n span.linenos\n display: inline-block\n padding-left: 0\n padding-right: $code-spacing-horizontal\n margin-right: $code-spacing-horizontal\n box-shadow: -0.0625rem 0 var(--color-foreground-border) inset\n","// Inline Footnote Reference\n.footnote-reference\n font-size: var(--font-size--small--4)\n vertical-align: super\n\n// Definition list, listing the content of each note.\n// docutils <= 0.17\ndl.footnote.brackets\n font-size: var(--font-size--small)\n color: var(--color-foreground-secondary)\n\n display: grid\n grid-template-columns: max-content auto\n dt\n margin: 0\n > .fn-backref\n margin-left: 0.25rem\n\n &:after\n content: \":\"\n\n .brackets\n &:before\n content: \"[\"\n &:after\n content: \"]\"\n\n dd\n margin: 0\n padding: 0 1rem\n\n// docutils >= 0.18\naside.footnote\n font-size: var(--font-size--small)\n color: var(--color-foreground-secondary)\n\naside.footnote > span,\ndiv.citation > span\n float: left\n font-weight: 500\n padding-right: 0.25rem\n\naside.footnote > *:not(span),\ndiv.citation > p\n margin-left: 2rem\n","//\n// Figures\n//\nimg\n box-sizing: border-box\n max-width: 100%\n height: auto\n\narticle\n figure, .figure\n border-radius: 0.2rem\n\n margin: 0\n :last-child\n margin-bottom: 0\n\n .align-left\n float: left\n clear: left\n margin: 0 1rem 1rem\n\n .align-right\n float: right\n clear: right\n margin: 0 1rem 1rem\n\n .align-default,\n .align-center\n display: block\n text-align: center\n margin-left: auto\n margin-right: auto\n\n // WELL, table needs to be stylised like a table.\n table.align-default\n display: table\n text-align: initial\n",".genindex-jumpbox, .domainindex-jumpbox\n border-top: 1px solid var(--color-background-border)\n border-bottom: 1px solid var(--color-background-border)\n padding: 0.25rem\n\n.genindex-section, .domainindex-section\n h2\n margin-top: 0.75rem\n margin-bottom: 0.5rem\n ul\n margin-top: 0\n margin-bottom: 0\n","ul,\nol\n padding-left: 1.2rem\n\n // Space lists out like paragraphs\n margin-top: 1rem\n margin-bottom: 1rem\n // reduce margins within li.\n li\n > p:first-child\n margin-top: 0.25rem\n margin-bottom: 0.25rem\n\n > p:last-child\n margin-top: 0.25rem\n\n > ul,\n > ol\n margin-top: 0.5rem\n margin-bottom: 0.5rem\n\nol\n &.arabic\n list-style: decimal\n &.loweralpha\n list-style: lower-alpha\n &.upperalpha\n list-style: upper-alpha\n &.lowerroman\n list-style: lower-roman\n &.upperroman\n list-style: upper-roman\n\n// Don't space lists out when they're \"simple\" or in a `.. toctree::`\n.simple,\n.toctree-wrapper\n li\n > ul,\n > ol\n margin-top: 0\n margin-bottom: 0\n\n// Definition Lists\n.field-list,\n.option-list,\ndl:not([class]),\ndl.simple,\ndl.footnote,\ndl.glossary\n dt\n font-weight: 500\n margin-top: 0.25rem\n + dt\n margin-top: 0\n\n .classifier::before\n content: \":\"\n margin-left: 0.2rem\n margin-right: 0.2rem\n\n dd\n > p:first-child,\n ul\n margin-top: 0.125rem\n\n ul\n margin-bottom: 0.125rem\n",".math-wrapper\n width: 100%\n overflow-x: auto\n\ndiv.math\n position: relative\n text-align: center\n\n .headerlink,\n &:focus .headerlink\n display: none\n\n &:hover .headerlink\n display: inline-block\n\n span.eqno\n position: absolute\n right: 0.5rem\n top: 50%\n transform: translate(0, -50%)\n z-index: 1\n","// Abbreviations\nabbr[title]\n cursor: help\n\n// \"Problematic\" content, as identified by Sphinx\n.problematic\n color: var(--color-problematic)\n\n// Keyboard / Mouse \"instructions\"\nkbd:not(.compound)\n margin: 0 0.2rem\n padding: 0 0.2rem\n border-radius: 0.2rem\n border: 1px solid var(--color-foreground-border)\n color: var(--color-foreground-primary)\n vertical-align: text-bottom\n\n font-size: var(--font-size--small--3)\n display: inline-block\n\n box-shadow: 0 0.0625rem 0 rgba(0, 0, 0, 0.2), inset 0 0 0 0.125rem var(--color-background-primary)\n\n background-color: var(--color-background-secondary)\n\n// Blockquote\nblockquote\n border-left: 4px solid var(--color-background-border)\n background: var(--color-background-secondary)\n\n margin-left: 0\n margin-right: 0\n padding: 0.5rem 1rem\n\n .attribution\n font-weight: 600\n text-align: right\n\n &.pull-quote,\n &.highlights\n font-size: 1.25em\n\n &.epigraph,\n &.pull-quote\n border-left-width: 0\n border-radius: 0.5rem\n\n &.highlights\n border-left-width: 0\n background: transparent\n\n// Center align embedded-in-text images\np .reference img\n vertical-align: middle\n","p.rubric\n line-height: 1.25\n font-weight: bold\n font-size: 1.125em\n\n // For Numpy-style documentation that's got rubrics within it.\n // https://github.com/pradyunsg/furo/discussions/505\n dd &\n line-height: inherit\n font-weight: inherit\n\n font-size: var(--font-size--small)\n text-transform: uppercase\n","article .sidebar\n float: right\n clear: right\n width: 30%\n\n margin-left: 1rem\n margin-right: 0\n\n border-radius: 0.2rem\n background-color: var(--color-background-secondary)\n border: var(--color-background-border) 1px solid\n\n > *\n padding-left: 1rem\n padding-right: 1rem\n\n > ul, > ol // lists need additional padding, because bullets.\n padding-left: 2.2rem\n\n .sidebar-title\n margin: 0\n padding: 0.5rem 1rem\n border-bottom: var(--color-background-border) 1px solid\n\n font-weight: 500\n\n// TODO: subtitle\n// TODO: dedicated variables?\n","[role=main] .table-wrapper.container\n width: 100%\n overflow-x: auto\n margin-top: 1rem\n margin-bottom: 0.5rem\n padding: 0.2rem 0.2rem 0.75rem\n\ntable.docutils\n border-radius: 0.2rem\n border-spacing: 0\n border-collapse: collapse\n\n box-shadow: 0 0.2rem 0.5rem rgba(0, 0, 0, 0.05), 0 0 0.0625rem rgba(0, 0, 0, 0.1)\n\n th\n background: var(--color-table-header-background)\n\n td,\n th\n // Space things out properly\n padding: 0 0.25rem\n\n // Get the borders looking just-right.\n border-left: 1px solid var(--color-table-border)\n border-right: 1px solid var(--color-table-border)\n border-bottom: 1px solid var(--color-table-border)\n\n p\n margin: 0.25rem\n\n &:first-child\n border-left: none\n &:last-child\n border-right: none\n\n // MyST-parser tables set these classes for control of column alignment\n &.text-left\n text-align: left\n &.text-right\n text-align: right\n &.text-center\n text-align: center\n","@use \"../variables\" as *\n\n:target\n scroll-margin-top: 2.5rem\n\n@media (max-width: $full-width - $sidebar-width)\n :target\n scroll-margin-top: calc(2.5rem + var(--header-height))\n\n // When a heading is selected\n section > span:target\n scroll-margin-top: calc(2.8rem + var(--header-height))\n\n// Permalinks\n.headerlink\n font-weight: 100\n user-select: none\n\nh1,\nh2,\nh3,\nh4,\nh5,\nh6,\ndl dt,\np.caption,\nfigcaption p,\ntable > caption,\n.code-block-caption\n > .headerlink\n margin-left: 0.5rem\n visibility: hidden\n &:hover > .headerlink\n visibility: visible\n\n // Don't change to link-like, if someone adds the contents directive.\n > .toc-backref\n color: inherit\n text-decoration-line: none\n\n// Figure and table captions are special.\nfigure:hover > figcaption > p > .headerlink,\ntable:hover > caption > .headerlink\n visibility: visible\n\n:target >, // Regular section[id] style anchors\nspan:target ~ // Non-regular span[id] style \"extra\" anchors\n h1,\n h2,\n h3,\n h4,\n h5,\n h6\n &:nth-of-type(1)\n background-color: var(--color-highlight-on-target)\n // .headerlink\n // visibility: visible\n code.literal\n background-color: transparent\n\ntable:target > caption,\nfigure:target\n background-color: var(--color-highlight-on-target)\n\n// Inline page contents\n.this-will-duplicate-information-and-it-is-still-useful-here li :target\n background-color: var(--color-highlight-on-target)\n\n// Code block permalinks\n.literal-block-wrapper:target .code-block-caption\n background-color: var(--color-highlight-on-target)\n\n// When a definition list item is selected\n//\n// There isn't really an alternative to !important here, due to the\n// high-specificity of API documentation's selector.\ndt:target\n background-color: var(--color-highlight-on-target) !important\n\n// When a footnote reference is selected\n.footnote > dt:target + dd,\n.footnote-reference:target\n background-color: var(--color-highlight-on-target)\n",".guilabel\n background-color: var(--color-guilabel-background)\n border: 1px solid var(--color-guilabel-border)\n color: var(--color-guilabel-text)\n\n padding: 0 0.3em\n border-radius: 0.5em\n font-size: 0.9em\n","// This file contains the styles used for stylizing the footer that's shown\n// below the content.\n@use \"../variables\" as *\n\nfooter\n font-size: var(--font-size--small)\n display: flex\n flex-direction: column\n\n margin-top: 2rem\n\n// Bottom of page information\n.bottom-of-page\n display: flex\n align-items: center\n justify-content: space-between\n\n margin-top: 1rem\n padding-top: 1rem\n padding-bottom: 1rem\n\n color: var(--color-foreground-secondary)\n border-top: 1px solid var(--color-background-border)\n\n line-height: 1.5\n\n @media (max-width: $content-width)\n text-align: center\n flex-direction: column-reverse\n gap: 0.25rem\n\n .left-details\n font-size: var(--font-size--small)\n\n .right-details\n display: flex\n flex-direction: column\n gap: 0.25rem\n text-align: right\n\n .icons\n display: flex\n justify-content: flex-end\n gap: 0.25rem\n font-size: 1rem\n\n a\n text-decoration: none\n\n svg,\n img\n font-size: 1.125rem\n height: 1em\n width: 1em\n\n// Next/Prev page information\n.related-pages\n a\n display: flex\n align-items: center\n\n text-decoration: none\n &:hover .page-info .title\n text-decoration: underline\n color: var(--color-link)\n text-decoration-color: var(--color-link-underline)\n\n svg.furo-related-icon,\n svg.furo-related-icon > use\n flex-shrink: 0\n\n color: var(--color-foreground-border)\n\n width: 0.75rem\n height: 0.75rem\n margin: 0 0.5rem\n\n &.next-page\n max-width: 50%\n\n float: right\n clear: right\n text-align: right\n\n &.prev-page\n max-width: 50%\n\n float: left\n clear: left\n\n svg\n transform: rotate(180deg)\n\n.page-info\n display: flex\n flex-direction: column\n overflow-wrap: anywhere\n\n .next-page &\n align-items: flex-end\n\n .context\n display: flex\n align-items: center\n\n padding-bottom: 0.1rem\n\n color: var(--color-foreground-muted)\n font-size: var(--font-size--small)\n text-decoration: none\n","// This file contains the styles for the contents of the left sidebar, which\n// contains the navigation tree, logo, search etc.\n\n////////////////////////////////////////////////////////////////////////////////\n// Brand on top of the scrollable tree.\n////////////////////////////////////////////////////////////////////////////////\n.sidebar-brand\n display: flex\n flex-direction: column\n flex-shrink: 0\n\n padding: var(--sidebar-item-spacing-vertical) var(--sidebar-item-spacing-horizontal)\n text-decoration: none\n\n.sidebar-brand-text\n color: var(--color-sidebar-brand-text)\n overflow-wrap: break-word\n margin: var(--sidebar-item-spacing-vertical) 0\n font-size: 1.5rem\n\n.sidebar-logo-container\n margin: var(--sidebar-item-spacing-vertical) 0\n\n.sidebar-logo\n margin: 0 auto\n display: block\n max-width: 100%\n\n////////////////////////////////////////////////////////////////////////////////\n// Search\n////////////////////////////////////////////////////////////////////////////////\n.sidebar-search-container\n display: flex\n align-items: center\n margin-top: var(--sidebar-search-space-above)\n\n position: relative\n\n background: var(--color-sidebar-search-background)\n &:hover,\n &:focus-within\n background: var(--color-sidebar-search-background--focus)\n\n &::before\n content: \"\"\n position: absolute\n left: var(--sidebar-item-spacing-horizontal)\n width: var(--sidebar-search-icon-size)\n height: var(--sidebar-search-icon-size)\n\n background-color: var(--color-sidebar-search-icon)\n mask-image: var(--icon-search)\n\n.sidebar-search\n box-sizing: border-box\n\n border: none\n border-top: 1px solid var(--color-sidebar-search-border)\n border-bottom: 1px solid var(--color-sidebar-search-border)\n\n padding-top: var(--sidebar-search-input-spacing-vertical)\n padding-bottom: var(--sidebar-search-input-spacing-vertical)\n padding-right: var(--sidebar-search-input-spacing-horizontal)\n padding-left: calc(var(--sidebar-item-spacing-horizontal) + var(--sidebar-search-input-spacing-horizontal) + var(--sidebar-search-icon-size))\n\n width: 100%\n\n color: var(--color-sidebar-search-foreground)\n background: transparent\n z-index: 10\n\n &:focus\n outline: none\n\n &::placeholder\n font-size: var(--sidebar-search-input-font-size)\n\n//\n// Hide Search Matches link\n//\n#searchbox .highlight-link\n padding: var(--sidebar-item-spacing-vertical) var(--sidebar-item-spacing-horizontal) 0\n margin: 0\n text-align: center\n\n a\n color: var(--color-sidebar-search-icon)\n font-size: var(--font-size--small--2)\n\n////////////////////////////////////////////////////////////////////////////////\n// Structure/Skeleton of the navigation tree (left)\n////////////////////////////////////////////////////////////////////////////////\n.sidebar-tree\n font-size: var(--sidebar-item-font-size)\n margin-top: var(--sidebar-tree-space-above)\n margin-bottom: var(--sidebar-item-spacing-vertical)\n\n ul\n padding: 0\n margin-top: 0\n margin-bottom: 0\n\n display: flex\n flex-direction: column\n\n list-style: none\n\n li\n position: relative\n margin: 0\n\n > ul\n margin-left: var(--sidebar-item-spacing-horizontal)\n\n .icon\n color: var(--color-sidebar-link-text)\n\n .reference\n box-sizing: border-box\n color: var(--color-sidebar-link-text)\n\n // Fill the parent.\n display: inline-block\n line-height: var(--sidebar-item-line-height)\n text-decoration: none\n\n // Don't allow long words to cause wrapping.\n overflow-wrap: anywhere\n\n height: 100%\n width: 100%\n\n padding: var(--sidebar-item-spacing-vertical) var(--sidebar-item-spacing-horizontal)\n\n &:hover\n color: var(--color-sidebar-link-text)\n background: var(--color-sidebar-item-background--hover)\n\n // Add a nice little \"external-link\" arrow here.\n &.external::after\n content: url('data:image/svg+xml,')\n margin: 0 0.25rem\n vertical-align: middle\n color: var(--color-sidebar-link-text)\n\n // Make the current page reference bold.\n .current-page > .reference\n font-weight: bold\n\n label\n position: absolute\n top: 0\n right: 0\n height: var(--sidebar-item-height)\n width: var(--sidebar-expander-width)\n\n cursor: pointer\n user-select: none\n\n display: flex\n justify-content: center\n align-items: center\n\n .caption, :not(.caption) > .caption-text\n font-size: var(--sidebar-caption-font-size)\n color: var(--color-sidebar-caption-text)\n\n font-weight: bold\n text-transform: uppercase\n\n margin: var(--sidebar-caption-space-above) 0 0 0\n padding: var(--sidebar-item-spacing-vertical) var(--sidebar-item-spacing-horizontal)\n\n // If it has children, add a bit more padding to wrap the content to avoid\n // overlapping with the