#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Per-claim grounding verdict — thin public wrapper around ``is_grounded``.
``is_grounded`` (see :mod:`._gate`) is the pure, reusable chain-walk primitive
behind the aggregate verify/export gate: it collapses a claim's provenance
status to a single bool. That is exactly right for ``verify_all_claims``
(which only needs a pass/fail bit to decide an exit code), but WRONG for a
live inline editor (scitex-writer's SSOT paper editor) that needs to tell a
human apart:
* "nothing registered yet" (amber, fine — compose-phase convenience) from
* "a manifest exists and this claim fails it" (red, a real problem)
Collapsing those two into the same ``False`` would misreport a claim's
actual provenance status — the exact bug class this whole feature exists to
prevent. :func:`is_claim_grounded` is the richer, per-claim verdict: the SAME
``grounded`` bool (guaranteed to agree with :func:`~._gate.is_grounded`
bit-for-bit on the same claim/manifest/db whenever a manifest is present —
see the HARD invariant tested in
``tests/scitex_clew/_sources/test__grounding_api.py``), plus a precise
``reason`` + actionable ``fix_hint`` + the matched anchor (if any).
Locked design: agreed with scitex-writer + scitex-dev on 2026-07-08
(scitex-writer ADR 0001 §4 "Inline engine", status Accepted). Tracked by
scitex-todo card ``clew-per-claim-grounding-api``.
"""
from __future__ import annotations
import os
from pathlib import Path
from typing import Dict, Optional
from .._claim._model import _ensure_claims_table, _resolve_claim
from .._db._core import VerificationDB, _default_db_path
from ._gate import _hash_consistent, collect_chain_files, is_grounded
from ._manifest import SourcesManifest, _resolve_sources_tier3, load_sources_manifest
# ---------------------------------------------------------------------------
# Reason enum — a stable, importable constant. A plain tuple (mirrors
# ``scitex_clew._claim.CLAIM_TYPES``, not an ``Enum`` class like ``Severity``
# — the reason is a descriptive classification field, not a config-coerced
# value). Downstream consumers (scitex-dev's future ``provenance_verdict``)
# should import this rather than hardcode the strings.
# ---------------------------------------------------------------------------
GROUNDING_REASONS = (
"grounded",
"no_chain_match",
"no_manifest",
"manifest_untrusted",
"claim_not_found",
)
_FIX_HINTS: Dict[str, str] = {
"grounded": "",
"no_chain_match": (
"register a source for this claim's input chain "
"(clew register-source ...)"
),
"no_manifest": (
"no sources manifest found under this workdir — provenance gate is "
"inactive (compose-phase convenience, not an error)"
),
"manifest_untrusted": (
"sources manifest exists but failed signature/trust verification — "
"re-sign it (clew sign) or check for tampering"
),
"claim_not_found": (
"no claim matches this location — check the claim_id or file:line"
),
}
def _verdict(
*,
grounded: bool,
claim_id: str,
matched_source: Optional[Dict[str, str]],
reason: str,
) -> Dict:
"""Build the canonical ``GroundingVerdict`` dict."""
return {
"grounded": grounded,
"claim_id": claim_id,
"matched_source": matched_source,
"reason": reason,
"fix_hint": _FIX_HINTS[reason],
}
def _resolve_db_path_for_workdir(root: Path) -> Path:
"""Resolve the clew DB path for an explicit ``root`` (not cwd).
Mirrors :func:`scitex_clew._db._core.resolve_db_path`'s tier2/tier3
precedence exactly (the ``SCITEX_CLEW_DB_PATH`` env var escape hatch,
then the canonical ``runtime/clew.db`` layout + migration via
:func:`~scitex_clew._db._core._default_db_path`), swapping only the
tier-3 "which directory is the project root" step from a cwd
upward-walk to the explicit ``root`` the caller already resolved from
``workdir`` — the same "workdir IS the project root" convention already
established by :mod:`scitex_clew._gate_plugin` (scitex-dev's
pre-submission gate), the one existing precedent for an explicit-workdir
resolution in this codebase.
"""
env_path = os.environ.get("SCITEX_CLEW_DB_PATH")
if env_path:
return Path(env_path)
return _default_db_path(root)
def _resolve_sources_path_for_workdir(root: Path) -> Path:
"""Resolve the sources manifest path for an explicit ``root`` (not cwd).
Mirrors :func:`scitex_clew._sources._manifest.resolve_sources_path`'s
tier2/tier3 precedence exactly, reusing the SAME tier-3 helper
(:func:`~scitex_clew._sources._manifest._resolve_sources_tier3`) with
``root`` instead of a cwd-derived project root.
"""
env_path = os.environ.get("SCITEX_CLEW_SOURCES")
if env_path:
return Path(env_path)
path, _label = _resolve_sources_tier3(root / ".scitex" / "clew")
return path
def _find_matched_entry(claim, manifest: SourcesManifest, db):
"""Return the first VALID manifest entry that grounds ``claim`` (else ``None``).
Mirrors :func:`~._gate.is_grounded`'s own chain-walk EXACTLY (same
``collect_chain_files`` candidates, same ``anchor_paths``, same
``_hash_consistent`` comparison) so the returned entry is always the one
``is_grounded`` itself would have matched — never a second, drifting
implementation of the walk.
"""
anchor_paths = manifest.anchor_paths()
if not anchor_paths:
return None
for abspath, fhash in collect_chain_files(claim, db):
if abspath in anchor_paths:
for entry in manifest.valid_entries:
if str(entry.abspath) == abspath and _hash_consistent(
fhash, entry.sha256
):
return entry
return None
[docs]
def is_claim_grounded(claim_location: str, *, workdir: str = ".") -> Dict:
"""Per-claim grounding verdict for a live inline editor.
Resolves ``claim_location`` (a bare ``claim_id`` OR a ``file.tex:L42``
location — reusing :func:`scitex_clew._claim._model._resolve_claim`, the
SAME resolution ``verify_claim`` uses) against the clew DB + registered-
source manifest found under ``workdir``, and returns a richer verdict
than :func:`~._gate.is_grounded`'s bare bool.
This function OWNS opening the sources manifest and the clew DB
internally (via ``workdir``) — callers must never construct a
``SourcesManifest`` or a DB handle directly.
Parameters
----------
claim_location : str
A claim_id or a ``file.tex:L42``-style location.
workdir : str, optional
Directory the clew DB + sources manifest are resolved under
(default ``"."`` — the current working directory, byte-identical to
every other clew call that resolves implicitly from cwd since
``Path(".").resolve()`` is the cwd).
Returns
-------
dict
The ``GroundingVerdict``::
{
"grounded": bool, # see Notes — never disagrees with
# the aggregate gate
"claim_id": str,
"matched_source": {"path": str, "sha256": str} | None,
"reason": str, # one of GROUNDING_REASONS
"fix_hint": str,
}
Notes
-----
``grounded`` NEVER disagrees with the aggregate ``verify_all_claims`` /
``export_claims_json`` gate on the same claim: when a manifest is
present, ``grounded`` is the LITERAL return value of
:func:`~._gate.is_grounded` (including its defensive-True when the
manifest has no valid anchors at all — reason folds to ``"grounded"``,
there is no separate reason for that edge case); when no manifest exists
at all, the aggregate gate is INACTIVE (it never demotes a claim), so
``grounded=True`` here too — reason ``"no_manifest"`` carries the
"amber, not a failure" nuance the bool alone cannot express.
"""
root = Path(workdir).resolve()
db_path = _resolve_db_path_for_workdir(root)
db = VerificationDB(db_path=db_path)
_ensure_claims_table(db)
claim = _resolve_claim(claim_location, db)
if claim is None:
return _verdict(
grounded=False,
claim_id=claim_location,
matched_source=None,
reason="claim_not_found",
)
sources_path = _resolve_sources_path_for_workdir(root)
manifest = load_sources_manifest(sources_path, root=root)
if manifest is None:
return _verdict(
grounded=True,
claim_id=claim.claim_id,
matched_source=None,
reason="no_manifest",
)
grounded = is_grounded(claim, manifest, db)
if not grounded:
reason = "manifest_untrusted" if not manifest.trusted else "no_chain_match"
matched_source = None
else:
reason = "grounded"
entry = _find_matched_entry(claim, manifest, db)
matched_source = (
{"path": entry.path, "sha256": entry.sha256}
if entry is not None
else None
)
return _verdict(
grounded=grounded,
claim_id=claim.claim_id,
matched_source=matched_source,
reason=reason,
)
# EOF