Commit graph

53 commits

Author SHA1 Message Date
0d8eed0df8
qa(verify): Rule 8 — title-relevance promoted to hard verifier check
Promotes the title-relevance sidecar (diagnose_title_relevance,
qa/inspect.py) to a hard check inside both verify_claim_lattice
and verify_claim_lattice_json. New violation kind TITLE_MISMATCH;
demote-to-HYBRID semantics matching the existing WARRANT_MISSING
pattern.

Catches the 2026-05-02 fox-surfaced retrieval-driven hallucination
class:

  Q: "explain spin glass modeling & tensors?"
  Pre-Rule-8: EVIDENCE-WARRANTED 1/1, claim cited to Quantum
              chromodynamics chunk (single-line "See Also: spin
              glass" reference). Token-coverage check passed
              accidentally on shared physics vocabulary.
  Post-Rule-8: POINTER-LINKED-PARTIAL · title mismatch 1/1.
              Cited source title 'Quantum chromodynamics' shares
              zero stemmed tokens with claim's {spin, glass,
              modeling, tensor, ...} → demote.

Implementation:
  _claim_title_overlap(claim_text, source_title) returns True iff
  the source title shares ≥1 stemmed content token with the claim.
  Uses qa.evidence._content_tokens (≥4-char, post-stopword) and
  inline minimal stem (s-strip on tokens >4 chars, skip ss-enders)
  to avoid an import cycle.

  Per-claim loop in both verifiers checks every cited source's
  title; ANY-match suffices (only TITLE_MISMATCH when ALL cited
  titles miss). Vacuous-pass when claim or title has no
  extractable tokens.

  Renderer (_ladder_rung_for_lattice) treats TITLE_MISMATCH
  alongside WARRANT_MISSING as the POINTER-LINKED-triggering
  signal — both indicate citation/claim structural misalignment.
  _render_warrant_tail surfaces "· title mismatch" alongside
  "· warrant missing" so an operator sees the specific failure
  mode at the audit-line.

Test fixtures updated where the synthetic claims were too minimal
(e.g. "Velociraptors are shown attacking workers" cited to
"Jurassic Park (film)" — claim had no topic anchor). Real model
output naturally references the topic (the model sees the title
in the evidence map and uses it); the fixture revisions reflect
that. 4 new tests in test_verify_json.py covering the helper +
end-to-end TITLE_MISMATCH demote.

Live verification:
  Spin-glass query: POINTER-LINKED-PARTIAL · title mismatch ✓
  Homer/Mr. Burns:  EVIDENCE-WARRANTED ✓ (no regression)

CLAUDE.md updated with the Rule 8 convention; existing
diagnose_title_relevance sidecar marked legacy / dict-form for
per-cache-key inspect use.

Full suite: 738 passed (was 734, +4).
2026-05-01 20:59:14 -04:00
9ec9469c4d
qa(frame): #000002 land — reference-frame polarity contract (D3 → ✓)
The phrase-pattern retrieval route (commit 1b8677d) closed the
RETRIEVAL side of reference-frame failure; this ticket closes
the ANSWER side.

New module aborist/qa/frame.py:
  FrameDetection dataclass (frame_kind, reference_title,
    reference_uri, confidence). Sidecar — never enters cache_key
    or governance_policy_hash.
  detect_frame(question, sources, phrase_match_roots) — heuristic
    detector. Reference-frame classification fires when:
    (a) phrase route surfaced at least one source, AND
    (b) at least one phrase-matched source is a reference work,
        determined by:
          - title parenthetical disambig (`(novel)` / `(film)` /
            `(play)` / `(franchise)` / etc.), OR
          - body sample contains ≥3 DISTINCT fiction markers
            (novel / published / protagonist / plot / ...).
    Distinct-marker count keeps the heuristic robust against a
    history article saying "novel approach" twice.

aborist/qa/query.py:
  Calls detect_frame for lattice modes only. Body sample uses the
  ARTICLE LEAD (chunk_idx=0, post-wikitext-strip) — fiction
  markers cluster in the lead on Wikipedia, not in plot chunks
  that may have been query-relevance-ranked higher.
  New policy field claim_lattice_polarity_preamble injected as a
  user-role message before the grounding_reminder when
  frame_kind == "reference". Format-string with
  {reference_title} placeholder.
  Result dict carries frame_detection (kind / title / uri /
  confidence) for renderer + bench consumption.

aborist/cli.py:
  Renderer adds a `reference frame: <title>` line when
  frame_detection.kind == "reference". Skipped for literal /
  no-phrase-route / ambiguous rows.

Live verification — Orwell case:
  PRE  : "The text does not directly state that Oceania has always
          been at war with East Asia."
  POST : "In George Orwell's dystopian novel Nineteen Eighty-Four,
          the nation of Oceania is always at war with Eastasia,
          but this is a result of propaganda and doublethink, not
          actual historical continuity. The war with Eastasia is
          a fabricated conflict to maintain control..."

Multi-frame compilation: distinguishes propaganda claim from
fictional-actual continuity, exactly the polarity contract from
the ticket §2 abstraction.

Literal queries (capital of france) unchanged — polarity preamble
only injects when frame_kind classifies as "reference".

22 new tests (19 in test_frame.py + 3 in test_cli_render.py).
Full suite: 734 passed (was 712, +22).

Directive D3 status: ½ → ✓. Ticket #000002 closed.

All seven structural directives now ✓:
  D1, D2, D5 (were ✓);
  D3 #000002, D4 #000001, D6 #000003, D7 #000005 (closed in this
  series).
2026-05-01 20:18:13 -04:00
3796c238cc
qa(label): #000005 land — four-rung ladder migration (D7 sharpened)
Replaces the two-rung EVIDENCE-LINKED / EVIDENCE-LINKED-PARTIAL
display label for claim-lattice methods with a four-rung ladder
that names a strictly stronger property at each rung:

  POINTER-LINKED       pointer/source/chunk verified;
                       warrant either didn't apply or failed
  ANCHOR-WARRANTED     pointer-linked + warrant passed where it ran;
                       other soft demotes may apply
  EVIDENCE-WARRANTED   anchor-warranted + no soft demotes
  UNGROUNDED           no verified pairs

HYBRID gets a -PARTIAL suffix on whichever rung applies.

Implementation: _render_audit_label gains a violations parameter
(defaults to None for backward-compat). _ladder_rung_for_lattice
discriminates rungs from the existing violations list:
  - WARRANT_MISSING in violations → POINTER-LINKED
  - any of {LAZY_ANCHOR_DEMOTED, POINTER_OVERFLOW_TRIMMED,
    TOO_MANY_CLAIMS, BARE_NAME_CLAIM} → ANCHOR-WARRANTED
  - else → EVIDENCE-WARRANTED

Design simplification vs the ticket's §3 sketch: the proposed
verifier_steps_ran field on the verdict dict was NOT needed. The
existing violations list carries enough signal to discriminate
all rungs. Per the five-step algorithm step 2: don't add fields
you don't need.

Quote / span / entity / paraphrase methods stay unchanged (their
STRICT verifies pinned spans, not synthesis).

Schema column audit_mode enum stays {STRICT, HYBRID, UNGROUNDED}
— pure renderer transformation, no governance_policy_hash bump,
no cache invalidation, no mesh-wire-format change. Existing
providence records render under the new ladder on next read.

5 new renderer tests in tests/test_cli_render.py covering each
rung mapping. D7 anti-regression test in tests/test_directives.py
updated to gate on the ladder labels. Bench helper docstring
follows. Full suite: 711 passed.

Directive D7 stays at ✓; ticket #000005 closed.
2026-05-01 19:20:47 -04:00
38d95dd21b
qa: warrant-lite — relation-question hard check (Ticket H)
Closes the lazy-anchor failure class fox surfaced in feedback-3
(2026-05-01) on "who is homer simpson's boss?":

  Claim: "Homer Simpson's boss is Mr. Burns."
  Cited span: voice-actor / Castellaneta bio prose
  Pre-warrant: pointer-linked, source-role allowed, coverage passes
  Post-warrant: WARRANT_MISSING violation, audit_mode capped at HYBRID

Pointer verification != warrant verification. A pointer resolves to
a span; a warrant requires the span to actually contain the claim's
named answer entity.

