Source code for scitex_clew._claim._export

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Claim export — enriched claims.json + provenance-chain flag resolution."""

from __future__ import annotations

import importlib.metadata
import json
import os
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, List, Optional, Union

from .._db import get_db
from ._model import (
    _CLAIM_PALETTE,
    _DISPLAY_GROUPS,
    _DISPLAY_PALETTE,
    _PALETTE_FALLBACK,
    _resolve_display_group,
    _resolve_status,
)


def _resolve_chain_flags(claim) -> tuple:
    """Derive ``chain_has_exception`` and ``chain_has_frozen`` for a claim.

    Walks the provenance DAG reachable from the claim's ``source_session``
    (or the newest producer of ``source_file`` when only that is available)
    using the **existing** :func:`scitex_clew._chain._routes.resolve_file_dag`
    walk — no custom DAG traversal here.

    Rules
    -----
    * ``chain_has_exception``: any run reachable from the starting session has
      ``provenance == 'exception'`` in the ``runs`` table.
    * ``chain_has_frozen``: any ``file_hashes`` row whose ``session_id`` is in
      the resolved session set has ``frozen == 1``.
    * If neither ``source_session`` nor ``source_file`` is present (claim has
      no computable provenance), both flags default to ``False`` — safe.
    * Never re-hashes anything: DB-metadata only, cheap per claim.

    Returns
    -------
    tuple of (bool, bool)
        ``(chain_has_exception, chain_has_frozen)``.
    """
    from .._chain._routes import resolve_file_dag

    db = get_db()

    # Determine the leaf session to start the backward DAG walk.
    session_id = claim.source_session
    if not session_id and claim.source_file:
        sessions = db.find_session_by_file(
            str(Path(claim.source_file).resolve()), role="output"
        )
        if sessions:
            session_id = sessions[0]

    if not session_id:
        # No provenance link at all — safe defaults.
        return False, False

    # Walk the provenance DAG from the leaf session backward.
    _, all_ids = resolve_file_dag([session_id], db=db)

    if not all_ids:
        return False, False

    # Build the IN-clause placeholders for the set of session IDs.
    placeholders = ", ".join("?" * len(all_ids))
    id_list = list(all_ids)

    chain_has_exception = False
    chain_has_frozen = False

    with db._connect() as conn:
        # chain_has_exception: any run has provenance == 'exception'
        row = conn.execute(
            f"SELECT 1 FROM runs WHERE session_id IN ({placeholders})"
            " AND provenance = 'exception' LIMIT 1",
            id_list,
        ).fetchone()
        if row:
            chain_has_exception = True

        # chain_has_frozen: any file_hashes row has frozen == 1
        row2 = conn.execute(
            f"SELECT 1 FROM file_hashes WHERE session_id IN ({placeholders})"
            " AND frozen = 1 LIMIT 1",
            id_list,
        ).fetchone()
        if row2:
            chain_has_frozen = True

    return chain_has_exception, chain_has_frozen


def _resolve_exception_reasons(claim) -> List[tuple]:
    """Return the list of exception nodes in a claim's provenance chain.

    Walks the SAME provenance DAG that :func:`_resolve_chain_flags` walks,
    reusing :func:`scitex_clew._chain._routes.resolve_file_dag` exactly —
    no custom traversal.

    Returns
    -------
    list of (str, str)
        ``[(session_id, reason), ...]`` for every run in the resolved session
        set whose ``provenance == 'exception'``. A NULL/empty ``exception_reason``
        in the DB is replaced by the string ``"no reason given"``.
        Returns an empty list when the claim has no provenance link or there
        are no exception nodes in the chain.
    """
    from .._chain._routes import resolve_file_dag

    db = get_db()

    # Determine the leaf session to start the backward DAG walk.
    session_id = claim.source_session
    if not session_id and claim.source_file:
        sessions = db.find_session_by_file(
            str(Path(claim.source_file).resolve()), role="output"
        )
        if sessions:
            session_id = sessions[0]

    if not session_id:
        return []

    # Walk the provenance DAG from the leaf session backward.
    _, all_ids = resolve_file_dag([session_id], db=db)

    if not all_ids:
        return []

    placeholders = ", ".join("?" * len(all_ids))
    id_list = list(all_ids)

    with db._connect() as conn:
        rows = conn.execute(
            f"SELECT session_id, exception_reason FROM runs"
            f" WHERE session_id IN ({placeholders})"
            f" AND provenance = 'exception'"
            f" ORDER BY session_id",
            id_list,
        ).fetchall()

    result = []
    for row in rows:
        sid = row[0]
        reason = row[1]
        if not reason:
            reason = "no reason given"
        result.append((sid, reason))

    return result


[docs] def export_claims_json( path: Optional[Union[str, Path]] = None, *, file_path_filter: Optional[str] = None, read_only: bool = True, include_superseded: bool = False, ) -> Path: """Export every registered claim to a canonical JSON artifact. The exported file is the single human-readable + machine-consumable view of the claims table in ``clew.db``. The DB remains the source of truth; this JSON is a regenerable artifact. Path resolution (mirrors :func:`scitex_clew._db._core._default_db_path`):: 1. Explicit ``path`` argument. 2. ``$SCITEX_CLEW_CLAIMS_JSON`` env var (escape hatch). 3. ``<project_root>/.scitex/clew/runtime/claims.json`` (project root = nearest ancestor dir with ``.git`` or ``pyproject.toml``; falls back to cwd if none found). Parameters ---------- path : str | Path, optional Override the resolved path. Useful for tests / one-off dumps. file_path_filter : str, optional When set, only claims registered against this manuscript file path are exported. Default: every claim in the DB. read_only : bool, optional After writing, ``chmod 0o444`` the file so accidental edits fail loudly at the OS layer. Default True (the file IS derived). Set False for tests that need to mutate the file. include_superseded : bool, optional When False (default), superseded claims are excluded from the exported JSON — consumers should only see active claims. Pass True to include them (audit/debug use). Returns ------- Path The path the artifact was written to (absolute). Examples -------- >>> import scitex_clew as clew >>> clew.add_claim("paper.tex", "value", 42, "0.94", source_file="r.csv") >>> # claims.json now auto-exported under ./.scitex/clew/runtime/ >>> clew.export_claims_json() # idempotent — re-emit on demand PosixPath('.../.scitex/clew/runtime/claims.json') """ from .._db import _core as _db_core from ._register import list_claims if path is None: env_path = os.environ.get("SCITEX_CLEW_CLAIMS_JSON") if env_path: path = Path(env_path) else: path = _db_core._default_claims_json_path(_db_core._find_project_root()) path = Path(path).resolve() path.parent.mkdir(parents=True, exist_ok=True) claims = list_claims( file_path=file_path_filter, limit=10_000, include_superseded=include_superseded, ) # Registered-source gate (opt-in): load the manifest ONCE. Absent -> None # -> gate inactive (grounded stays None -> zero behavior change). Malformed # -> ValueError (fail loud). from .._sources import is_grounded, load_sources_manifest _manifest = load_sources_manifest() _gate_active = _manifest is not None and _manifest.active _gate_db = get_db() # Build per-claim dicts with v1.1 enrichment fields appended AFTER all # existing fields so the existing field order (and thus byte-positions for # streaming parsers) is unchanged. New fields are purely additive. enriched_claims = [] # Accumulate all exception nodes across all claims for top-level dedup list. _all_exception_pairs: Dict[str, str] = {} # session_id -> reason (deduped) for c in claims: base = c.to_dict() # all existing fields, byte-identical chain_has_exception, chain_has_frozen = _resolve_chain_flags(c) # Schema v1.4: registered-source gate. grounded is None when the gate # is inactive (no manifest) -> identical to v1.3. When active, False # demotes an otherwise-green claim to "unsourced". grounded = None if _gate_active: grounded = is_grounded(c, _manifest, _gate_db) # Schema v1.3/1.4: resolved full-8 status via color precedence — # mismatch/missing > unsourced > [verified claims only: exception > # frozen] > suspect > verified > registered resolved_status = _resolve_status( c.status, chain_has_exception, chain_has_frozen, grounded ) base["color"] = _CLAIM_PALETTE.get(resolved_status, _PALETTE_FALLBACK) base["resolved_status"] = resolved_status base["chain_has_exception"] = chain_has_exception base["chain_has_frozen"] = chain_has_frozen # Schema v1.4 additive: per-claim groundedness (None => gate inactive). base["grounded"] = grounded # Schema v1.3: display group + display color (color-only) display_group = _resolve_display_group( c.status, chain_has_exception, chain_has_frozen, grounded ) base["display_group"] = display_group base["display_color"] = _DISPLAY_PALETTE[display_group] # Schema v1.3 additive: exception_reasons for this claim's chain. if chain_has_exception: exc_pairs = _resolve_exception_reasons(c) base["exception_reasons"] = [reason for _, reason in exc_pairs] # Accumulate into the dedup dict. for sid, reason in exc_pairs: _all_exception_pairs.setdefault(sid, reason) else: base["exception_reasons"] = [] enriched_claims.append(base) # --------------------------------------------------------------------------- # Schema v1.3: attestation + legend blocks (4-state, no subbadges). # --------------------------------------------------------------------------- try: _pkg_version = importlib.metadata.version("scitex-clew") except importlib.metadata.PackageNotFoundError: _pkg_version = "0.0.0" claims_count = len(claims) verified_count = sum(1 for c in claims if c.status == "verified") unverified_count = claims_count - verified_count # Schema v1.4 additive: how many claims the source gate demoted to amber. unsourced_count = sum( 1 for e in enriched_claims if e["resolved_status"] == "unsourced" ) # Schema v1.3: 4 display buckets — the reader's legend (color-only, no icons). legend_statuses = [ { "status": "verified", "color": _DISPLAY_PALETTE["verified"], "marker": "wavy-underline", "label": "verified — matches its source", }, { "status": "suspect", "color": _DISPLAY_PALETTE["suspect"], "marker": "wavy-underline", "label": ( "suspect — not confirmed: upstream unverified, never verified, " "or reaches no registered source" ), }, { "status": "failed", "color": _DISPLAY_PALETTE["failed"], "marker": "wavy-underline", "label": "failed — mismatch or missing against source", }, { "status": "exception", "color": _DISPLAY_PALETTE["exception"], "marker": "wavy-underline", "label": "exception — auto-verification chain does not connect through this declared node (transparently NOT auto-verified)", }, # Schema v1.4.1 (operator decision): NO separate `unsourced` legend row — # it folds into the amber `suspect` bucket above (see _DISPLAY_GROUPS). ] payload = { "_note": ( "AUTO-GENERATED by scitex_clew.export_claims_json() from " "clew.db. Do NOT edit by hand — re-emit by calling " "scitex_clew.export_claims_json() (default-on after every " "clew.add_claim()) or by re-running your pipeline." ), "schema_version": "1.4", "generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), # Full-8 status palette (author tooling + DAG fidelity). "palette": dict(_CLAIM_PALETTE), # 4-bucket reader palette (color-only, no icons). "display_palette": dict(_DISPLAY_PALETTE), # Per-status collapse map: full-7 status -> display bucket. "display_groups": dict(_DISPLAY_GROUPS), "claims_count": claims_count, "attestation": { "text": "Provenance checked by SciTeX Clew.", "tool": "scitex-clew", "version": _pkg_version, "url": "https://github.com/ywatanabe1989/scitex-clew", "color": _CLAIM_PALETTE["verified"], "verified_count": verified_count, "unverified_count": unverified_count, # Schema v1.4: source-gate demotions (0 when the gate is inactive). "unsourced_count": unsourced_count, }, "legend": { "statuses": legend_statuses, "badge": { "template": "{verified} verified · {unverified} unverified", "all_clear": "Clew Verified — all {n} claims match source", }, }, # Schema v1.3 additive: deduped exception nodes across all claims. # Stable sort by session_id for determinism. "exceptions": [ {"session_id": sid, "reason": reason} for sid, reason in sorted(_all_exception_pairs.items()) ], "claims": enriched_claims, } # Clear any pre-existing read-only bit before rewriting. if path.exists(): try: path.chmod(0o644) except OSError: pass path.write_text(json.dumps(payload, indent=2, default=str)) if read_only: try: path.chmod(0o444) except OSError: # Best-effort — on filesystems that don't support unix # perms (e.g. some Windows mounts) this is a no-op. pass return path
# EOF