diff --git a/Makefile b/Makefile index 47e4168..2d47049 100644 --- a/Makefile +++ b/Makefile @@ -211,9 +211,18 @@ query: bootstrap ## ask the corpus a question [JSON=1 BURN=1 LLM=qwen|hermes REP # tree node with a Bates id + a session-wide subtree_hash. Forking is # implicit: /cd to a parent node and ask again → sibling under that # parent. See arborist/qa/session.py + docs/sessions.md. -session: bootstrap ## interactive multi-turn Q&A REPL [SID=... LLM=qwen|hermes ANSWER_MODE=... BURN=1] - $(if $(LLM_ENDPOINT),@echo "# llm: $(LLM_ENDPOINT) / $(LLM_MODEL)" >&2,) - $(ARBORIST) --shards-dir $(SHARDS_DIR) session $(if $(SID),--sid $(SID),) --top-k $(QUERY_TOP_K) $(if $(LLM_ENDPOINT),--endpoint $(LLM_ENDPOINT) --model $(LLM_MODEL),) $(if $(ANSWER_MODE),--answer-mode $(ANSWER_MODE),) $(if $(BURN),--burn,) +# +# Session defaults to Qwen3.6-27B (better answers for interactive +# multi-turn use). Override with LLM=hermes for the 8B fast path. +SESSION_LLM_ENDPOINT ?= https://qwen.ai.unturf.com/v1 +SESSION_LLM_MODEL ?= Qwen3.6-27B-UD-Q4_K_XL.gguf +ifeq ($(LLM),hermes) +SESSION_LLM_ENDPOINT := https://hermes.ai.unturf.com/v1 +SESSION_LLM_MODEL := adamo1139/Hermes-3-Llama-3.1-8B-FP8-Dynamic +endif +session: bootstrap ## interactive multi-turn Q&A REPL [SID=... LLM=qwen|hermes ANSWER_MODE=... BURN=1]; defaults LLM=qwen + @echo "# llm: $(SESSION_LLM_ENDPOINT) / $(SESSION_LLM_MODEL)" >&2 + $(ARBORIST) --shards-dir $(SHARDS_DIR) session $(if $(SID),--sid $(SID),) --top-k $(QUERY_TOP_K) --endpoint $(SESSION_LLM_ENDPOINT) --model $(SESSION_LLM_MODEL) $(if $(ANSWER_MODE),--answer-mode $(ANSWER_MODE),) $(if $(BURN),--burn,) session-list: bootstrap ## list all session dbs in ~/.arborist/sessions/ $(ARBORIST) session --list $(if $(JSON),--json,) diff --git a/arborist/cli.py b/arborist/cli.py index afe5860..85a6110 100644 --- a/arborist/cli.py +++ b/arborist/cli.py @@ -7502,6 +7502,15 @@ def build_parser() -> argparse.ArgumentParser: return p +def _ignore(fn, *a, **kw): + """Run fn, swallow any exception. Used for atexit hooks where + failure (e.g. write to a closed file) shouldn't crash shutdown.""" + try: + fn(*a, **kw) + except Exception: + pass + + def _cmd_session(args: argparse.Namespace) -> int: """Merkle-rooted multi-turn Q&A REPL with forks. @@ -7661,6 +7670,28 @@ def _session_repl(args: argparse.Namespace, sess) -> int: ) from arborist.store import connect as _connect + # readline gives up-arrow line history + emacs/vi line editing + # in the input() call below. The module is in the stdlib but + # importing it is what wires it into input(). Persist history to + # ~/.arborist/sessions/.history so commands survive across runs. + try: + import readline + _hist = ( + Path.home() / ".arborist" / "sessions" / ".history" + ) + _hist.parent.mkdir(parents=True, exist_ok=True) + try: + readline.read_history_file(str(_hist)) + except (FileNotFoundError, OSError): + pass + readline.set_history_length(2000) + import atexit + atexit.register( + lambda: _ignore(readline.write_history_file, str(_hist)) + ) + except ImportError: + pass + shards_dir = getattr(args, "global_shards_dir", None) or getattr( args, "shards_dir", None ) diff --git a/arborist/qa/providence_query.py b/arborist/qa/providence_query.py index 4def8ef..2384673 100644 --- a/arborist/qa/providence_query.py +++ b/arborist/qa/providence_query.py @@ -308,6 +308,59 @@ def providence_query( def emit(self, *a, **kw): pass progress = _NoProgress() + # 0. π* canonical projection preflight (math / algebra / logic). + # For questions that are pure arithmetic ("33/66", "0.1+0.2"), + # pure algebra ("x^2 - 4 = 0"), or pure propositional logic + # ("A AND NOT B"), short-circuit retrieval + LLM entirely and + # return the deterministic canonical bytes. Without this, + # "33/66" was tokenized by FTS5 as ["33", "66"] which matched + # the year-article titles "33" and "66" — LLM then "interpreted" + # the bare fraction as years 33 AD / 66 AD. The legacy query() + # pipeline has the same preflight; providence_query was missing + # it (#000015 / #000030). + # Off-by-default for tests + bench paths that need deterministic + # retrieval shapes; ON for interactive (set by callers). + if policy.get("canonical_projection_preflight", True): + try: + from arborist.qa.query import _canonical_projection_preflight + preflight = _canonical_projection_preflight(question) + except Exception: + preflight = None + if preflight is not None: + pi_star_ref, canonical_bytes = preflight + answer_text = canonical_bytes.decode("utf-8", errors="replace") + progress.emit("canonical_projection.hit", pi_star_ref=pi_star_ref) + return { + "status": "canonical_projection", + "answer_text": answer_text, + "raw_answer": answer_text, + "audit_mode": "CANONICAL_PROJECTION", + "verifier_method": "canonical_projection", + "n_quotes": 0, "n_verified": 0, "violations": [], + "sources": [], + "pi_star_ref": pi_star_ref, + "capacity": {}, + "timings": {"total": _time.time() - t_total}, + "model": model_id, + "cache_key": "", + "lookup_path": "preflight_canonical", + "burned_existing": 0, + "audit_event_hash": None, + "run_dag_root": None, + "context_root": "", + "prompt_chars": { + "messages_total": 0, "system_prompt": 0, + "evidence_or_context": 0, "user_question": len(question), + "grounding_reminder": 0, + }, + "answer_chars": len(answer_text), + "elapsed_s": round(_time.time() - t_total, 3), + "unverified_quotes": [], + "partially_verified_quotes": [], + "warrant_proven_claim_idxs": [], + "format_collapsed": None, + } + # 1. RETRIEVAL ONLY — fast phase. Body + title FTS5 in parallel, # merge by best bm25 per doc_root. Body alone misses canonical # primary articles when high-DF tokens dilute the match (e.g.