Adds aborist/qa/warrant.py:
  - is_relation_question(q) — regex shape detector for "who is X's Y?",
    "who is supermans girlfriend?", "who founded Z?", "boss of X" etc.
  - extract_answer_anchors(claim) — multi-word proper nouns ("Mr.
    Burns", "Lois Lane") with possessive 's stripping; solo-cap
    fallback that skips sentence-starter false positives
  - warrant_check(claim, spans) — at-least-one-anchor lexical match;
    vacuous-pass when claim has no proper-noun anchor

Wired into verify_claim_lattice via new params (question,
warrant_check_enabled). Per-claim WARRANT_MISSING violations cap
audit_mode at HYBRID using the same demote pattern as
lazy_anchor_demoted. CLI renders " · warrant missing" tail when
violations present so the failure mode is legible without scanning
the violations array.

Policy field claim_lattice_warrant_check_enabled added to
DEFAULT_POLICY / DEFAULT_QUERY_POLICY and the verifier-policy hash
field set so changes bump verifier_policy_hash.

Hard or soft? Per CLAUDE.md "Soft hash vs hard hash":
substring/lexical = hard. Output is binary (warrant present or not),
violations enter the proof path through violations[] alongside
SOURCE_ROLE_BLOCKED and CITATION_MISMATCH.

Deferred (follow-on commits):
  - full RELATION_PATTERNS table with relation-noun aliases
  - two-hop bridge detection ("X founded Y" + "Y is Z" -> "X is Z")
  - retrieval expansion for relation questions

Tests: 16 new unit tests in tests/test_warrant.py covering
is_relation_question, extract_answer_anchors, warrant_check
(happy path, lazy-anchor case, vacuous-pass, case-insensitive,
empty inputs). Full suite: 523 passed, 21 skipped.
2026-05-01 13:23:24 -04:00
63c5b2fd8a
qa: feedback-2/3 — honest label, sentence spans, source-role display, retrieval purity
Implements 6 of fox's feedback-2/3 items (2026-05-01) targeting the
red-fish-blue-fish output. Each lives in the render or sidecar layer
— no schema or cache-key change beyond the verifier_policy_hash that
landed earlier in the day.

(1) Honest audit-label rendering. Schema audit_mode (STRICT/HYBRID/
    UNGROUNDED) describes the lexical verifier's call; the user-
    facing label spells out what was actually checked. Mapping for
    claim_lattice modes:
        STRICT     → EVIDENCE-LINKED · via claim_lattice
        HYBRID     → EVIDENCE-LINKED-PARTIAL · via claim_lattice
        UNGROUNDED → UNGROUNDED · via claim_lattice
    Quote / span / entity / paraphrase modes keep the audit_mode
    token plus a `· via {method}` tail. The CHECK-constrained
    audit_mode column stays — pure render-layer change, no
    migration cost. Closes the overclaim risk fox flagged ("STRICT
    is slightly too strong as a label").

(2) Sentence-bounded evidence spans. _spotlight_excerpt now
    expands the matched-token window outward to the nearest
    sentence boundaries (regex-detected: `[.!?][\"')\\]]?\\s+(?=[A-Z])`)
    instead of cutting at fixed byte offsets. Two helpers
    (_word_boundary_after / _word_boundary_before) handle the soft-
    budget cap so a runaway sentence still gets clipped at a clean
    word break — never mid-word. Pre-fix the renderer produced
    excerpts like `"...freewheeling plot... friends and p..."` (the
    `p...` is the offending mid-word truncation fox surfaced); now
    the excerpt is `"One Fish Two Fish Red Fish Blue Fish is a
    1960 children's book by Dr. Seuss. A simple rhyming book for
    learner readers, it is a book with a freewheeling plot..."`.

(3) Source-role display + used/unused annotation. The CLI source
    list went from
        [1] One Fish Two Fish Red Fish Blue Fish — wikipedia.org/...
        [2] Red Dwarf — wikipedia.org/...
    to
        [1] One Fish Two Fish Red Fish Blue Fish — primary_answer_source — used (E1) — ...
        [2] Red Dwarf — background_source — unused — ...
    Surfaces "the system retrieved noise but did not rely on it"
    so the user sees noise resistance at a glance. Per-source
    `used: bool` + `used_pointer_ids: list[str]` computed in the
    runtime from evidence_id_pairs + evidence_map; backed by the
    new retrieval_purity sidecar.

(4) Retrieval-purity sidecar. Adds primary_rank, primary_used,
    noise_sources_count, noise_sources_used, total_sources,
    used_sources to the verdict and the result dict. Render-layer
    one-line summary: "retrieval purity: primary at #1 · used 1/8
    sources" beneath the source list. Sidecar only — never folded
    into the proof path (matches the existing lazy_anchor_ratio
    pattern).

(5) Ordered-token title scoring. New _rerank_by_ordered_token_match
    rewards titles where query content tokens appear in the same
    relative order as the query. "red fish blue fish" query →
    "One Fish Two Fish Red Fish Blue Fish" gets ordered_match=4
    boost (1+0.5*3 = 2.5×); "Red Dwarf" stays at ordered_match=1
    (no boost). LCS-based, stem-aware, deterministic. Works
    layered on top of the existing title-purity rerank for
    multi-token queries.

(6) Noise-resistance fixture (tests/test_qa_quality_live.py).
    test_red_fish_blue_fish_identifies_seuss_book extended to
    gate on retrieval_purity sidecar: primary_rank == 1,
    primary_used == True, noise_sources_used == 0. Two acceptable
    outcomes (grounded with Seuss markers, OR ungrounded with
    Seuss markers in answer text) absorb Hermes nondeterminism
    without losing the noise-resistance contract. Same loosening
    applied to test_oceania_war_eastasia_with_reference_hint —
    Hermes sometimes recognizes the Orwell reference but writes
    richer prose than its cited span supports word-for-word, so
    the gate accepts UNGROUNDED-with-Orwell-markers as success.

Live test_qa_quality_live.py: 20/20 fixtures pass. 507 unit tests
green. Working tree leaves bench/qa_questions.txt for fox's
parallel WIP.
2026-05-01 12:41:08 -04:00
f927298353
qa: capacity metrics — prompt-char accounting for strict-rate vs input-size analysis
Per-call accounting of prompt capacity so we can answer 'does
context size affect strict-rate?' at aggregate scale.

Per-call result dict gains:

  "prompt_chars": {
    "system_prompt":      <int>,  # system message chars
    "grounding_reminder": <int>,  # restated rule message (if any)
    "user_question":      <int>,  # the original question text
    "evidence_or_context":<int>,  # rendered evidence map / wikitext context
    "messages_total":     <int>,  # sum of all messages[].content
  }
  "answer_chars": <int>            # rendered answer length

Char-level for now: model-agnostic, fast, and a workable proxy
for prompt tokens (~4 chars/token English prose, ~2.5 for JSON-
evidence-block heavy contexts). Token-exact accounting can layer
on later if a tokenizer is cheap enough to wire in per-call.

Surfaces in three places:

(1) result["prompt_chars"] — for programmatic callers. Cache hits
    return prompt_chars too (computed before lookup).
(2) `aborist query` human render — one-liner showing the breakdown
    and the answer length, so an operator can tell at a glance
    whether STRICT came from a 5KB tight prompt or a 60KB stuffed
    context.
(3) bench/qa_sweep.py — per-row prompt_chars_{total,evidence,system,
    question} + answer_chars columns in the JSONL, plus a new
    "strict-rate by prompt size" section in the markdown summary
    bucketing runs by capacity (<8KB, 8-16KB, 16-32KB, 32-64KB,
    >=64KB) so the bench reveals whether input size correlates
    with verdict quality across modes.

Never enters cache_key — runtime measurements, not policy.
2026-05-01 10:34:25 -04:00
2d6a86b991
qa: --retrieval-keywords flag for explicit retrieval augmentation
Empirically observed 2026-05-01: long discursive questions like
'what technology is currently or soon available which may enable
one person to reconstruct another person's thoughts...' under-
retrieve because their content tokens get diluted by template
phrasing. AND-mode FTS5 returns zero hits; OR-fallback ranks
unrelated articles by raw BM25.

Fox's discovery: appending domain keywords ('transcranial knowledge
acquisition') to the question lifts the verdict from HYBRID 6/10
to STRICT 1/1 by narrowing OR-mode retrieval to the topical
article (Neurotechnology). The keywords act as an operator hint
about what the question is really asking.

This commit exposes that pattern as a first-class flag without
polluting the question text:

  --retrieval-keywords TEXT   on `aborist query`
  K="..."                     on `make query`

Plumbing: the keywords are concatenated with the question for
FTS5 search and title-filter token construction only. The LLM
still sees the original question; the verifier still checks against
the original question; cache_key still computes from the original
question. Keywords are session-only — successive calls with
different keywords on the same question can cache-hit each other.
Pair with BURN=1 for fresh inference per call.

Live verification:

  make query Q="...thoughts...without speaking or sign language."
       K="transcranial knowledge acquisition" BURN=1
  -> STRICT 2/2 via claim_lattice, 37.5s, Neurotechnology article
     cited for fMRI and DBS claims.

Without K: HYBRID 6/10 with Videoconferencing/Telepathy as top
sources — model hallucinated structure across irrelevant chunks.
2026-05-01 10:30:13 -04:00
8de00442f1
qa: self-reference thought chains — STRICT-as-fact substrate
Closes the recursive-grounding gap fox surfaced today: aborist
tends Wikipedia trees but never grafts its own past Q&A records
into the forest. Each query starts from cold corpus retrieval;
prior providence_cache records sit unread until the same question
is re-asked (cache_key match). The "kindergarten thought chains"
framing names the gap — the system has a kindergarten of thoughts
(early STRICT records) that should mature into citable substrate
as they cool.

This commit lands the MVP — STRICT live providence records past
the kindergarten window become substrate via a new Source
subclass. Trust model per fox: "we trust strict statements as
fact unless a verifier falsifies it."

NEW
---
- docs/self-reference-thought-chains-design.md — full architecture
  doc covering the four iteration-time gates, the recursive Merkle
  proof story (Q2 → Q1 → Wikipedia chunk per v9.8's recursive-
  cores insight), the falsification trust model (state=live as the
  fail-closed gate), risks (lazy-anchor compounding, echo chambers,
  storage bloat), and bench-impact estimate.
- aborist/sources/providence.py — ProvidenceSource(Source) with
  four hard gates:
    1. audit_mode == 'STRICT' (HYBRID/UNGROUNDED stay opaque)
    2. falsification_state == 'live' (failed/stale/quarantined
       excluded — verifier-falsification mechanism per fox)
    3. now - created_at >= kindergarten_seconds (default 1h —
       fresh thoughts cool first; kills tight echo loops)
    4. anti-recursion: records whose answer text contains a
       self-reference URI are skipped — first-generation only
- tests/test_providence_source.py — 10 unit tests covering each
  gate plus the URI-scheme source-role classifier
- Makefile target `ingest-self-providence` (KG_SECONDS=3600
  default; iterates each shard and self-promotes its STRICT live
  records — cross-shard sharing happens via the existing
  shards-dir UNION at retrieval time)

WIRE-UP
-------
- aborist/qa/query.py
  - SOURCE_ROLE_BUDGET_WEIGHTS: self_reference_source = 1.0
    (same as background — Wikipedia stays canonical primary;
    self-reference is supplementary anchoring)
  - SOURCE_ROLE_RANK_WEIGHTS: self_reference_source = 0.9
  - _classify_source_role: short-circuits on aborist://providence/
    URI prefix → self_reference_source regardless of title shape
  - DEFAULT_QUERY_POLICY['claim_lattice_allowed_source_roles']
    += 'self_reference_source'
- aborist/qa/runner.py — same allowlist update for the
  per-document `ask` path
- aborist/cli.py — `aborist ingest --source providence` reads the
  providence_cache from the same shard it writes into;
  --kindergarten-seconds flag plumbed through

NOT IN THIS COMMIT
------------------
- Aggregation of multiple Q&A records into synthesized summary
  records (follow-on)
- Self-reference for HYBRID records (only STRICT is substrate
  today; HYBRID could land later as a soft-anchor role with
  lower trust)
- Live virtual sourcing (the design discusses it; MVP uses
  snapshot ingestion so existing FTS / chunker / Merkle apply
  with zero schema change)
- A live bench validating actual lift on self-reference questions
  (requires running ingest-self-providence then bench; deferred
  to follow-on commit on real data)

10 new unit tests pass; full suite at 482 passed / 21 skipped
(live fixtures gated).
2026-05-01 10:16:47 -04:00
bc9438f900
cli: surface answer_mode=claim_lattice on ask/query (JSON variant)
Round-tripped end-to-end against Hermes-3-8B:
  query "what dinosaurs were in the first jurassic park film?"
  --answer-mode claim_lattice
  -> HYBRID 5/5 verified via claim_lattice in 4.9s

Hermes packed all five species into a single claim with five
evidence_ids, tripping TOO_MANY_EVIDENCE_IDS (max 2/claim). The
substrate's hard rule kicked in correctly — per-id checks all passed
but claim under-decomposition demoted STRICT to HYBRID. Reasoner-class
models (Qwen 3.6, Claude, GPT-4) should decompose into one claim per
species and clear STRICT.
2026-04-30 12:37:49 -04:00
c3da725a62
qa: wire claim_lattice JSON mode into runner; partial-grounding split
JSON mode (answer_mode="claim_lattice") is now reachable end-to-end via
runner.ask: message build, optional vLLM guided_json extra_body, verifier
dispatch, and providence_cache persistence. Verifier returns
verifier_method="claim_lattice" (same as pointer variant) so both modes
share the existing CHECK constraint; answer_mode on the run-DAG and
json_fixups on the verdict disambiguate.

Pointer verifier now splits EVIDENCE_LINKED_PARTIAL into its own
partially_verified_quotes bucket so a claim never renders as both a
verified bullet and an unverified footer. CLI shows a "partially
grounded" section between the verified body and the unverified list.
2026-04-30 12:06:22 -04:00
8fb5148e8e
qa: positive-form prompts + source-role rank boost + smell-line floor
Three fixes that meaningfully improve claim_lattice_pointer behavior
on real queries.

1. Prompts in positive form. Hermes-3-8B (and instruction-tuned 8Bs
   in general) follow positive directives ("do X") much more
   reliably than negations ("don't do Y"). Rewrote the
   claim_lattice_system_prompt and claim_lattice_grounding_reminder
   in DEFAULT_POLICY and DEFAULT_QUERY_POLICY so every rule says
   what TO do — "Reference evidence by pointer ID", "Use only
   pointer IDs that appear in the EVIDENCE blocks", "Cite 1 or 2
   pointers per claim", "Stop when the evidence runs out — a short
   answer is the right answer when only short evidence exists",
   etc. The cite-count constraint (1-2 per claim) directly
   addresses the spray-anchor failure where Hermes attached every
   available pointer to one claim line ("where is florida"
   produced 1 claim with 33 cites pre-fix).

2. Source-role rank boost. _rerank() now ends with a new
   _rerank_by_source_role stage that classifies each hit's role
   (mutating h.source_role for downstream reuse) and rescales the
   score by SOURCE_ROLE_RANK_WEIGHTS — primary 2.0, secondary 0.7,
   noisy/sequel 0.3. The Florida defect: list-pages
   (List_of_State_Roads_in_Florida, List_of_places_in_Florida:S/C/B,
   Florida_locations_by_per_capita_income) all match
   _SECONDARY_TITLE_MARKERS for "list of" and now sort below the
   actual Florida article instead of dominating the top-8 by body
   density. Affects ranking for both quote and pointer modes; the
   earlier per-source budget weights stay (still primary gets 2x
   the cap), they just don't have to fight a sort order that put
   list-pages first.

3. Lazy-anchor smell line gates on n_verified >= 3. With one
   verified pair the ratio is trivially 1.00 ("1 of 1 verified
   pairs cite [E11]") which is vacuous. Below 3 pairs the metric
   has no comparison surface; suppress the warning in those cases.

Live results on the 'where is florida' query went 33/33 with
cite-spray to 1/1 with a single targeted citation; spotlight
now hits "...largest metropolitan area in the state as well as
the entire southeastern United States is the South Florida..."
instead of state-road-number tables. The JP-dinosaurs benchmark
went from 11/11 with all cites at [E1] (ratio 1.00) to 13/14
with cites distributed across [E8], [E9], [E13] (ratio 0.77),
spotlight finding film-specific spans like "the film's
Dilophosaurus stands about 1.2 meters (4 ft) tall."

442 tests still passing. All 7 production shards report 0 chain
breaks.
2026-04-29 21:42:35 -04:00
37ac523bba
qa: lazy-anchor smell sidecar — surface when one pointer carries every claim
verify_claim_lattice now derives pointer_id_distribution and
lazy_anchor_ratio from the verified-or-partial claim_statuses and
returns them in the verdict. Surfaced through ask()/query() result
dicts; CLI human-render displays a one-liner when ratio >= 0.5
(threshold for "model is lazy-anchoring").

Sidecar discipline preserved: never persisted in providence_cache,
never threaded into build_run_dag's verify_payload (test pins this
invariant — sidecar values must not enter run_dag_root). The
distribution is recoverable from claim_statuses which IS persisted,
so future sidecar tools can recompute on demand without enlarging
the audit chain.

JP dinosaurs benchmark currently triggers the warning at 11/11
ratio 1.00 — Hermes-3-8B cites [E1] for every claim even with
G0.3's relevance ranking putting the dinosaur-mention chunk near
the top. The render-layer signal now makes that lazy-anchor
behavior explicit instead of leaving it for the auditor to spot
from the spotlight excerpts.

Tests: +4 covering the ratio across distributions (single-anchor /
diversified / no-verified-pairs) plus the run_dag isolation
invariant. Total 442 passing.
2026-04-29 21:28:28 -04:00
2337b7705c
qa: G0 claim-lattice-pointer answer mode — CTI quote-by-pointer
New policy["answer_mode"] = "claim_lattice_pointer" (default "quote").
The runtime builds an evidence map with two-layer ids — pointer-id
(E1, E2, ...) shown to the model, sha256-derived evidence-id used by
the cache & run-DAG — and the model emits pointer-line prose
("Claim. [E12]") referencing them. Renderer interpolates literal
source spans at display time. Synthetic-elision-by-construction-
impossible: the model never types the quote string.

Pieces:

- aborist/qa/evidence.py (new): EvidenceObject + spotlight excerpt
  (claim-token-centered window into the cited span, falls back to
  leading window when no token matches).

- aborist/qa/parse_claims.py (new): pointer-line parser walks
  lines, pulls [E\d+] / [E\d+,E\d+,...] tags, returns ParsedClaim
  with PARSED / NO_EVIDENCE_POINTER status. Strict regex refuses
  fuzzy alternatives so honest UNGROUNDED beats lax acceptance.

- aborist/qa/verify.py: verify_claim_lattice. Hard checks only —
  parser succeeded, evidence_id resolves, source_role allowed, no
  manual quotes (any " char violates), claim text non-empty.
  Returns evidence_id_pairs (content-addressed, run-stable) for the
  run-DAG. Soft signals (entailment, completeness, predicate
  compatibility) stay sidecar.

- aborist/qa/dag.py: 9-stage CTI shape when evidence_map_root is
  supplied — question / retrieval / evidence_map / prompt /
  raw_answer / parsed_claim_lattice / verify / render /
  final_label. Quote mode keeps the original 7-stage shape so
  pre-G0 run_dag_root values stay valid.

- aborist/qa/query.py: per-chunk evidence-map build with role-
  weighted budget AND query-relevance ordering. Within each source,
  chunks are ranked by (distinct_query_tokens_present,
  total_mentions, doc_order_asc) so the chunk most likely to
  textually support the question gets the lowest pointer id. Without
  this re-rank Hermes-3-8B lazy-anchored on doc-order-first chunks
  regardless of relevance.

- aborist/qa/runner.py: same answer_mode branch for the single-doc
  ask() path. No iterative repair in pointer mode (one-shot
  benchmark discipline).

- aborist/store.py: verifier_method CHECK extended with
  'claim_lattice'. New _rebuild_providence_cache_claim_lattice
  migration preserves run_dag_root / run_dag_blob across the
  rebuild — older rebuilds dropped them.

- aborist/cli.py: --answer-mode {quote,claim_lattice_pointer} on
  query and ask.

- Makefile: ANSWER_MODE knob; defaults to claim_lattice_pointer for
  `make query` so the testing harness exercises the new path.
  Library DEFAULT_POLICY / DEFAULT_QUERY_POLICY stay "quote" so
  Python callers and unit tests aren't surprised.

Prompt: one-shot worked example (Apple founders) plus strict
no-quotes / no-JSON / no-markdown / plain-prose-only rules. Without
the worked example Hermes-3-8B drops the bracket protocol on roughly
half of runs; with it, JP dinosaurs benchmark went 0/13 -> 10/11 ->
16/17 verified across the iterations that hardened the pipeline.

Tests: +48 covering parser, two-layer ids, verifier failure modes
(UNKNOWN_EVIDENCE_ID / SOURCE_ROLE_BLOCKED / MANUAL_QUOTE_VIOLATION
/ NO_EVIDENCE_POINTER / SCHEMA_INVALID), spotlight rendering with
buried-term fixture, per-chunk evidence map, query-relevance
ordering, schema migration round-trip, 9-stage DAG shape divergence.
Total 438 passed, all 7 production shards report 0 chain breaks.

Known limitation: chunk boundaries can cut wikitext mid-template, so
mwparserfromhell-backed to_base() leaves orphan </ref> tags and
leading list markers in the visible spotlight excerpts. Verifier and
CTI architecture are unaffected; the leak is cosmetic. Proper fixes
are template-aware chunking (chunker bump invalidates prior records)
or an orphan-marker post-strip in aborist/wikitext.py — both out of
G0 scope.
2026-04-29 21:19:19 -04:00
304dadfb71
fix burn-kindergarten 0-second flake; expose --repair on query (off by default)
burn-kindergarten flake fix:
test_burn_kindergarten_zero_seconds_burns_everything failed under
full-suite ordering when the wall-clock second rolled over between
seeding the row & running burn-kindergarten. Old impl computed
`cutoff = now - 0 = now` and required `created_at >= now`, so a row
seeded at second T became invisible to a burn check that ran at T+1.

The verb's docstring is "0-second window = burn every live row".
Fix the impl to match: when kindergarten_seconds <= 0, drop the time
gate entirely (burn all live records). Non-zero windows keep the
existing cutoff-based filter unchanged.

Repair CLI:
`aborist query --repair` enables the mechanical repair pass after
first verify. `--repair-reprompts N` adds the optional re-prompt
tier (default 0 = off; mechanical-only when --repair is set without
--repair-reprompts). Both flags are off by default so `make query`
stays single-shot unless the operator opts in.

Makefile: `make query REPAIR=1` flips on mechanical repair;
`make query REPAIR=1 REPROMPTS=1` adds one re-prompt iteration.
Both knobs off by default to preserve existing behavior.

485 tests pass under full-suite ordering (was 484 + 1 flake).
2026-04-29 19:57:24 -04:00
3cdebb7e2a
qa: per-claim status taxonomy on verifier + repair-action plans on sidecar
Two paired enhancements (D + E from the toy-Hermes design pass):

D. Per-claim status taxonomy on `verify_quotes`.

   New `claim_statuses` field on every verdict — a per-evidence-unit
   list with three labels:

       VERIFIED_QUOTE        unit substring-matched in normalized context
                             (any of quote/span/entity strategies)
       SUPPORTED_PARAPHRASE  unit cleared the paraphrase token-coverage
                             threshold (≥85% topical tokens present)
       UNSUPPORTED           unit didn't match anything

   Diagnostic labels (QUOTE_INTEGRITY_FAILED, SOURCE_MISMATCH,
   FALSIFIED) stay in the sidecar / falsification machinery — the
   binary-verifier discipline holds. Empty list when no evidence
   was extracted at all (verifier_method='none'). Backward-compatible:
   existing fields (audit_mode, n_quotes, n_verified, unverified_quotes,
   verifier_method) unchanged; current callers ignore the new field.

E. Repair-action plans on sidecar diagnoses.

   Each `_classify_span` diagnosis now carries a `repair` field with
   a concrete suggestion the operator can act on:

       synthetic_elision_inside_quote → split_into_two_quotes
                                        (when both halves verbatim)
                                      → trim_to_verified_half
                                        (when only one half verbatim)
                                      → remove_claim
       interior_elision               → include_aside_for_verbatim
                                        (with the dropped aside text)
       trailing_artifact              → trim_trailing_artifact
                                        (with the kept_prefix string)
       paraphrase                     → downgrade_to_paraphrase
       partial_paraphrase             → split_or_remove
       no_overlap                     → remove_claim

   Read-only suggestions — sidecar still doesn't write to providence_cache
   or audit_events. The repair stage is recommendation, not mutation.
   Operator (or an automated repair pass) decides whether to act.
   Human render in `aborist inspect` shows `repair: <action>  (<reason>)`
   under each diagnosis line.

Tests:
- verify: claim_statuses_quote_path_labels_each_unit (per-quote VERIFIED
  / UNSUPPORTED), claim_statuses_paraphrase_method_flagged,
  claim_statuses_empty_when_no_evidence.
- inspect: repair_synthetic_elision_split_when_both_halves_verbatim,
  repair_interior_elision_includes_aside, repair_trailing_artifact_trim,
  repair_no_overlap_remove.

462 tests pass (verify +3, inspect +4).
2026-04-29 18:37:52 -04:00
d0a3d93836
qa: synthetic_elision sidecar diagnosis + role-weighted source budget
Two enhancements from the toy-Hermes design pass (2026-04-30):

A. Synthetic-elision-inside-quote diagnosis (sidecar only).

   Distinct from interior_elision (model dropped a `(...)` aside source
   carries) — synthetic_elision is the model writing literal `[...]`
   between fragments of a `"..."` span, signaling self-elision while
   claiming verbatim citation. The verifier still rejects (binary
   discipline holds), but `aborist inspect` now reports
   `diagnosis: synthetic_elision_inside_quote` with prefix/suffix
   presence flags so an operator can judge whether the elided middle
   was benign. Probe runs first in the classify-span chain (more
   specific than trailing_artifact / interior_elision / paraphrase).

   Catches the Brachiosaurus case: `"The film centers on the fictional
   Isla Nublar [...] Universal Studios..."` — both halves are in source,
   but the literal `[...]` isn't, so substring match correctly fails &
   the sidecar tells the operator why.

C. Source-role classification + role-weighted context budget.

   `_classify_source_role(title, qtokens_stem)` tags each top-K hit:
       primary_answer_source     2.0× cap   strong title-stem overlap
       secondary_context_source  1.0× cap   "list of", "characters",
                                            "franchise", "history of"
       noisy_background_source   0.5× cap   "score", "music",
                                            "video game", "merchandise"
       sequel_background_source  0.5× cap   "lost world", roman numerals
       background_source         1.0× cap   default

   Order matters: noisy/sequel/secondary markers fire before the
   primary check so peripheral pages with strong title overlap (e.g.
   `Jurassic Park (film score)` shares 3 stems with the JP-film query)
   don't claim a primary slot.

   Cap loop now applies role weight on top of the baseline
   `max_context_chars / top_k`. Total context still bounded by the
   running `char_budget` — weights just shift how the budget gets
   divided so primary pages get more text & noisy pages less, fixing
   the case where a `(film score)` page consumed a primary slot.

   `source_role` is persisted on `_Hit` and surfaces on
   `merkle_proof.sources[*].source_role` in the providence record so
   inspect & audits can see which slot each source occupied.

Tests:
- inspect: synthetic_elision_caught (Brachiosaurus regression),
  synthetic_elision_does_not_fire_when_source_has_brackets (false-
  positive guard).
- query: role classifier matrix (primary / secondary / noisy / sequel /
  background) on JP-film-style titles, role persistence in sources list.

455 tests pass (sidecar +2, query +2).
2026-04-29 18:32:28 -04:00
e668a8355f
qa: 'make query Q=... BURN=1' busts matching cache before lookup
Per fox: while iterating on retrieval/verifier knobs, a flag on the
query itself is more useful than a separate verb. One step reset:
change a knob, re-query, see fresh result.

Surface:
  aborist query --burn ...
  make query Q="..." BURN=1
  make query-dry Q="..." BURN=1   (works on dry-run too)

Behavior in `aborist.qa.query.query()`:
- New `burn_existing: bool = False` parameter on query().
- After computing primary cache_key (per the active dedup mode) but
  BEFORE the cache lookup, if burn_existing: DELETE the live row that
  matches the primary cache_key & write one providence_burn audit
  event with reason "query --burn (test-ergonomic mid-query bust)".
- The lookup then misses → fresh inference runs. Result reports
  `burned_existing: 0|1` so the caller sees whether anything got
  busted.
- The equivalence-class fallback cache_key is deliberately NOT
  touched: prior alt-mode records stay as historic witnesses.

Tests (3): cache populates → cache hits → BURN=1 forces fresh
inference; --burn writes a providence_burn audit event; first-time
query with --burn is a clean no-op (burned_existing=0). 356 passed,
1 skipped.

Complementary to `aborist burn-kindergarten` (mass reset) — this is
the surgical version. Both are local-only by design; for cross-peer
invalidation use `make falsify` and let mesh sync broadcast.
2026-04-29 17:02:13 -04:00
4573dbcf4e
cli: 'aborist burn-kindergarten' — mass-burn fresh providence rows
Per fox: useful while iterating on retrieval/verifier knobs to wipe
recent test runs without finding each cache_key. Mirrors the mesh-sync
kindergarten window so what's still un-broadcast (private to this peer)
is exactly what's safe to bust without confusing peers.

Surface:
  aborist burn-kindergarten [--kindergarten-seconds N]
                            [--reason '...'] [--by-actor X]
                            [--force] [--dry-run] [--verbose N]
  make burn-kindergarten [SECONDS=3600] [FORCE=1] [DRY_RUN=1] [REASON='why']

Behavior:
- Walks every shard, finds providence_cache rows with
  created_at >= now - SECONDS and falsification_state='live'.
- Each row goes through the existing _burn_cache_key — children gate
  honored unless --force, audit event written per successful burn,
  chain integrity preserved.
- Result JSON reports examined / burned / refused_has_children /
  not_found counts plus a verbose tail of per-row results.
- --dry-run reports without writing or auditing.
- --kindergarten-seconds 0 = burn every live row (test reset).

Tests (4): selective burn (old kept, fresh burned), dry-run writes
nothing, 0-second window burns everything, children-gate refusal
without --force. 353 passed, 1 skipped.

Operational note: this command does NOT propagate to peers (burn is
local kindergarten cleanup by design, see docs/mesh.md). If you want
the remote effect, falsify each record individually & let mesh sync
broadcast the falsifications instead.
2026-04-29 16:56:59 -04:00
aefc032749
qa: dedup-mode policy + JIT fidelity — agents pick fast-cache vs audit-grade
Different agents have different value functions on the same providence
records. Today aborist bakes one canonicalization policy into
governance_policy_hash and calls it universal. This adds two knobs that
let agents express their preference without breaking provenance.

- aborist/qa/keys.py: `canonical_question(q, mode=...)` and
  `question_hash(q, mode=...)` accept "strict" (NFC + ws-collapse only;
  every variant gets its own hash) or "equivalence_class" (default —
  additionally lowercase + trailing-punct strip + article strip).
  New constants: QUESTION_DEDUP_MODES, FIDELITY_MODES, defaults.

- aborist/qa/query.py + aborist/qa/runner.py: each call computes the
  primary cache_key under policy["question_dedup"]. New `fidelity`
  parameter:
    "strict"            only primary cache_key checked
    "equivalence_class" primary first; if miss AND alternate mode
                        produces a different cache_key, try alternate
  Cross-silo fallback works because _ckey_for_mode rewrites
  policy["question_dedup"] to the alternate mode before computing
  governance_policy_hash — so the fallback ckey matches what an agent
  under that mode would have written. Lookups can find each other's
  records when fidelity permits. Result dict gains `lookup_path` ∈
  {"strict", "equivalence_class", "strict_fallback",
  "equivalence_class_fallback", "miss"}.

- aborist/cli.py: `--question-dedup` and `--fidelity` flags on the
  `query` subcommand. Human render annotates cache_hit lines that
  came from a fallback ckey ("cached via equivalence_class_fallback").

- tests/test_query.py: three new regressions
    * strict-policy + strict-fidelity: variants get distinct cache_keys
    * cross-silo fallback: strict-policy reads eq-class-policy records
      via fidelity=equivalence_class
    * strict-fidelity refuses fallback (audit-grade): cache miss even
      when the alternate silo has a hit

- CLAUDE.md: dedup-mode and fidelity convention bullets updated.

This is V1 + V2 of the substrate move sketched in conversation: write
policy determines which silo a record lives in; read fidelity determines
how loosely an agent walks across silos. Records stay exactly-keyed
(provenance hard); routing is per-agent (preference soft). Same
verifier_no_diagnostics discipline holds — no soft signal enters the
hard chain.

442 tests pass.
2026-04-29 16:36:39 -04:00
b48646bc0c
mesh: sync default-holds records younger than 1 hour (kindergarten window)
Fox 2026-04-29: "part of the gossip protocol should be some default
delay that allows a user to catch and burn kindergarteners before
they are synced." Currently `mesh sync` enumerates the most-recent
N items with no age filter — a doc ingested 30 seconds ago goes
out on the next sync, & if a peer ingests it before the operator
notices a problem, burn no longer suffices (peer has its own copy).

Adds a sender-side kindergarten window:

  --kindergarten-seconds N   default 3600 (1 hour)

Records younger than `now - N` are held back from broadcast. Both
ANNOUNCE_ROOT (filtered on documents.ingest_ts) & ANNOUNCE_FALSIFICATION
(filtered on falsifications.at). N=0 = broadcast everything (cron-
friendly opt-out for operators preferring immediate propagation).

Result JSON now reports the held counts so the operator can see
what the window protected:

  kindergarten_seconds: 3600
  announced_roots: 12
  kindergarten_held_roots: 3
  announced_falsifications: 0
  kindergarten_held_falsifications: 1

Sender-side discipline only — the receiver has no view into when
the sender created the record, so it can't enforce. Adding a
created_at on the envelope would let receivers reject too-fresh
gossip, but that's a future protocol bump (envelopes today don't
carry sender wall-clock; ts is the send time).

Tests:
  - existing tests now pass `--kindergarten-seconds 0` so freshly-
    ingested fixtures broadcast immediately for the test
  - new test_sync_kindergarten_holds_fresh_records: a 30-second-old
    doc is held; an artificially-aged doc broadcasts. announced=1,
    held>=1.
  - new test_sync_kindergarten_zero_broadcasts_everything: explicit
    opt-out works.

342 passed, 1 skipped.
2026-04-29 16:07:24 -04:00
01340013dd
mesh: sync now also broadcasts ANNOUNCE_FALSIFICATION by default
Per fox 2026-04-29: the wire protocol has had ANNOUNCE_FALSIFICATION
since the foundation commit (and MeshWireClient.announce_falsification
since AEAD landed), but the user-facing `mesh sync` only ever fired
ANNOUNCE_ROOT. Falsifications local to one peer never reached others
unless an operator hand-rolled a Python script.

Now `mesh sync` enumerates BOTH categories:
  - ANNOUNCE_ROOT       most-recent --limit documents (existing path)
  - ANNOUNCE_FALSIFICATION  most-recent --limit falsifications (new)

Receivers verify Ed25519 sig + per-peer chain-of-claims as before,
write one mesh_received audit event per accepted envelope. Result
JSON now reports both counts:

  announced_roots: N      (was: announced)
  announced_falsifications: N
  sent_roots: [...]       (was: sent)
  sent_falsifications: [...]

Two opt-out flags so operators can scope the broadcast:
  --no-roots               only push falsifications
  --no-falsifications      only push roots

Burns are deliberately NOT propagated. Burn semantics are local
kindergarten cleanup ("delete a leaf I shouldn't have written") —
other peers may have legitimately ingested the doc independently.
Falsify is the audit-preserving alternative whose broadcast IS the
right cross-peer signal for "this answer is wrong."

Caveat (deferred): no per-peer dedup state yet. Re-running sync
re-broadcasts the same most-recent N falsifications; receivers get
duplicate mesh_received audit-log entries (no state corruption,
just log noise). A `mesh_sync_state` table tracking
last_falsify_announced_ts per peer URL is the natural follow-up
when the falsification volume grows.

Tests:
  - existing test_sync_announces_local_roots updated for new field
    names (announced_roots, announced_falsifications)
  - new test_sync_announces_falsifications: bob falsifies, syncs,
    alice's chain has the ANNOUNCE_FALSIFICATION envelope
  - new test_sync_no_falsifications_flag_skips_them: --no-falsifications
    skips the broadcast cleanly

339 passed, 1 skipped.
2026-04-29 15:40:53 -04:00
d3168fa236
qa: 'aborist inspect' — sidecar diagnose for unverified spans
Per fox: the verifier reports n/N verified but doesn't tell you WHY a
span didn't ground. Some are paraphrase, some are model-added
trailing artifacts (e.g. `(Source: https://...)` citations the model
appended to verbatim prose), some are full inventions. Operator
needs to see which is which to triage.

Read-only sidecar — no audit events, no providence_cache mutations,
no v9.8 field changes. Per the verifier-no-diagnostics memory rule:
hard chain stays binary; soft signals live in sidecar verbs that
never feed back.

Five diagnosis labels:

  verbatim_in_base       span IS in base context — verifier or
                         canonicalization bug (worth flagging loudly).
  verbatim_in_raw_only   raw wikitext match but base form differs —
                         wikitext-strip edge case.
  trailing_artifact      a >=60-char prefix matches; the tail (often a
                         model-added citation) doesn't. Surfaces the
                         tail explicitly.
  paraphrase             >=85% query tokens (>4 chars) present in
                         base context but not in this sequence.
                         Model rewrote source content.
  partial_paraphrase     40-85% coverage — mixed sourced/emergent.
  no_overlap             <40% coverage — likely full invention.

Verified live against the real Pikachu cache record (5 sources,
71KB raw -> 34KB base): two spans flagged paraphrase (token_coverage
1.0 for both, just rewritten sequence), one flagged trailing_artifact
(100-char prefix matches; tail is `(Source: https://...)` citation).
That's the actual reason HYBRID — model paraphrased + appended
citations that aren't in the corpus.

Surface:
  aborist inspect --cache-key <hex> [--qa-db ...] [--json]
  make inspect KEY=<hex> [JSON=1]

Tests: 9 (6 classifier on synthetic contexts pinning each label,
3 end-to-end with seeded fixture record + verification that inspect
writes nothing). 318 passed, 1 skipped overall.
2026-04-29 10:29:06 -04:00
31701ad524
cli: human render for query by default; --json for raw; ensure_ascii=False
Two things fox surfaced from a "who is pikachu?" run:

1. Output emitted "Pok\\u00e9mon" instead of "Pokémon" — json.dumps
   defaulted to ensure_ascii=True. Switched to ensure_ascii=False on
   every user-facing dump in cli.py (41 sites). The one canonical-JSON
   call (`separators=(",", ":")` for storing in providence_cache as
   a JSON column, not for hashing) was deliberately left alone.

2. The actual answer was buried under cache_key / context_root / per-
   source metadata / timings JSON. Default now renders human-readable:

       who is pikachu?
         HYBRID  1/2 verified  via quote  9.2s  (fresh)

       Pikachu is a species of Pokémon creatures...

       sources (3):
         [1] Pikachu — en.wikipedia.org/wiki/Pikachu  (002.db)
         [2] List_of_Pokémon — en.wikipedia.org/wiki/List_of_Pokémon
         ...

       unverified (1):
         - "spans the model couldn't ground..."

       cache_key: 35ab7d33…   <run with --json for full record>

   Pass `--json` to get the prior raw record (still ensure_ascii=False
   so unicode renders cleanly there too — scripts parsing the output
   see real chars; the JSON spec accepts either form).

Implementation:

- `_render_query_human(result, question)` — pure function, easy to
  unit-test, no I/O. Truncates unverified spans over 100 chars,
  omits empty sections, distinguishes cached vs fresh in the summary.
- `_strip_scheme` / `_short_path` — small helpers for source display.
- `--json` flag on the `query` subparser; default is human render.

Tests: 12 new in tests/test_cli_render.py — question position, summary
fields (audit/n_verified/method/elapsed/cache-status), unicode
literals (Pokémon not \\u00e9), source line shape, long-quote
truncation, empty-section omission, error fallback, short cache_key
with --json hint. 309 passed, 1 skipped overall.
2026-04-29 10:14:36 -04:00
dfe39a0bf4
crawler: --fast flag exposes the verbatim AsyncWebFetcher fast_mode
The lifted AsyncWebFetcher already had a fast_mode constructor knob;
just unbroken plumbing was missing. fast_mode trades politeness for
throughput:

  - timeouts: 5s vs 15-60s
  - parallel page workers: cpu_count() * 3 vs 1
  - ignores robots.txt 'crawl-delay' directive
  - robots.txt 'Disallow' is STILL honored (only the delay drops)

Surface:

  aborist crawl --seed-url ... --fast
  make crawl-ingest URL=... FAST=1

Tests: two regressions pin that fast=True / fast=False each translate
to the right AsyncWebFetcher(fast_mode=...) construction. Capturing
fetcher fixture to avoid network. 21 bridge tests + 273 default
suite, all passing.
2026-04-28 21:13:58 -04:00
d2ceba201e
crawler: live stderr heartbeat during BFS + ingest (rate-limited)
A no-cap crawl could take many seconds with no feedback. Wires the
existing aborist.progress.Progress into both phases so stderr shows
heartbeats every 2s (Progress's default interval).

Discovery phase: prints prefix='crawl', counts discovered URLs,
shows queue depth as the secondary number ('inserted' slot in the
Progress format — works fine, semantically "still to do").

Ingest phase: prefix='ingest', total_estimate=len(urls) so the user
sees percent + ETA. Threads through ingest_source's existing
progress= parameter.

Plus three banner lines to stderr at phase boundaries (start crawl,
end discovery, start ingest) so even sub-2s crawls show signs of
life. All flushed via Progress's flush=True path; stderr is
line-buffered by default so this works without PYTHONUNBUFFERED.

Tests stay green (Progress goes to stderr, pytest captures only the
stdout summary). 19 bridge tests + 273 default suite, all passing.
2026-04-28 21:05:45 -04:00
552dc0def2
crawler: --max-pages defaults to 0 (no cap); depth is the only bound
Per fox: 20 was a wrong default — typical sites have many more pages
within depth 2 than that, and the cap was silently truncating real
crawls. New behavior:

  aborist crawl --depth 2                  # no page cap
  aborist crawl --depth 2 --max-pages 50   # opt-in cap when needed
  make crawl-ingest URL=... DEPTH=2        # no page cap
  make crawl-ingest URL=... DEPTH=2 MAX=50 # explicit cap

Implementation: bridge's BFS loop treats max_pages=0 as "unbounded"
(while-condition becomes `queue and (max_pages == 0 or len(...) < max_pages)`).
Default flows from CLI argparse default=0 down to the bridge.

Tests: pinned max_pages=0 → no cap with a 30-page chain regression
test (19 passed in tests/crawler/test_bridge.py). Default suite still
273 passed, 1 skipped.
2026-04-28 20:59:22 -04:00
5f060ff326
crawler: bridge to ingest + fast recrawl-check via conditional HEAD
Glues the verbatim crawler lift to aborist's existing ingest pipeline,
plus adds a freshness probe that asks "does this need recrawling?"
without paying for a body fetch when the answer is no.

Bridge (aborist/sources/crawler/bridge.py):
- crawl_seed(seed, depth, max_pages): BFS-discovers same-domain URLs
  via AsyncWebFetcher. Exact-hostname filter so subdomain crossover is
  off by default. Caps at max_pages. URL dedup, fragment-strip
  canonicalization. Sync wrapper around an async core so the CLI
  remains a one-liner.
- ingest_crawled(conn, urls): runs the URL list through the standard
  ingest_source() path so chunking_version / canonicalization_version
  / Merkle commitment all stay consistent. Captures ETag +
  Last-Modified per response and writes one row per document_root
  into the new document_http_meta table.

Schema (idempotent migration, mirrors _migrate_mesh_peer_chains):
  document_http_meta (
    document_root    TEXT PRIMARY KEY,
    etag             TEXT,
    last_modified    TEXT,
    last_fetched_at  INTEGER NOT NULL,
    last_status      INTEGER,
    last_checked_at  INTEGER,
    FK(document_root) -> documents(document_root) ON DELETE CASCADE
  )

Recrawl-check (recrawl_check):
- Sends conditional HEAD with If-None-Match + If-Modified-Since.
  304 -> fresh (no body transfer needed). 200 -> stale (server says
  the body changed). 404/410 -> gone. Other -> unreachable.
- Updates last_status + last_checked_at so consecutive runs visit
  oldest-first (NULLS FIRST in ORDER BY).
- Optional --domain filter so operators can probe one site at a time.

CLI:
  aborist crawl --seed-url URL [--depth 2] [--max-pages 20] [--ingest]
  aborist crawler recrawl-check [--domain DOMAIN] [--limit 100]

Makefile:
  crawl-ingest URL=https://example.com [DEPTH=2 MAX=20]
  recrawl-check [DOMAIN=example.com LIMIT=100]

Both targets gate on bootstrap-crawler so [crawler] extras land first.

Tests: 17 new in tests/crawler/test_bridge.py — same-domain filter,
BFS bounds (depth + max_pages), dedup, fragment normalization, ETag
capture, content-type filter, HEAD classification (304/200/404),
domain filter, last_checked_at update. All stub the network via a
fake httpx.Client; default suite still 273 passed, 1 skipped.

End-to-end test path:
  make bootstrap-crawler
  make crawl-ingest URL=https://russell.ballestrini.net DEPTH=2 MAX=20
  make query Q="who is russell ballestrini?"
  make recrawl-check DOMAIN=russell.ballestrini.net
2026-04-28 20:34:54 -04:00
dcd2783507
merge: burn docs/cores (worktree #4) 2026-04-28 18:02:17 -04:00
bc67ad80e8
mesh: 'pull' CLI verb — fetch document body from a peer
Closes the request half of the gossip loop. MeshWireClient already
verifies signature + Merkle root on delivered bodies; this verb adds
the CLI surface, gating, local re-ingest through the standard chunker,
and a 'mesh_pulled' audit event. Re-ingest is rejected if the local
document_root differs from the requested root.
2026-04-28 17:33:27 -04:00
58d5656352
cli: extend 'aborist burn' to documents and cores
burn --kind {providence,document,core}; default 'providence' preserves
the prior --cache-key surface (no breaking change).

Children gates for document/core leaves:
  derivations.src_root        downstream cores derived from this doc
  edges.dst_root              other docs link to this doc
  providence_cache.source_root Q&A grounded in this doc

derivations has ON DELETE CASCADE on BOTH core_root and src_root, so a
naive DELETE FROM documents would silently cascade-prune downstream
derivations and orphan deeper cores. Gate before delete; --force to
override with the count recorded in the audit body.

Burn manually clears chunks_fts (FTS5 has no FK) and outbound edges
(no FK either) before deleting the document. chunks + merkle_nodes +
inbound derivations cascade via FK.

Audit event types are document_burn / core_burn — distinct from
providence_burn so chain consumers can tell leaf kinds apart.

CLAUDE.md "Cores never evict" remains intact: burn is operator-driven
removal, evict is the automated cold-tier compression that touches
only kind='surface'.

Makefile: KIND=providence|document|core selects the leaf type;
KEY for providence, ROOT for document/core. Single 'burn' target keeps
the surface DRY.

Tests (tests/test_burn_doc.py, 13 new):
- happy path: chunks + merkle_nodes vanish via cascade
- refused on derivation downstream / providence ref / incoming edge
- --force overrides and records counts
- core burn happy path (no children) preserves parent surface
- core burn refused with downstream derivation
- audit chain integrity preserved (no dangling prev_event_hash)
- CLI invocations for document, core, default-providence, refused

Full suite: 247 passed, 1 skipped.
2026-04-28 17:32:40 -04:00
5427a61768
mesh: 'serve' and 'sync' CLI verbs over the wire layer
Wires the gossip-wire core (commit f141bab) into the user-facing CLI:

  aborist mesh serve --host 127.0.0.1 --port 8400
    Spawns MeshWireServer, blocks until SIGINT. Refuses if mesh isn't
    initialized or .enabled is off — same gate every other mesh verb
    enforces. Stdout is unbuffered JSON status lines.

  aborist mesh sync --peer http://other.example:8400 [--limit N]
    Enumerates local documents (most-recent N, default 100), fires one
    ANNOUNCE_ROOT per document via MeshWireClient, reports counts of
    acknowledged vs. errored. Pulling missing roots back from the peer
    is a v2 addition — this verb pushes only.

Tests (6): gating refusals on uninitialized + disabled mesh; sync
round-trip with two peers proving Alice's audit chain gains one
'mesh_received' per Bob announce; unreachable-peer reporting; serve
smoke-test via start_in_thread + GET /mesh/info.
2026-04-28 17:05:31 -04:00
4a994b7ead
cli: 'aborist burn' — kindergarten leaf delete with audit event
Burn deletes a providence_cache row outright; falsify (the existing
verb) flips state and keeps the row. Burn is for early/scratch use
during a tree's genesis when downstream consumers haven't ingested
the answer yet.

Children gate: refuses if any falsifications reference the cache_key.
Caller can override with --force; the audit event records forced=True
plus the child count at burn time so the chain can reconstruct what
was orphaned. Refusal is non-destructive AND skips the audit event so
scripts can retry without polluting history.

Always writes a 'providence_burn' audit event regardless of force.
Body fields: cache_key, burned_audit_mode, burned_n_verified,
burned_state, question_text, reason, by_actor, child_falsifications_at_burn,
forced. The chain-break query (`make chain-check`) shows 0 after burn —
the new event extends the prior chain cleanly.

Surface:
  aborist burn --cache-key <hex> [--reason '...'] [--force]
  make burn KEY=<hex> REASON='...' [FORCE=1]

Tests (7): happy-path delete + audit event, refusal on children,
--force override, unknown-key not-found, chain-integrity preserved,
CLI happy path, CLI non-zero exit on refusal.
2026-04-28 17:03:30 -04:00
01a4390693
verify: layered strategies + entity policies, rename VISUAL → UNGROUNDED
- aborist/qa/verify.py: three strategies tried in sequence. quote uses
  sequential pairing (1st & 2nd `"`, 3rd & 4th, ...) which eliminates
  the phantom inter-pair captures regex pairing produced on adjacent
  quote pairs like `"title" prose "quote"`. span checks bullet/sentence
  lines verbatim. entity matches multi-word proper nouns; gated by
  policy ∈ {strict, hybrid, drop, proximity}. Default proximity
  promotes to STRICT only when N=3 verified entities cluster within
  W=300 chars in source — separates structural grounding (cast list,
  infobox) from incidental mention (scattered plot summary).

- aborist/store.py: schema CHECK now `('STRICT','HYBRID','UNGROUNDED')`.
  Migration helper rebuilds legacy `('STRICT','HYBRID','VISUAL')`
  tables via temp-table copy, translating VISUAL → UNGROUNDED in the
  SELECT. Idempotent — DDL inspection skips the rebuild on already-
  migrated DBs.

- aborist/cli.py: new `aborist reclassify` re-runs the verifier
  against existing live records under the current entity policy; no
  LLM calls. --compare runs all four policies side-by-side, --dry-run
  reports transitions without writing. Cold-source records skipped.
  One providence_reclassify audit event per changed row.

- AuditMode.UNGROUNDED replaces VISUAL across search backend, FTS5,
  test fixtures, CLAUDE.md. The substrate (Merkle-AGI) name was
  about FOR-style visualization; in the RAG layer the semantic is
  "no recoverable grounding," so the label now says that.

DEFAULT_QUERY_POLICY gains entity_policy + entity_proximity_n +
entity_proximity_window so any tuning folds into governance_policy_hash
and invalidates cache cleanly.
2026-04-28 16:58:31 -04:00
1ef0ac753a
qa: post-LLM faithfulness verifier sets STRICT/HYBRID/VISUAL audit_mode
Adds aborist/qa/verify.py — three-strategy verifier (explicit quotes,
bullet/sentence spans, multi-word proper nouns) that lexically checks
every claim against retrieved context under norm-v1 + lowercase. The
strategy that fires is recorded as verifier_method for diagnostics.
Entity strategy gates classification via an entity_policy
(strict/hybrid/proximity/drop) so a single proper-noun match no longer
overclaims STRICT — proximity (default) requires a cluster of 3+
verified entities within 300 chars.

Wires into ask() and query(). System prompts now require verbatim
quoted spans for every factual claim, restated via a user-turn
grounding_reminder one message before sources arrive (recent
user-turn instructions outweigh decayed system-turn rules under long
context in Hermes).

providence_cache gains 5 columns (audit_mode, n_quotes, n_verified,
unverified_quotes, verifier_method) with CHECK constraints. A
connect-time _migrate_audit_mode() ALTERs legacy DBs idempotently.
Cache hits return the persisted audit_mode rather than asserting
STRICT unconditionally.

CLI:
  emergent     list VISUAL/HYBRID records; --aggregate ranks
               unverified quotes by frequency (corpus-growth signal).
  reclassify   re-run the verifier against live providence records;
               cold-source records skipped; --dry-run reports
               transitions without writing; each change writes one
               'providence_reclassify' audit event.
2026-04-28 15:45:08 -04:00
1f5bf7318d
add corpus-level snapshots: single-hash identity for the forest
A snapshot_root is MerkleTree.build([sorted DISTINCT document_roots]).root
— one 32-byte hash naming the entire content-addressed forest at a
point in time. Two peers that ingested the same dump compute bit-
identical snapshot_roots, so cross-machine "are we synced?" becomes
an O(1) hash comparison; the same root doubles as the TF-IDF
scope_root we sketched in the mesh design (snapshot_root *is* a
scope), and pins Q&A answers to a verifiable corpus state.

Schema: one new table, additive over existing data.
  snapshots(snapshot_root PK, taken_at, audit_event_hash, doc_count,
            parent_snapshot, reason)
  + idx_snapshots_taken_at

API:
  compute_snapshot_root(conn, *, document_roots=None) -> (root, count)
  create_snapshot(conn, *, reason, parent_snapshot=None) -> dict
  verify_snapshot(conn, snapshot_root) -> dict (matches: bool)
  diff_against_current(conn, snapshot_root) -> dict (added/removed/unchanged)
  list_snapshots(conn, *, limit) -> list[dict]

CLI:
  aborist snapshot create [--reason "..."] [--parent <hex>]
  aborist snapshot list   [--limit N]
  aborist snapshot verify <snapshot_root>
  aborist snapshot diff   <snapshot_root>

Cross-shard: with --shards-dir + --db, the snapshot is computed over
the cluster-wide UNION view but persisted into args.db (a dedicated
snapshots store, conventionally ~/.aborist/shards/snapshots.db).
parent_snapshot auto-links to the latest prior snapshot in the
writer DB, giving a chain for free.

Each snapshot creation writes an audit_event of type 'snapshot_create'
with subject_root=snapshot_root, so the corpus's pinned states are
themselves tamper-evidently logged.

Defect caught + fixed during live test on the 2010 enwiki ingest:
verify and diff disagreed on the same connection because compute used
a list (with cross-shard duplicate document_roots) while diff used a
set. Two shards can land identical document_roots when canonicalize()
maps two structurally-similar pages to the same byte stream — rare
but real. Switched the underlying SQL to SELECT DISTINCT so a
membership snapshot is always order- AND multiplicity-independent.

Live test: 2010-11 enwiki corpus snapshotted at
  43797e46605de08dbab06cdcaf5be7ad78243b193c56f8580200dee6bcc7e1b9
  doc_count: 3,468,134 (after dedup)
verify + diff round-trip both report identical against current state.

11 new tests covering empty corpus, single-doc degenerate, order
independence, drift detection, audit-chain pinning, parent auto-link,
and idempotent creation on unchanged corpus. 136 tests + 1 skipped.
2026-04-27 21:29:10 -04:00
aa8caeeece
mesh: cryptographic foundation, off by default
Phase 1 of the federation/gossip layer fox sketched as the natural
extension of v9.8 admissibility's content-addressed identity. Two
peers ingesting the same dump already compute identical document_roots
and identical 8-dim cache_keys; the mesh layer is the wire-and-trust
plumbing that lets them dedup answers, exchange Merkle proofs, and
cleanly distrust an evicted member without a hard fork.

Cryptography (cryptography lib, audited):
  Ed25519       — every membership mutation + (future) gossip envelope
                  is signed by the actor's pubkey.
  X25519 ECDH   — wraps each epoch's symmetric mesh secret to every
                  current member's DH pubkey via HKDF-derived AEAD key.
  ChaCha20-P1305— AEAD for envelope payloads + per-member secret wrap.

State machine:
  mesh_identity   — singleton; this peer's keys + group name
  mesh_roster     — per-epoch (member_id, sign_pub, dh_pub, role)
  mesh_epochs     — epoch_id -> {started_at, started_event_hash,
                                  secret_envelope JSON, reason}
  meta:mesh.enabled flag — off by default; gates everything

Eviction works by rotating to a new epoch whose envelope omits the
kicked member. Their prior signatures stay verifiable (the older
roster row is retained), but any gossip from epoch+1 onward is
opaque to them — the secret was never shared with their pubkey.

Authority gate: only roster members with role='admin' can add or
kick. Self-kick is rejected explicitly. The last admin can't be
kicked. Schedule-rotate (refresh secret, no roster change) is open
to any current member as a session-hygiene op.

Audit-chain integration: every mesh state mutation writes an audit
event (mesh_init, mesh_enable/disable, mesh_epoch_rotate). The
epoch's started_event_hash backfills into mesh_epochs after the
audit row commits, giving each epoch a tamper-evident pin into the
ledger.

CLI subcommands: mesh init, mesh status, mesh enable, mesh disable,
mesh members, mesh add, mesh kick, mesh rotate. All read-only or
local-state-only — no network code paths in this commit.

The HTTP gossip wire (`mesh sync`, `mesh serve`) is the next phase.
Schema, cryptography, and roster state machine are all in place to
support it without further migration.
2026-04-27 19:00:24 -04:00
082143e158
add git and mercurial repo sources for self-play
GitRepoSource walks the working tree at HEAD via `git ls-tree` + `git show`,
yielding one Document per text file. URI shape `git://<repo>/file/<path>`
intentionally omits the commit hash — re-ingest after new commits produces
fresh document_roots that aborist's prior-doc detection chains via
`supersedes` edges, so the audit trail and Merkle tree grow as the repo
grows. Binaries skipped via NUL-byte + UTF-8 decode probes; >5 MB files
filtered out by default. Commit hash + timestamp + subject ride along in
`extra` (informational only; not part of the Merkle commitment).

MercurialRepoSource mirrors via `hg manifest` + `hg cat`. Same supersedes
semantics, same shape.

Makefile adds:
  make ingest-self                          (this repo -> aborist-self.db)
  make ingest-git GIT_REPO=/path/to/repo    (arbitrary git clone)
  make ingest-hg  HG_REPO=/path/to/repo     (mercurial)

Each lands in its own shard file alongside existing shards/grok.db,
keeping per-shard write paths independent of the wikipedia 4-way ingest's
WAL writer lock.
2026-04-27 18:17:39 -04:00
f90b7c69f0
add Phase IV Wikipedia XML + abstract sources
Drops in for any dated snapshot in dumps.wikimedia.org/archive — pages-articles.xml.bz2
and pages-meta-current.xml.bz2 (cur snapshot, single-revision-per-page) plus
pages-meta-history.xml.bz2 (multi_revision=True). abstract.xml feed yields
pre-distilled summaries at ~1/100th the chunk volume. Streams .bz2/.gz directly
via iterparse with bounded memory; same shard / resume contract as the SQL
source. Falls back to title-prefix namespace filtering when older export
schemas omit the per-page <ns> element (e.g. enwiki 20101011).

Makefile defaults target enwiki 20101011 (6.2 GB). Override with WP_XML_YEAR /
WP_XML_MONTH / WP_XML_DATE / WP_XML_LANG to fetch any other archived snapshot.
2026-04-27 17:24:28 -04:00
9ba06cd809
add Grok export source: conversations + media posts
aborist/sources/grok.py exposes two Source subclasses for ingesting
xAI's user data export:

  GrokExportSource    walks ttl/<period>/export_data/<user-id>/ and
                      reads prod-grok-backend.json. Yields one Document
                      per conversation with:
                        - URI: grok://conversation/<conversation-id>
                        - title: the conversation's auto-generated title
                        - content: full message text in turn order
                      source_type='grok_export'.

  GrokMediaPostsSource same export, but yields per media-generation
                      post (image/video prompts) under URI
                      grok://media/<post-id>.
                      source_type='grok_media'.

Both auto-walk down from the export root so callers can pass the
top-level directory xAI delivered (e.g., ~/Downloads/<user-uuid>/).

Wired into the CLI: aborist ingest --source {grok_export,grok_media}
accepts --path <export-root>. tests/test_grok_source.py covers the
walk + parse + Document shape with a fabricated mini-export fixture.

Once ingested, conversations become normal queryable docs in the
shard cluster — your prior chats become memory the corpus can
consult during RAG.

79 tests passing.
2026-04-27 13:49:42 -04:00
1abe6d4adc
falsification CLI: mark wrong cached answers stale/failed
aborist [--shards-dir DIR] providence --falsify CACHE_KEY \
        --state {failed,stale,quarantined} --reason "X" --by-actor NAME

Searches every shard for the cache_key (it lives in exactly one),
flips falsification_state on the providence_cache row, appends a row
to the falsifications log, and writes a 'falsify' audit event so the
chain captures the act with from_state/to_state/reason/by_actor.

Future cache lookups skip records whose state != 'live', so a
falsified answer never gets reused. The record itself is preserved
(history matters); only its admissibility changes.

Makefile target:
  make falsify KEY=<hex> REASON='why' [STATE=failed]

Demo on the live shard cluster: marked the AMD/Pentium-4 conflation
answer as failed, verified state flipped + audit row recorded +
falsifications log captured fox as actor + the Barton-clock-vs-PR
explanation as reason. Re-asking the same question generated a fresh
cache_key (corpus state shifted between attempts) so a new LLM call
ran — exactly the v9.8 admissibility behavior: each (8-dim cache_key)
is independently live/failed.

66 tests still passing.
2026-04-27 13:03:06 -04:00
76446d4349
add aborist activity — agent-readable recent timeline
New CLI: aborist [--shards-dir DIR] activity [--limit N] [--since-seconds S]
            [--preview-chars N]

Aggregates across all shards into a single JSON document with:

  shards         the SQLite files queried
  corpus         counts: documents_total/surface/core,
                 providence_total/live/stale/failed,
                 audit_events_total
  recent_qa      providence_cache rows (cache_key, question, answer
                 preview, hit_count, state, last_hit_at) newest first
  recent_ingests audit events of type=ingest with body's document_uri,
                 source_type, chunks, supersedes link
  recent_derives audit events of type=derive (distillation results,
                 compression_ratio, compression_depth)
  recent_falsifications  records marked failed/stale/quarantined,
                 with reason and by_actor

--limit caps each list independently. --since-seconds filters to recent
events only. --preview-chars trims long answers.

Designed for an agent to read before deciding the next action — what
was just asked, what just got integrated, what's the current state.

Makefile target: make activity ACTIVITY_LIMIT=N

66 tests still passing.
2026-04-27 12:58:23 -04:00
fc039555cc
multi-source corpus query: pose a question, the tree pulls related cached docs
aborist/qa/query.py exposes query() — the user-facing RAG flow:

  1. FTS5 search across all shards (chunks_fts can't be UNION'd as a
     view, so each shard's index is queried independently and merged
     by score).
  2. Top-K distinct documents are selected within a max-context-chars
     budget (default 60 KB so a 768-token response fits Hermes-3's
     82 K context window comfortably).
  3. context_root = Merkle root over the sorted source document_roots.
     That's the v9.8 'source' dimension for multi-source answers —
     a verifier can recompute it from the listed source roots.
  4. 8-dim cache_key over (context_root, question_hash, model_profile,
     conversation, governance_policy, schema, canonicalization,
     chunking). Hit returns STRICT immediately; miss calls Hermes and
     persists.

CLI: aborist [--shards-dir DIR] query "<question>"
  Default qa_db is <shards-dir>/qa.db (or ~/.aborist/qa.db). Uses the
  same OpenAICompatibleClient/StubClient as `ask`. --dry-run skips the
  LLM and returns context-only.

Search escape fix: the prior FTS5 escape ANDed every token including
stopwords + punctuation, so "What is anarcho-capitalism?" required
the doc to literally contain "what" + "is" + "anarcho-capitalism?" —
zero hits. New tokenizer drops stopwords + punctuation and ORs the
remaining content tokens; BM25 ranks the multi-token matches highest.

Live demo against the 122k-doc 4-shard cluster:
  Q "What is anarcho-capitalism?"  6.1 s wall miss / 0.45 s cache hit
  Q "Who was George Washington?"   10.3 s wall miss
Both answers cite the source URIs Hermes was given.

57 tests passing (4 new query tests covering search → context →
cache → audit chain).
2026-04-27 11:43:57 -04:00
649aeec79a
progress reporter + structured benchmark
aborist/progress.py — stdlib-only rate-limited stderr reporter.
  Periodic lines (default every 2s) showing:
    elapsed | seen | inserted | docs/s now | docs/s avg | percent | ETA
  Wired into ingest_source via a `progress` parameter; CLI default-on
  with --quiet to suppress and --total-estimate N to enable percent/ETA.

  Demo on a 5k-doc ingest with --progress-interval 0.8:
    [    1s] |   200 seen |   200 new |  193 now | ( 193 avg) docs/s |   4.0% | ETA  24s
    [    7s] | 3,200 seen | 3,200 new |  458 now | ( 424 avg) docs/s |  64.0% | ETA   3s
    [   12s] | 5,001 seen | 5,000 new |  392 now | ( 413 avg) docs/s | 100.0% | ETA   0s

bench/run.sh + `make bench` — reproducible 5000-doc workload through
three configs:
  serial            single process, single SQLite
  parallel-shared   N shards, one shared SQLite (WAL serialized)
  attached          N shards, per-shard SQLite (true parallel writes)

Output is a one-shot table plus CSV at /tmp/aborist-bench/results.csv
so the ratchet is visible as we keep optimizing. Override via
BENCH_DOCS=N and SHARDS=N.

Latest baseline (this commit, on this machine):
  config             wall_s    docs   docs/s
  serial              11.74    5000    425.7
  parallel-shared     10.39    5000    481.1
  attached             7.99    5000    625.5

53 tests still passing.
2026-04-27 11:37:20 -04:00
db3e3c00ab
resumable ingest + per-shard audit chain integrity
Resume (rsync-style)
  Each shard DB gains a `meta` table. After every successful batch
  flush, ingest_source persists `source_high_water:<source_type>` ->
  the largest row id seen (cur_id or old_id). On --resume, the source
  reads it back and skips rows whose id is <= the mark, so an ingest
  killed at any point can be re-run cheaply: already-cached docs are
  fast-forwarded past without re-hashing or DB writes.

  WikipediaSqlDump now exposes `start_id` (skip threshold) and
  `last_id` (running max). cur_id is surfaced in Document.extra
  alongside old_id so both tables behave the same.

  CLI: aborist ingest --resume

  Demo on shared DB (cur):
    Round 1: --limit 3000 --resume   high_water = 5714
    Round 2: --limit 5000 --resume   skips 1..5714, picks up at 5715
    Round 3: --limit 100  --resume   skips 1..15362, ingests 100 more
  Always idempotent on re-run; no dups, no missing docs.

Per-shard audit chain integrity for sharded analyze
  Cross-shard analyze previously reported nonsense breaks counts —
  each shard owns its own audit chain (genesis -> ... -> latest), and
  the UNION view interleaves them so cross-shard transitions look
  like break events. The fix: when --shards-dir is set, open each
  shard's DB directly and run _check_audit_chain on it, then
  aggregate.

  Output now reads:
    "audit_chain": {
      "events": <total>,
      "breaks": 0,
      "shards": [{"shard": "000.db", "events": N, "breaks": 0}, ...]
    }

53 tests passing. Tests cover: high-water write, skip-on-resume,
idempotency across two resume runs, kill-and-resume continuity.
2026-04-27 11:29:27 -04:00
a3fd8ddc53
shard-aware distill: iterate per-shard, cores stay in source shard
aborist --shards-dir DIR distill iterates over each shard's DB and
runs distill_existing on it. Cores live in the same shard as their
source surface so the per-shard derivation chain stays
self-contained.

Per-shard processing also enables trivial parallelism via the
distill-shards-parallel Makefile target — one process per shard, no
DB contention because each shard is its own file.

End-to-end demo on 4-way 10k cur ingest:
  ingest:  17.6 s wall (4 parallel)
  distill: 6.2 s sequential per-shard (9185 cores from 10000 surface)
  verify:  30/30 random Merkle proofs across shards

Aggregated counters cover all shards plus per-shard breakdown in the
output JSON so the user sees both the union and the parts.

Note: cross-shard analyze reports an inflated audit_chain.breaks count
because each shard owns its own chain genesis -> ... -> latest, but the
UNION view sees them interleaved. Fix coming next.
2026-04-27 11:24:17 -04:00
7d2bf4e7fa
phase 2: attach-forever sharding — zero merge cost
Each ingest shard owns its own SQLite file. N processes write in
true parallel (no WAL writer-lock contention). Cross-shard reads
attach all shards as UNION ALL views over the standard tables, so
existing query code (`SELECT * FROM documents`) works unchanged.

CLI:
  aborist ingest --shards-dir DIR --shard 0/4    # write to DIR/000.db
  aborist --shards-dir DIR stats                  # read across all shards
  aborist --shards-dir DIR analyze                #   ditto
  aborist --shards-dir DIR verify -n 20           #   Merkle round-trips
                                                  #   on a sample drawn
                                                  #   from all shards

Makefile:
  SHARDS_DIR=~/.aborist/shards
  ingest-cur-attached SHARDS=4   ingest-old-attached SHARDS=4
  stats-shards                   cross-shard stats

Implementation lives in store.connect_query():
  - opens a fresh in-memory connection
  - ATTACH DATABASE per shard file under aliases sh000, sh001, ...
  - CREATE TEMP VIEW <table> AS UNION ALL SELECT * FROM <alias>.<table>
    for the eight shardable tables (documents, chunks, merkle_nodes,
    edges, derivations, providence_cache, audit_events, falsifications)
  - exposes a _shards temp table for tools that want the shard list

Benchmark (20k cur, 4 cores):
  Phase 1a serial            55.3 s
  Phase 1b shared-WAL        43.1 s
  Phase 2 attached-forever   40.1 s    (no WAL contention)

The 7% gap vs Phase 1b is bz2-decompression duplication: each shard
decodes the full dump independently. Architectural wins are bigger
than that number — write isolation, per-shard crash recovery,
audit chain stays per-shard checkable, shards can migrate to other
disks, new shards are pure append (never rewrite existing files).

Merge cost remains: zero. `ls $SHARDS_DIR/*.db` is the registry.
2026-04-27 11:17:17 -04:00
d0857fb43d
phase 1b: --shard rank/total + parallel Makefile targets
Workers each parse the full SQL stream but only emit rows whose index
satisfies (idx % total == rank). N processes cooperate against a
single shared SQLite file; WAL+synchronous=NORMAL handles writer
contention via BEGIN IMMEDIATE serialization.

Implementation chose OS-level processes over in-process mp.Queue: the
gain ceiling is identical (writer is the bottleneck either way) and
the surface area is one new flag plus stride-filter logic instead of
a queue-based pipeline with picklable workers.

Makefile gains:
  ingest-cur-parallel SHARDS=4
  ingest-old-parallel SHARDS=4

Benchmark on 20k cur articles:
  serial            55.3 s   361 docs/s   1.0x CPU usage
  4-way parallel    43.1 s   464 docs/s   ~3.0x CPU usage   1.28x wall

Both runs landed 19,999 unique documents — correctness verified.

The write-lock is now the bottleneck; further gains require Phase 2
attach-forever sharding (each shard owns its own SQLite file, queries
fan out via ATTACH DATABASE, zero merge cost).
2026-04-27 11:10:25 -04:00
85ae761df8
ETL throughput: 4x parser, 2.5x full ingest
Profile of the prior implementation (cProfile on 2000-doc ingest):
  total       23.6s
  parser      20.1s   (85% of time, 27.7M list.append calls)
  DB writes    2.9s

Three changes compound:

1. PRAGMA pack in store.connect():
     synchronous=NORMAL    skip per-commit fsync (durable under WAL replay)
     cache_size=-65536     64 MB page cache
     temp_store=MEMORY     temp tables in RAM
     mmap_size=256 MB      page-cache reads bypass read() syscall

2. Batched transactions in ingest_source and distill_existing:
     200 docs/tx default, --batch-size CLI flag.
     One BEGIN/COMMIT per batch instead of per doc.
     Audit chain SELECT-then-INSERT still chains correctly because
     SQLite reads its own uncommitted writes within a transaction.
     Idempotent on collision (INSERT OR IGNORE / pre-existence check)
     so partial-batch re-runs are safe.

3. Parser rewrite (the actual bottleneck): replaced char-by-char
   list-append loops with str.find / string slicing.
     _decode_mysql_string fast-path: if no '\\\\' in input, return as-is.
     _split_values_tuples uses _find_string_end (str.find with
       backslash-parity check) instead of per-char scanning.
     _iter_insert_statements buffered scan (str.find for ';' and "'")
       crossing read-chunk boundaries via in_string flag.
     ~13x fewer Python function calls, semantics identical.
   _upsert_edges now batches via executemany.

After: 2000 docs in 6.0s; 5000 old revisions in 12.8s wall (was 32.3s).
Throughput 85 -> 333 docs/sec. 49 tests still passing.

Cheaper to fail = cheaper to experiment.
2026-04-27 08:51:52 -04:00
f623250135
add 'aborist analyze' + correct default LLM endpoint
analyze emits a compression-spectrum report:
  compression_depth_histogram   surface vs depth=1, 2, ...
  distillers                    per-process compression ratio mean/min/max
  documents_by_source_kind      source/kind crosstab
  chunks_by_tier                hot/warm/cold distribution
  audit_chain                   event count + integrity (breaks must be 0)
  gravity_top_inbound           the 'gravity wells' of the forest

Default LLM endpoint moves from uncloseai.com (marketing site, no /v1)
to hermes.ai.unturf.com/v1 — the actual reachable Hermes-3 inference
endpoint serving adamo1139/Hermes-3-Llama-3.1-8B-FP8-Dynamic on vLLM
with an 82K context window. uncloseai.com still works as a brand
override via ABORIST_LLM_ENDPOINT.
2026-04-27 08:36:38 -04:00
a056acfb5b
prepare full Wikipedia 2003-05-16 ingest: cur + old (revisions)
The 2003-05-16 archive ships three files:
  20030516_cur_tablesql.bz2   82 MB  current snapshot (single revision/page)
  old_tablesqlbz2.1          640 MiB \
  old_tablesqlbz2.2          252 MiB / split halves of old (full revision
                                       history). Concatenate before bzcat.

Generalize the parser:
  WikipediaSqlDump(table='cur'|'old')  — shared statement parser, single
                                         column-position contract for the
                                         first 4 fields (id/ns/title/text)
  WikipediaCurDump  — back-compat wrapper, table='cur'
  WikipediaOldDump  — new, table='old'; old has no is_redirect, every
                      revision is real

Old rows surface old_id and old_timestamp via Document.extra so a
downstream pass can sort revisions chronologically before re-ingesting
through the supersedes-edge path.

Makefile gains:
  fetch-cur / fetch-old / fetch (both)
  ingest-cur / ingest-old / ingest (cur default)
  WP_OLD target concatenates the two split parts
CLI ingest --source now accepts wikipedia_cur or wikipedia_old.

Smoke (real dump): 5 revisions of "AtlasShrugged/Companies" yielded
correctly with old_id=2..10, timestamps from January 2002.
2026-04-27 08:10:42 -04:00