#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Unified manuscript-claims export — one render feed for value + citation + figure.
This is the compile-time bridge scitex-writer's "Clew Render" pre-flight calls
(Option A, agreed 2026-07-01): it reads BOTH clew ledgers — value/figure claims
(the ``claims`` table via :func:`list_claims`) and citation nodes (the
``citations`` table via :func:`scitex_clew.list_citations`) — and emits ONE
inline ``claims`` list in writer's FROZEN render schema, so the compiler renders
all three kinds with the same green/amber/red marker + link.
Per-entry frozen schema (what writer joins/renders on)::
{
"claim_id": join key — value: claim_id; citation: cite_key;
figure: the (explicit) claim_id (image save-path),
"claim_type": "value" | "citation" | "figure" (render style),
"status": 4-bucket "verified" | "suspect" | "failed" | "exception",
"claim_value": verbatim value (value; optional for citation/figure),
"display_color": resolved 6-hex for the status (no '#'),
"link": href — citation: DOI url; value/figure: source path,
... extra provenance (raw_status / resolved_status / doi / …) — writer
ignores for rendering.
}
Top-level adds ``palette`` (4-bucket display palette), ``status_palette``
(full-7 status→hex), ``display_groups`` (full-7 status → display bucket) +
``attestation`` (badge facts: ``badge_state`` + ``counts``). Status mapping
for citations: verified→verified, stub→failed (red), unverified→suspect
(amber) — clew's stored citation statuses; ``unknown`` is a verify-time
verdict only, never a stored row, so it never appears here.
Emit model (writer's Option A): the compile calls this LAST (last-write-wins) so
render_clew reads the complete unified shape. It writes to the same canonical
``.scitex/clew/runtime/claims.json`` by default (``path=`` overrides for a
dedicated file). clew-absent → the compiler skips this call and render_clew
no-ops.
"""
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 ._export import _resolve_chain_flags
from ._model import (
_CLAIM_PALETTE,
_DISPLAY_GROUPS,
_DISPLAY_PALETTE,
_resolve_status,
)
from ._register import list_claims
# Stored citation status -> writer's 4-bucket render group.
_CITATION_STATUS_TO_GROUP: Dict[str, str] = {
"verified": "verified",
"stub": "failed", # hallucinated / placeholder -> RED
"unverified": "suspect", # cited, not yet scholar-confirmed -> amber
}
def _claim_entry(claim, grounded=None) -> Dict:
"""Map a value/figure/statistic/table/text claim to a unified render entry.
``grounded`` is the registered-source gate signal (None => gate inactive,
identical to the pre-gate behavior; False => demote to ``unsourced``).
"""
has_exception, has_frozen = _resolve_chain_flags(claim)
resolved = _resolve_status(claim.status, has_exception, has_frozen, grounded)
group = _DISPLAY_GROUPS[resolved]
claim_type = "figure" if claim.claim_type == "figure" else "value"
return {
"claim_id": claim.claim_id,
"claim_type": claim_type,
"status": group,
"claim_value": claim.claim_value,
"display_color": _DISPLAY_PALETTE[group],
# value -> its source; figure -> its recipe/data (both source_file).
"link": claim.source_file,
# --- provenance (writer ignores for rendering) ---
"raw_claim_type": claim.claim_type,
"raw_status": claim.status,
# Full-7 resolved status (author tooling / DAG fidelity).
"resolved_status": resolved,
"file_path": claim.file_path,
"line_number": claim.line_number,
"source_file": claim.source_file,
"source_session": claim.source_session,
"chain_has_exception": has_exception,
"chain_has_frozen": has_frozen,
}
def _citation_entry(citation) -> Dict:
"""Map a citation node to a unified render entry."""
group = _CITATION_STATUS_TO_GROUP.get(citation.status, "failed")
return {
"claim_id": citation.cite_key,
"claim_type": "citation",
"status": group,
"claim_value": None,
"display_color": _DISPLAY_PALETTE[group],
"link": citation.link, # scholar url > https://doi.org/<doi> > None
# --- provenance (writer ignores for rendering) ---
"raw_status": citation.status,
"doi": citation.doi,
"source_id": citation.source_id,
"is_stub": citation.is_stub,
"manuscript_file": citation.manuscript_file,
"line_number": citation.line_number,
}
[docs]
def export_manuscript_claims(
path: Optional[Union[str, Path]] = None,
*,
read_only: bool = True,
) -> Path:
"""Emit the unified render feed (value + citation + figure) to claims.json.
Reads both clew ledgers and writes ONE ``claims`` list in scitex-writer's
frozen render schema. This is the compile-time exporter behind
``clew export-claims --unified``; the compiler calls it last so render_clew
reads the complete unified shape.
Parameters
----------
path : str | Path, optional
Output path. Resolution mirrors :func:`export_claims_json`:
explicit ``path`` > ``$SCITEX_CLEW_CLAIMS_JSON`` >
``<project_root>/.scitex/clew/runtime/claims.json`` (the canonical file
render_clew reads). Pass an explicit path for a dedicated file.
read_only : bool, optional
``chmod 0o444`` the file after writing (default True — it is derived).
Returns
-------
Path
The path written (absolute).
"""
from .._db import _core as _db_core
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)
# Registered-source gate (opt-in): load the manifest ONCE. Absent -> None
# -> gate inactive (grounded stays None). Malformed -> ValueError.
from .._db import get_db
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()
def _grounded_for(claim):
if not _gate_active:
return None
return is_grounded(claim, _manifest, _gate_db)
entries: List[Dict] = [
_claim_entry(c, _grounded_for(c)) for c in list_claims(limit=100_000)
]
# Citations live in their own ledger; a missing citations table (e.g. no
# citations ever registered) must not break the export.
try:
from .._citation import list_citations
entries.extend(_citation_entry(c) for c in list_citations(limit=100_000))
except Exception: # noqa: BLE001 — citation ledger optional
pass
total = len(entries)
bucket_counts = {
bucket: sum(1 for e in entries if e["status"] == bucket)
for bucket in ("verified", "suspect", "failed", "exception")
}
# Schema v1.4.1: `unsourced` FOLDS into the suspect display bucket, so it is
# no longer a bucket VALUE — but the attestation still reports HOW MANY
# claims are ungrounded, counted from the full-8 `resolved_status` (which
# keeps `unsourced` distinct for author/DAG fidelity).
unsourced_count = sum(
1 for e in entries if e.get("resolved_status") == "unsourced"
)
verified_count = bucket_counts["verified"]
# Raw ledger statuses (claims only; citations never carry these).
mismatch_count = sum(1 for e in entries if e.get("raw_status") == "mismatch")
missing_count = sum(1 for e in entries if e.get("raw_status") == "missing")
# Badge state: failing if ANY entry is in the failed bucket (claim
# mismatch/missing or stub citation); all_verified iff every entry is
# verified (vacuously true for an empty ledger); else partial.
if bucket_counts["failed"] > 0:
badge_state = "failing"
elif verified_count == total:
badge_state = "all_verified"
else:
badge_state = "partial"
try:
_pkg_version = importlib.metadata.version("scitex-clew")
except importlib.metadata.PackageNotFoundError:
_pkg_version = "0.0.0"
payload = {
"_note": (
"AUTO-GENERATED by scitex_clew.export_manuscript_claims() — the "
"UNIFIED render feed (value + citation + figure) in scitex-writer's "
"frozen schema. Regenerated at compile; do NOT edit by hand. "
"Per-entry status is the 4-bucket display group "
"(verified|suspect|failed|exception); attestation.counts maps the "
"ledger statuses onto it: failed = claim mismatch+missing + stub "
"citations; suspect includes registered (never-verified) claims "
"and scholar-unconfirmed citations; unverified = total - verified. "
"Superseded claims are excluded from all entries and counts."
),
"schema_version": "1.6-unified",
"generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
# 4-bucket display palette (what writer renders with).
"palette": dict(_DISPLAY_PALETTE),
# Full-7 status palette + collapse map (author tooling / DAG fidelity).
"status_palette": dict(_CLAIM_PALETTE),
"display_groups": dict(_DISPLAY_GROUPS),
"attestation": {
"text": "Provenance checked by SciTeX Clew.",
"tool": "scitex-clew",
"version": _pkg_version,
"total": total,
"verified_count": verified_count,
"unverified_count": total - verified_count,
# Badge facts (writer renders the badge from attestation+palette).
"badge_state": badge_state,
"counts": {
"total": total,
"verified": verified_count,
"unverified": total - verified_count,
"suspect": bucket_counts["suspect"],
"failed": bucket_counts["failed"],
"exception": bucket_counts["exception"],
# Schema v1.6-unified: source-gate demotions (0 if inactive).
# Counted from resolved_status (unsourced folds into suspect at
# the display bucket but is still reported here).
"unsourced": unsourced_count,
"mismatch": mismatch_count,
"missing": missing_count,
},
},
"claims": entries,
}
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:
pass
return path
# EOF