diff --git a/arborist/cli.py b/arborist/cli.py index e6d62b2..205a022 100644 --- a/arborist/cli.py +++ b/arborist/cli.py @@ -531,8 +531,19 @@ def _cmd_query(args: argparse.Namespace) -> int: except Exception: # pragma: no cover — best-effort journaling pass + # Ticket #000031 Phase 3 — stash shards_dir on the result dict + # so the render-layer warrant-chain tail can look up + # warrant-resolver derivations without needing args. Stripped + # before JSON output to keep the json shape stable. + _shards_dir = args.global_shards_dir or args.shards_dir + if _shards_dir: + result["_shards_dir"] = str(_shards_dir) + if args.json: - print(json.dumps(result, indent=2, ensure_ascii=False)) + # Don't leak the internal-only `_shards_dir` field into JSON + # output — strip it before serialize. + json_result = {k: v for k, v in result.items() if not k.startswith("_")} + print(json.dumps(json_result, indent=2, ensure_ascii=False)) else: print(_render_query_human(result, args.question)) return ( @@ -986,11 +997,19 @@ def _render_query_human(result: dict, question: str) -> str: # Schema column stays unchanged; pure display. display_label = _render_audit_label(audit, method, result.get("violations")) warrant_tail = _render_warrant_tail(result) + # Ticket #000031 Phase 3 — when a cited source has a warrant- + # resolver derivation row tying it to a primary-source surface + # chunk, surface a `· warrant: N proven` tail so the user sees + # the chain-of-custody. Pure render layer; cache_key / + # audit_mode / governance_policy_hash all unchanged. The + # _shards_dir is stashed on the result dict by `_cmd_query` + # before render time (the CLI has it; the renderer doesn't). + warrant_chain_tail = _render_warrant_chain_tail(result) lines: list[str] = [] lines.append(question) lines.append( - f" {display_label}{warrant_tail} {n_verified}/{n_quotes} " + f" {display_label}{warrant_tail}{warrant_chain_tail} {n_verified}/{n_quotes} " f"{elapsed} ({cache_status})" ) lines.append("") @@ -3332,6 +3351,46 @@ def _cmd_selfmodel_list(args: argparse.Namespace) -> int: return 0 +def _render_warrant_chain_tail(result: dict) -> str: + """Compute a render-layer tail showing how many cited sources + have a `warrant-resolver-v1` derivations chain back to a primary- + source surface (ticket #000031 Phase 3). + + Returns ``""`` when no shards_dir is on the result dict, no + chains exist, or the lookup fails — keeps the render path silent + in absence of data. + Returns ``" · warrant: 1 proven"`` (or higher count) when cited + sources have Merkle-bound bindings to textbook surface chunks. + + The actual ladder upgrade — POINTER-LINKED → ANCHOR-WARRANTED → + EVIDENCE-WARRANTED based on warrant chains — is intentionally + NOT done here. This is a positive-signal render addition; the + underlying audit_mode + violations stay as the verifier + produced them. + + Reads ``result["_shards_dir"]`` (stashed by ``_cmd_query`` before + render time). The render function takes only ``(result, question)`` + so the shards_dir threads through the result dict. + """ + shards_dir = result.get("_shards_dir") + if not shards_dir: + return "" + sources = result.get("sources") or [] + if not sources: + return "" + try: + from arborist.qa.warrant_resolver import warrant_chains_for_sources + + chains = warrant_chains_for_sources(sources, shards_dir) + except Exception: + return "" + if not chains: + return "" + n = len(chains) + label = "warrant" if n == 1 else "warrants" + return f" · {label}: {n} proven" + + def _cmd_warrant_status(args: argparse.Namespace) -> int: """Read-only — show what surface chunks the citation resolver finds for each claim-pack record. JSON output. No DB writes. diff --git a/arborist/qa/warrant_resolver.py b/arborist/qa/warrant_resolver.py index 795ee14..d4ae440 100644 --- a/arborist/qa/warrant_resolver.py +++ b/arborist/qa/warrant_resolver.py @@ -344,27 +344,73 @@ def _fts5_search( _FTS_STOPWORDS = {"of", "the", "a", "an", "and", "or", "to", "in", "on", "is", "by"} +# Categorical terms that classify a record but don't discriminate +# between records ("Axiom of X", "Theorem of Y" — both share "Axiom" +# / "Theorem"). Drop these from the discriminating-token set so the +# resolver can find DIFFERENT chunks per axiom in the same textbook. +# Without this filter, "Pasch's Axiom" and "Axiom of Betweenness" +# both rank for "axiom" and end up pointing at the same chunk. +_GENERIC_THEOREM_TERMS = { + "axiom", "axioms", "theorem", "theorems", "lemma", "lemmas", + "principle", "principles", "rule", "rules", "law", "laws", + "identity", "identities", "definition", "definitions", + "property", "properties", "schema", +} + + +def _discriminating_tokens(theorem_name: str) -> list[str]: + """Pull the tokens from a theorem name that distinguish it from + sibling theorems in the same textbook. Drops categorical terms + like "Axiom" / "Theorem" / "Principle"; keeps proper-noun-ish or + domain-specific words. + + "Pasch's Axiom" → ["Pasch"] + "Axiom of Betweenness" → ["Betweenness"] + "Law of Excluded Middle" → ["Excluded", "Middle"] + "Newton's First Law" → ["Newton", "First"] + """ + if not theorem_name: + return [] + out = [] + for t in re.findall(r"[A-Za-z]{3,}", theorem_name): + low = t.lower() + if low in _FTS_STOPWORDS or low in _GENERIC_THEOREM_TERMS: + continue + out.append(t) + return out + def _build_fts_query(citation: Citation, theorem_name: str = "") -> str: """Compose an FTS5 MATCH query from a citation + theorem name. - Strategy: union of significant tokens from the theorem name plus - author last name. FTS5's default tokenizer indexes lowercase ASCII - words. + Strategy: discriminating tokens from the theorem name (proper + nouns / domain terms, NOT categorical "Axiom"/"Theorem") + AND'd together — FTS5 will only return chunks that contain ALL + of them. Falls back to author + title tokens when no + discriminating signal is available. + + The shift from OR to AND on the discriminating tokens is the + key fix: per-axiom queries now point at per-axiom chunks + (Pasch's Axiom → chunk mentioning "Pasch"; Axiom of Betweenness + → chunk mentioning "Betweenness"), not at one shared chunk + that mentions "axiom" everywhere. """ + discriminating = _discriminating_tokens(theorem_name) + if discriminating: + # AND-join on discriminating tokens (FTS5 default operator + # IS AND — but explicit for readability + safety against + # quoting weirdness on multi-token records). + return " AND ".join(t.lower() for t in discriminating) + # Fall back: author last name + title tokens, OR'd. parts: list[str] = [] - if theorem_name: - toks = [ - t for t in re.findall(r"[A-Za-z]{3,}", theorem_name) - if t.lower() not in _FTS_STOPWORDS - ] - parts.extend(toks) for a in citation.authors: last = a.split()[-1] if a else "" if last and len(last) >= 4 and last not in parts: parts.append(last) - if not parts and citation.title: - parts = [t for t in re.findall(r"[A-Za-z]{4,}", citation.title)][:4] + if citation.title: + for t in re.findall(r"[A-Za-z]{4,}", citation.title)[:4]: + if t.lower() not in _GENERIC_THEOREM_TERMS: + parts.append(t) return " OR ".join(p.lower() for p in parts) if parts else "" @@ -645,6 +691,75 @@ def _has_derivation(db_path: str, record_root: str) -> bool: conn.close() +def warrant_chains_for_sources( + sources: list[dict], + shards_dir: Path | str, +) -> list[dict]: + """Given a list of cited source dicts (each with ``document_root``), + return entries for those whose document_root has a warrant-resolver + derivation row. + + Used at Q&A render time to flip the four-rung-ladder display label + from ANCHOR-WARRANTED toward EVIDENCE-WARRANTED when the cited + record has a Merkle-proven binding to a primary-source surface. + + Each returned entry: ``{record_root, surface_root, surface_title, + surface_uri, chunk_idx, host_shard}``. + """ + if not sources: + return [] + shards_dir = Path(shards_dir).expanduser() + + # Walk every shard once to collect the warrant-resolver derivations. + # The cost is bounded by total number of derivation rows across all + # shards in the cluster — small relative to a Q&A render. + chains: list[dict] = [] + for db in sorted(shards_dir.glob("*.db")): + if db.name.startswith(("qa.", "snapshots.")): + continue + try: + conn = sqlite3.connect(f"file:{db}?mode=ro", uri=True) + except sqlite3.OperationalError: + continue + try: + for row in conn.execute( + "SELECT dr.core_root, dr.src_root, dr.proof_blob " + "FROM derivations dr " + "WHERE dr.process_id = ?", + (WARRANT_PROCESS_ID,), + ): + core, src, blob = row + # Match this derivation to one of the answer's cited + # source document_roots. + for s in sources: + if s.get("document_root") == core: + try: + pb = json.loads(blob) + chunk_idx = pb.get("chunk_idx") + except (TypeError, ValueError): + chunk_idx = None + chains.append({ + "record_root": core, + "surface_root": src, + "chunk_idx": chunk_idx, + "host_shard": str(db), + }) + break + finally: + conn.close() + # De-duplicate by (record_root, surface_root) — same chain may + # show up if the same record is cited multiple times. + seen: set[tuple[str, str]] = set() + unique: list[dict] = [] + for c in chains: + k = (c["record_root"], c["surface_root"]) + if k in seen: + continue + seen.add(k) + unique.append(c) + return unique + + # --------------------------------------------------------------------------- # High-level orchestration # ---------------------------------------------------------------------------