Taguru
reference · segment extraction

Segment extraction — taguru extract

The producer half of taguru import. It has a chat model read segments (.md/.txt) and decompose them into associations under the /protocol ingest discipline. Each becomes one source file into --out — its path becomes the source id. It applies nothing: the files are the output, and applying is the job of taguru import (or POST /import). Two stages on purpose — source files can be inspected, diffed, version-controlled, and re-applied, and the expensive stage (the model calls) is decoupled from the idempotent one.

The CLI shape

taguru extract --context cef2e28b-43f0-4b6c-8201-abab0785399f --name sake --description "酒蔵の知識" \
  --out sources/ docs/                # every .md/.txt under docs/, in name order
taguru import sources/               # offline — or POST each file to /import

PDFs are not among the formats above — nor are HTML, DOCX, PPTX, or objects in S3-compatible storage. The standard ingest connectors (Python SDK) read all of those into the same source-file contract this page describes; the local RAG walkthrough shows one worked end to end.

TAGURU_EXTRACT_URL      OpenAI-compatible /chat/completions endpoint (required)
TAGURU_EXTRACT_MODEL    model name (required)
TAGURU_EXTRACT_API_KEY  Bearer credential (optional)
TAGURU_EXTRACT_SCHEMA   default for --schema (default unset)
TAGURU_EXTRACT_TIMEOUT_SECS  time budget per completion; 0 = unlimited (default 300)
TAGURU_EXTRACT_PARALLEL  concurrent chunk completions per segment (default 1)
TAGURU_EXTRACT_FACT_BUDGET  default for --fact-budget (default 0, off)
TAGURU_EXTRACT_MAX_ATTEMPTS  total attempts at valid JSON per chunk, 1-10 (default 2)
TAGURU_EXTRACT_CORRECTIVE_CONTEXT_BYTES  cap a corrective turn's replay of
                    the model's own prior bad answer to this many bytes;
                    0 omits it entirely (default: unset, replay in full)
TAGURU_EXTRACT_STRUCTURED_OUTPUT  default for --structured-output (default off)
TAGURU_EXTRACT_MAX_OUTPUT_TOKENS  default for --max-output-tokens (default unset)
TAGURU_EXTRACT_ESCALATION_FACTOR  cap of the one escalated resend after an
                    answer ends at --max-output-tokens, as a multiple of that
                    budget; 0 = uncapped (default 2)
TAGURU_EXTRACT_RUNAWAY_RATIO  a length-limited answer bigger than this many
                    times its piece's bytes is a runaway: no escalation, no
                    split — the segment fails, after a demotion attempt under
                    an auto-resolved rung (a pinned rung never demotes);
                    0 = no runaway judgment (default 8)
TAGURU_EXTRACT_CHUNK_BYTES  default for --chunk-bytes (default 24576)
TAGURU_EXTRACT_CHUNK_CONTEXT  default for --chunk-context (default off)
TAGURU_EXTRACT_TRACE_ATTEMPTS  `off` disables the per-segment attempts log (on by default; see Trace)
TAGURU_EXTRACT_LOSSY  default for --lossy (default 0/false)
TAGURU_EXTRACT_CANDIDATES  default for --candidates (default 0/false)
TAGURU_EXTRACT_REDACT  default for --redact: 1/true (both groups), secrets,
                    pii, or 0/false (default 0/false)
TAGURU_EXTRACT_REDACT_RULES  default for --redact-rules (default unset)
TAGURU_EXTRACT_VOCABULARY  default for --vocabulary (default unset, off)
TAGURU_EXTRACT_COVERAGE  default for --coverage (default 0/false)
TAGURU_EXTRACT_DIAGNOSTICS  default for --diagnostics-out (default unset, off)
TAGURU_EXTRACT_DIAGNOSTICS_RAW_BYTES  attach the model's raw answer text to
                    each diagnostics record, capped to this many bytes;
                    default unset/0 = never attach it (metadata only)

--dry-run           list what would extract or skip; calls nothing
--force             re-extract segments the manifest says are unchanged
--no-passage        omit the body text from the source file (facts only)
--questions N       doc2query: also propose up to N retrieval questions
                    per paragraph (1..=8; incompatible with --no-passage)
--fact-budget N     ask the model to keep each chunk's answer to at most N
                    associations total (default 0, off); a soft instruction,
                    never enforced after the fact — overrides
                    TAGURU_EXTRACT_FACT_BUDGET when both are set
--structured-output MODE  constrain the answer's shape on the wire
                    (see below): auto | json-schema | json-object | off
                    (default off — today's plain request)
--max-output-tokens N  explicit output budget per completion, sent as
                    max_tokens (default: none sent); an answer cut off at
                    the budget escalates deterministically instead of being
                    re-asked under the limit it just hit (see below)
--chunk-bytes N     segment bytes per model call (default 24576, at least
                    512); chunks split at paragraph boundaries — lower it for
                    a slow provider or output-dense segments (statutes,
                    minutes); overrides TAGURU_EXTRACT_CHUNK_BYTES
--chunk-context MODE  what each chunk is told about its place in the
                    segment (ADR 0033): off (default) sends the chunk
                    alone; structure prefixes every chunk with its
                    heading path, the openings of the units it refers
                    to, and the paragraphs just before it, and prefers
                    chunk boundaries at the outermost headings — no
                    model call; overview adds an overview pass first
                    (one call per chunk) whose per-unit synopses and
                    cast list ride in the block too; ingested (needs
                    --vocabulary) adds, from that export, what the target
                    context already holds about the cast; a computation
                    input
--parallel N        chunk completions to run concurrently within one
                    segment (default 1, sequential); overrides
                    TAGURU_EXTRACT_PARALLEL when both are set
--lossy             restore the pre-#199 behavior: drop a business-rule-invalid
                    item (bad weight, dangling alias, out-of-range question, …)
                    and count it instead of correcting or failing (see below)
--candidates        offer the segment's own names (kanji/katakana compounds,
                    ASCII identifiers — segmented deterministically, no
                    dictionary) as preferred subject/object spellings;
                    non-restrictive, off by default; toggling re-extracts
                    (ADR 0014)
--redact [GROUP]    mask secrets and pattern-recognisable personal data in
                    the segment before anything reads it: each match
                    becomes «redacted <rule> <hex>» in the prompt, the
                    passage, the checkpoint, the trace, and the attempts
                    log alike; GROUP = secrets | pii, none = both; every
                    redaction is named on stderr by rule and paragraph,
                    never by content; off by default; a computation
                    input (toggling re-extracts) — see Redaction below
                    (ADR 0038)
--redact-rules FILE your own rules on top of the built-ins (one
                    name<TAB>regex per line, # lines ignored; a name is
                    [a-z0-9_]+ and may not repeat a built-in's; a bad line
                    is a usage error naming it); the file's SHA-256 joins
                    the redaction version, so editing it re-extracts;
                    needs --redact (ADR 0038)
--vocabulary PATH   steer spellings toward a target context's existing
                    vocabulary: PATH is an exported source stream or a
                    directory of them — its *.jsonl files, so an extract
                    --out DIR works too (taguru export --out DIR); concept
                    names and labels are offered as preferred spellings,
                    and a context spelling never fails the occurrence
                    check; off by default, content-digest re-extracts
                    (ADR 0015)
--coverage          report every sentence holding two or more of the
                    segment's own names (the --candidates segmentation)
                    that no extracted association covers — one stderr line
                    per sentence, a count on the report line; report-only,
                    off by default, never re-extracts (ADR 0016)
--diagnostics-out FILE  write a JSONL sidecar of tagged records (see below):
                    one per chunk (provenance), one per LLM attempt (source,
                    chunk, attempt, terminal state, finish_reason, token
                    usage, latency, parse/validation issues), one per
                    segment written (association/alias/duplicate/dropped/
                    uncovered counts), a "run" record first; off by default,
                    ignored under --dry-run. Joins the per-segment trace
                    OUT/.extract-trace/ always writes (see below)
--source-id ID      write ID as the header line's source instead of the
                    segment path — the promotion runbook's
                    session:{agent}:{id} convention; several segments each
                    get ID/{file stem}, and a collision fails (import
                    retracts-then-applies per source id). Empty or
                    whitespace-only IDs are refused, and IDs over 1024
                    bytes (the server's source-name cap) fail up front
                    rather than at import. Changing it rewrites the source
                    file but reuses cached chunk answers (ADR 0017)
--date WHEN         the session's own date, written on the passage line:
                    YYYY-MM-DD (UTC midnight) or positive epoch seconds;
                    needs the passage
--tag TAG           tag the source (repeatable, deduplicated),
                    written on the passage line; needs the passage
--context ID        the context every source file targets, by id: a lowercase
                    hyphenated UUID — the id column of GET /contexts, or a
                    fresh one (e.g. from uuidgen) alongside --name
--name NAME         attach a create block naming the context (used only when
                    no context carries --context's id yet)
--description TEXT  the create block's description (needs --name)
--schema FILE       a context schema document (see below) — the same shape
                    {stem}.schema.json/PUT /contexts/{id}/schema persist and
                    serve; a file that fails to parse or fails schema::install's
                    own checks is a startup error, never a silent skip
--config F          read KEY=VALUE environment from F (same dialect as serve)

Exit codes: 0 every segment extracted or skipped · 1 some segments failed (details on stderr; the rest completed) · 2 usage error.

--schema is this pipeline's own copy of a context schema document — not to be confused with --structured-output json-schema below, which is an unrelated OpenAI wire format for the model's answer shape. Extract is offline and has no running server to fetch one from, so unlike the LangChain SDK ingesters (which pull it live), the operator hands it the file explicitly.

Redaction: what never reaches the model

A segment handed to extract goes to the model as written — and, for a hosted endpoint, out of the machine. --redact is the gate for the cases a regular expression can recognise: the segment is scanned before anything reads it. Each match is replaced by a placeholder, so the prompt, the passage the source file quotes, the checkpoint, the trace, and the attempts log all hold the placeholder and none holds the text (ADR 0038). Two groups, both on when no group is named:

  • secrets: AWS access keys, GitHub tokens, OpenAI keys, Slack tokens, Google API keys, PEM private-key blocks, JWTs, credential assignments (password = …, api_key: …), Authorization: headers, and the password in a URL's userinfo. For an assignment, a header, and a userinfo the value is masked and the name around it stays, so the sentence still reads.
  • pii: e-mail addresses, Japanese and international phone numbers, 個人番号 (check digit verified), and payment card numbers (issuer range and Luhn verified).

The placeholder is «redacted <rule> <hex>»: the rule that matched and a short prefix of a digest of the matched text salted with the segment's hash — the same secret in one segment masks the same way every time, so the model can still see that two sentences mention the same key, and distinct matches under one rule never share a placeholder. A placeholder already present in the input (a segment redacted upstream) is kept as it is and counted apart. Matching is per paragraph; overlapping candidates resolve to the earliest, then the longest, then the rule order above.

What is not judged, on purpose: names, addresses, dates, IP addresses, high-entropy strings without a known shape, and anything only context can tell apart. Those need a model or a dictionary, and a gate that sends the segment to a model to decide what not to send is no gate. The rule set is versioned (redact2) and is a computation input: turning the flag on, off, or to another group re-extracts the segment; the manifest's sha256 stays the file's own, so "did the file change" is unaffected.

Accounting is by rule and paragraph, never by content: one stderr line per segment (redacted 2 match(es): aws_access_key ×1 (paragraph 0), email ×1 (paragraph 3)), a , N redacted note on the report line, one redaction record per match at the top of the trace, and a , N redaction(s) note on --dry-run's plan line. The answer is scanned with the same rules before the source file is written. A label or question carrying what the masked text never showed fails that segment. The failure names the source-file line and the rule, and a subject, object, or alias that is a placeholder is removed as not an entity, with the usual accounting. With redaction off and TAGURU_EXTRACT_URL pointing anywhere but this machine, the run says so once on stderr, before the first segment is read.

Your own rules. What the built-ins cannot know — an employee-number format, an internal ticket id, a customer code — goes in a file handed to --redact-rules FILE (default TAGURU_EXTRACT_REDACT_RULES), applied after the built-ins in file order: one name<TAB>regex per line, blank lines and lines starting with # ignored. A name is [a-z0-9_]+ — it is written into the placeholder, «redacted emp_id 3f9a» — and may not repeat a built-in's or an earlier line's; the pattern is a Rust regex (no look-around; bound with \b), matched within one paragraph like every built-in. A bad line is a usage error naming it, never a skipped rule. The file's SHA-256 joins the redaction version (redact2+<64 hex>, redact2:pii+…), so editing the file re-extracts every segment exactly as a changed built-in would. The file needs --redact on: a rules file with redaction off would run nothing.

# redact-rules.tsv — one rule per line, name TAB regex
emp_id	\bEMP-\d{6}\b
ticket	\bTCK-[0-9]{4,}\b
customer_code	\bC[A-Z]{2}-\d{5}\b

taguru extract --context cef2e28b-43f0-4b6c-8201-abab0785399f --out sources/ --redact --redact-rules redact-rules.tsv docs/

The credential boundary survives

The server holds no model credentials — that rule stays intact. Extract is an offline producer, and TAGURU_EXTRACT_* lives only in its own process environment. It is exactly a custom agent-side pipeline holding its own keys, just shipped in the box as a subcommand. It never touches the data directory (and takes no lock). Its only output is files.

There is one wire protocol, deliberately the same stance as embeddings: OpenAI-compatible. https://api.openai.com/v1/chat/completions works as-is, and so do local servers (Ollama, llama.cpp, vLLM). Bedrock and native Anthropic are bridged by any proxy that speaks /chat/completions, such as LiteLLM — the same bridge pattern the Bedrock guide shows for embeddings. taguru communities reads the same TAGURU_EXTRACT_* variables for its community summaries — one provider configuration for every LLM the toolchain talks to.

Local models: five field notes

All of the following was learned by actually running the pipeline against Ollama on a laptop.

  • Turn thinking mode OFF. A model in reasoning mode spends the whole time budget on invisible thinking tokens before the first byte of JSON: the same 10 KB segment that extracted in ~30 s with thinking off blew through a 300 s timeout with it on. The extractor speaks plain OpenAI-compatible chat and does not toggle vendor-specific thinking flags — pick a non-thinking model, or disable thinking on the serving side. The symptom: a busy GPU and timed out reading response — or, with no timeout set, an empty response after minutes of thinking (reported as exactly that). Thinking exhausted the generation budget.
  • Give the server a real context window. A 24 KiB chunk is roughly 6k tokens. A serving default of 4k truncates the request silently — no error, just quietly degraded output. On Ollama, bake the window into a derived model: FROM <base> + PARAMETER num_ctx 16384 → ollama create.
  • Match the timeout to the hardware. TAGURU_EXTRACT_TIMEOUT_SECS bounds each chat completion (default 300; 0 = unlimited). Every attempt gets the full budget, and a transient failure is retried up to 4 attempts total, so a provider that stalls instead of erroring can cost several multiples of the timeout per chunk before the retries exhaust — worth remembering before dropping the timeout to something tight. Under the escalation ladder (--max-output-tokens or --structured-output) a timeout is not retried at the same size at all: the chunk splits instead (ADR 0020), so the cost is one timeout per split level, and --chunk-bytes lets you start smaller.
  • The manifest trusts the model's name. Re-point a serving alias at a different base (overwrite ollama create my-extractor with another base) and nothing the manifest can see changes — segments stay "unchanged" under what is actually a new model. Name models honestly, or --force after re-pointing. The same caveat as the embedding vector cache (also keyed by model name).
  • A wall of a malformed answer near the output cap can stall a chunk for minutes. A small local model that runs its output budget out mid-answer produces one huge invalid response; replaying that whole response back verbatim and then asking for the same length again just reproduces the same cutoff — one chunk of a few hundred bytes measured at 4-5 minutes this way. The first-class fix is --max-output-tokens: with an explicit budget, a cut-off answer takes the deterministic escalation ladder (below) instead of a shorter-please retry. Without one, --fact-budget N asks for fewer associations per chunk up front, the truncation-aware correction (see below) asks for a shorter answer once the provider's finish_reason says the prior one was cut off, and TAGURU_EXTRACT_CORRECTIVE_CONTEXT_BYTES=0 is the blunter fallback: never replay the bad answer at all.

These five are the extraction side of the same serving layer. The same knobs — thinking mode, context window, output caps, and streaming — seen from the answering side, plus where to separate Taguru's own latency from the model's, are in Troubleshooting — the serving layer. All of it applied together, against Ollama, extract through answer, is the local RAG walkthrough.

The pipeline: the named steps every segment goes through

Extract takes each segment through a fixed, ordered sequence of named steps. The names are a public contract (ADR 0030): the same name is used in the records below, in --resume-from, and in this table. The list is not closed — a later version can add a step (ADR 0033 added structure, overview, and annotate, table edition 2), and the order to read them in always comes from this table, never from a number: positions shift, names do not.

StepScopeModel callProducesDescribed in
readper segment— the segment text as sent, its hash, the canonical paragraph numbering —
structureper segment— the segment's structural units — headings, statute articles, speakers — each with a level and a paragraph range (empty under --chunk-context off) Chunk context, Trace (structure)
planper segment— the chunk plan (index, hash, byte length, paragraph range per chunk) Chunking
overviewper segmentyes under --chunk-context overview: one call per chunk, before any extraction — a synopsis of each unit opening in it and the cast it introduces, cached in the checkpoint (none under other modes) Chunk context, Trace (overview)
annotateper chunk— the chunk's context block — heading path, referenced units' openings, preceding paragraphs — as prefixed to its user turn (none under off) Chunk context, Trace (chunk_context)
steerper segment— what taguru puts into the prompt on its own: candidates, the label vocabulary, context names, the schema block What the model is asked, Trace (steering)
promptper piece— the messages actually sent, every turn, corrective replays included The attempts log
callper completionyes the raw answer, finish reason, usage, transport retries, the classified state Structured output, The attempts log
parseper completion— the parsed answer and any parse issue —
validateper completion— the items kept after mechanical validation, and what it removed Quality, Trace (loss)
reconcileper segmentyes the piece outputs after cross-chunk correction, and what it pruned Trace (loss)
mergeper segment— the folded, contract-enforced extraction — one segment's worth Trace (item)
renderper segment— the source file, self-validated and atomically written —
verifyper segment— paragraph coverage, and — with --coverage — uncovered candidate pairs Quality, Trace (paragraph/uncovered)

The "Model call" column is why resuming is worth having: call and reconcile are the only steps that spend money, so a change that only affects a later step (a validation rule, a rendering detail) should not have to pay for them again — this is what #781 is building.

What is not a step: the manifest's skip decision is the gate above this pipeline, not a step in it (The manifest). A corrective round, an ADR 0019 escalated resend, and an ADR 0021/ADR 0001 §7 demote-or-split are all another lap through prompt→call→parse→validate, not steps of their own — which attempt corrected which, and why the ladder moved, are already answered by the attempt record's corrects and the move record (The attempts log). Writing the trace, the attempts log, and the manifest entry, and clearing the checkpoint, are bookkeeping about a step, not steps themselves.

Naming a step to resume from

taguru extract --resume-from STEP ... (#822) accepts any name from the Step column above. Its meaning is always the same — "steps before STEP are satisfied from the attempts log where a record exists" — but this version's attempts log only ever records two steps' output in full (prompt's conversation and call's answer, both under --replay's existing matching), so the rule folds onto one of three concrete behaviors today:

STEPFolds ontoWhy
call, parse, validate, reconcile, merge, render, verify --replay auto everything before each of these is prompt+call, both recorded in full — a completion replays exactly as an unnamed --replay auto run would
prompt --replay auto with the system-prompt pin (ADR 0031 §3.6) turned off naming prompt means prompt itself is rebuilt, not reused — the system half of its output is never pinned from the log, so a change the pin would otherwise have absorbed instead falls through to a live call on its own; call's record is still tried against whatever conversation this run actually builds
read, structure, plan, overview, annotate, steer a plain, unreplayed run nothing before any of these has a record in this version — there is nothing to satisfy from the log at all

--resume-from and --replay are mutually exclusive — the former already selects one of the latter's modes, so giving both is a usage error rather than a silent "last one wins". --replay-from DIR still applies underneath either. --resume-from, whatever step it names, also bypasses the manifest skip and the checkpoint store — naming a resume point is a deliberate ask to redo the segment, and an unchanged-segment skip answering that ask with nothing at all would defeat it just as much for read/structure/plan/overview/annotate/steer (which trigger no replay at all) as it would under a plain --replay run.

What the model is asked, and what is enforced on this side

This is steer (what goes into the prompt) and prompt (the messages actually sent) together. The system prompt is the /protocol ingest loop distilled for a producer that cannot resolve live: extraction from the segment's text alone — never the model's own knowledge, never a subject/object built from words the segment does not contain (spellings the prompt itself offers stay fair game — a --vocabulary context name, a schema type, a candidate — for entities the segment does mention), and an empty answer is the correct one for a piece that states nothing extractable (#852) — one fact per association, short names in the segment's language, one spelling per referent, negation as negative weight, no paraphrase re-assertion, membership edges made explicit, procedures chained with a single next-step label. The segment is presented with every paragraph numbered (blank-line boundaries counted across the whole segment, not per chunk — the same numbering --questions uses), and each association is asked to tag its paragraph of origin — the paragraph whose sentences state the fact, never a heading-only paragraph such as [3] ## Abstract (#812: under the earlier wording a model represented a section by its heading's number, one paragraph above the fact, in half the paper corpus's citations; the rule cut that to one segment in ten). That tag rides into memory with the fact; nothing downstream persists it yet. Relation labels converge across the run: each segment's labels are offered to every later segment's prompt (the offline stand-in for check-before-mint), so one run doesn't mint synonyms per file but converges on one vocabulary — ranked by how many associations (plus any alias that settled on it as canonical) have used each one so far, most-reused first, with a (×N) count on anything used more than once (issue #759): a label many associations already share is a safer reuse than one that showed up once, and the count is the signal that tells them apart. Temperature is 0.

When --schema names a document whose mode is not "off", the prompt gains one more block after the vocabulary one: the allowed entity type names, one label: domain → range line per constrained relation (budget-capped like the vocabulary block, live-vocabulary relations first), and an instruction to assert types on the reserved schema:type label — see the context schema reference for what that label means. It is deliberately never rendered as a JSON Schema enum, so a schema-constrained model can still propose a genuinely new relation rather than being forced to misuse an existing one. A domain/range violation or a closed_labels refusal in the answer earns its own targeted corrective turn (schema_output_issues, a sibling of the contract violations below), separate from — but bounded by the same TAGURU_EXTRACT_MAX_ATTEMPTS as — the rest of this section. Adding this block bumps PROMPT_VERSION 2 → 3; see the manifest for what that invalidates.

Model output is treated as untrusted input, and the contract is enforced on this side of the wire:

  • Exact duplicate triples fold into one line (the in-segment paraphrase rule, applied mechanically).
  • Items that could never import as answered are removed mechanically, before any corrective turn (ADR 0013): an association or alias with a required field missing or empty, an alias that maps a spelling to itself, an alias whose spelling an earlier segment of the same run (or the --vocabulary context) already settled on as a different concept or label — import would refuse that rewire and stop the stream, so it never gets written — a relation label that is a single character (most often a bare particle picked up as the whole relation; two or more characters survives — same anchor-nothing judgment as a single-character candidate name), a label, subject, object, or alias spelling written with an ideograph outside JIS X 0208's repertoire that the segment never writes (适用法律 for a segment that says 適用法律 — a multilingual model's simplified-Chinese rendering of a Japanese term, which query, resolve, and the reuse vocabulary would all treat as a second label; a segment that itself uses the character, a Chinese or Korean one or a Japanese one quoting either, clears it, and nothing about the segment's language is judged — ADR 0039), and a subject/object that never appears in the segment text (a whitespace- and case-blind check, so a normalized compound like プール最大接続数 for a segment that says プールの最大接続数 still passes; a name written entirely in a Latin-style script is covered by its whole words and word stems, never by shared letter pairs, so an English object built from words the input never uses is removed even when the chunk or its context block is English — ADR 0036). Every removal is named path-first on stderr, counted on the report line as removed (mechanical validation), and listed in the diagnostics sidecar — never a silent drop. Two removal classes are the exception to "before any corrective turn", both judged per segment after the Stage 2 corrective turns so the corrective messages' item indices still match the replayed answers: an alias whose canonical names nothing any of the segment's associations contain is pruned then (ADR 0013 Stage 2), and an alias whose in-segment shadowing or conflict the one Stage 2 corrective turn leaves standing is removed after that turn (ADR 0022), with the same accounting — a correction the provider cut off at the output limit counts as leaving its issues standing (ADR 0032; see the ladder under structured output for the one escalated resend it gets first).
  • Invalid items removal cannot judge — wrong-typed or oversized fields, zero/non-finite/over-cap weights, unknown alias kinds, shadowing or conflicting aliases, out-of-range questions — earn a path-addressed corrective turn by default (see below): a present-but-wrong value is content the model can actually fix.
  • A missing or out-of-range paragraph tag loses only the tag, never the fact — the association keeps the model's subject/label/object/weight judgment as-is.
  • An alias is adopted only when its canonical is a name this file's associations register, and never when the alias spelling is itself such a name (either would fail the source file at apply time).
  • Every file written is re-parsed with the import parser before it is written — extract cannot produce a file import would reject.

--candidates (ADR 0014, #496 S2) attacks spelling variance one step earlier, before the model answers at all: the segment's own names — kanji/katakana compounds split at hiragana boundaries, script-adjacent mixtures like 約40分, ASCII identifiers like cargo-nextest — are segmented deterministically (no dictionary, no model, the same list on every run) and offered in the system prompt as preferred subject/object spellings. The block is non-restrictive by contract: the model is asked to reuse a listed spelling when one names the entity it means, and explicitly told that entities outside the list stay allowed — constraining spelling never becomes constraining what may be extracted. Off by default; toggling it (a computation input) re-extracts. All-hiragana nouns and multi-word Latin noun phrases are known blind spots of the dictionary-free segmenter — ADR 0014 documents the limits and the measured path to a morphological analyzer if they ever cost recall. The list earns its keep on Japanese and mixed-script text; on Latin-script prose it is the segment's first hundred distinct words. Measured over the 63-segment verification corpus (#808): in Japanese segments the stray Latin fragments (CC/BY from a licence line, CPU, NHK) take at most 6 of the 100 slots and most of those are real names, so no compound is pushed out by them; in English papers, the Rust Book, and source files the cap fills within the first 1–2 KB and 60–75 of the slots are ordinary English words (the, ownership, searches), which is the one-word-at-a-time limit above at corpus scale, not a fragment problem — dropping short or lowercase tokens moved the cap by ~150 bytes and changed nothing downstream. For such segments leave the flag off (identifiers survive the occurrence check on their own) or accept that the block anchors only the opening. Pair it with --structured-output: under a candidate block, a mid-size model was measured emitting the offered spellings as unquoted JSON values — a syntax failure class constrained decoding removes structurally, which is exactly the syntax/naming role split the two controls are designed around.

--vocabulary (ADR 0015, #496 S3) is the cross-segment half of the same idea. Point it at an exported source stream of the target context (taguru export --out DIR, or GET /contexts/{id}/export saved to disk — a file, or a directory read by the same rule as taguru import DIR: its *.jsonl files, sorted, non-recursive, so a previous extract's --out directory serves too, its .extract-manifest.json and .extract-trace/ left alone), and the context's own concept names and relation labels are offered as preferred spellings. One extra instruction: use the context's exact spelling even when the segment spells the same entity differently. That is the cargo-nextest/nextest twin being prevented at answer time instead of detected by the consolidation audit after import. Alias spellings are never offered (they are the variants a canonical exists to fold), harvested labels seed the "relation labels already in use" block from the first segment, and a context spelling never fails the mechanical occurrence check — the steering and the validation agree by construction. The harvested name set is content-digested into the manifest, so refreshing the export re-extracts exactly when the names actually changed. Prompt-side the concept list caps at 200 names (alphabetical, deterministic); relevance-ranked selection is ADR 0015 §4's measured upgrade path. Re-export the context periodically — a stale vocabulary still steers correctly toward every name it knows and simply misses newer ones.

--coverage (ADR 0016, #496 S4) is the recall-side mirror of the mechanical validation above: where the occurrence check removes what the model asserted and the segment never said, coverage reports what the segment said and the model never asserted. Every sentence holding two or more of the segment's own names (the --candidates segmentation, applied whether or not that flag is on) is owed a triple; when no extracted association lands at least two of its three parts (subject/label/object) in the sentence, the sentence is flagged — one stderr line each (uncovered: [paragraph N] <sentence>), a count on the report line, an uncovered count in the diagnostics segment record. Report-only: the source file is written whole either way, no extra model call is made, and the flag is not a computation input. A rerun over an unchanged corpus skips every segment and still reports. It judges each from the source file it already wrote, so any past run's recall ceiling is measurable for free. A gap is a lead, not a verdict (a name-dense heading can flag without a real miss); re-extracting flagged sentences automatically is ADR 0016 §4's measured upgrade path.

A model that answers with anything but the JSON object gets a corrective turn: its own bad answer goes back to it, followed by a request to try again. By default that happens once — 2 total attempts per chunk, today's fixed behavior — and TAGURU_EXTRACT_MAX_ATTEMPTS raises the ceiling to anywhere from 1 (no corrective turn at all) to 10. Every corrective turn rebuilds the conversation from the system/user base plus only the single most recent bad answer, never the accumulated history, so TAGURU_EXTRACT_CORRECTIVE_CONTEXT_BYTES bounds the replay the same way on attempt 5 as on attempt 2: left unset the bad answer is replayed back in full (today's behavior), a byte count truncates the replay to that size, and 0 omits it behind a placeholder entirely. When the provider's own finish_reason says the bad answer was cut off at its output-length cap, the corrective ask itself changes: instead of repeating the request that just produced a too-long answer, it asks for a shorter one, naming --fact-budget when the run has one — repeating the identical ask just reproduces the identical cutoff, which is the stall field-noted above. That shorter-please swap is the no-budget-control fallback: a salvaged-shorter answer measurably costs most of the extraction, so once --max-output-tokens or --structured-output engages the ladder below, a cut-off answer is regenerated or split instead — never asked to shrink.

A syntactically valid answer that still fails the contract above first goes through the mechanical pass (ADR 0013): removable items come out with their removals recorded, and only if issues remain that removal cannot judge does the answer earn a corrective turn — the corrective turn is the last resort, not the first response. That demotion is measured, not aesthetic: on the failure corpus that motivated it (empty object fields, self-referential aliases), corrective turns burned 53–63 seconds per segment across five attempts without fixing anything, while removal is instant, deterministic, and loses nothing a corrected answer would have kept. The corrective treatment itself is bounded by the same TAGURU_EXTRACT_MAX_ATTEMPTS/ TAGURU_EXTRACT_CORRECTIVE_CONTEXT_BYTES controls — never a second, unbounded mechanism. The turn names every violation by its exact JSON path (for example associations[1].weight: expected finite non-zero number, got string "strong") and asks for the complete corrected object: keep every item, correct the named fields rather than deleting their items, add nothing that wasn't already there, JSON only. A shadowing or conflicting alias can only be judged once every chunk of the segment has answered — that check runs once per segment, right before the facts are merged, and (like the per-chunk check) spends at most one corrective turn; an alias issue that turn leaves standing is then removed with accounting rather than failing the segment (ADR 0022: an alias records a spelling variant, never a fact, so losing one costs nothing the consolidation audit cannot propose later — while failing the segment cost every fact it held; a standing issue about an association, such as a schema domain/range violation, still fails the segment); a dangling alias found at that same point is mechanically pruned instead, after any corrective turns, since an alias whose canonical resolves to nothing cannot import at all — and so is an alias that would rewire a spelling an earlier segment of the run already claimed, which the model cannot correct (it can re-judge its own associations, never un-claim a previous segment's name). If the corrected answer is still invalid, or the model answers with something cut off, refused, or empty, the segment fails outright and nothing is written. A source file is only ever the complete, valid truth, never a subset quietly missing the items that didn't fit. --lossy (or TAGURU_EXTRACT_LOSSY) opts back into the pre-this-behavior default: invalid items are dropped and counted without ever costing a corrective turn, and the one-line report marks every such run's drops with (--lossy) so a policy trim (a --questions cap overflow, a volunteered question nobody asked for) is never confused with a discarded fact.

This corrective-turn policy is a separate layer from transport retries: a transient provider failure (429, 5xx, transport) is retried up to 4 attempts total regardless of TAGURU_EXTRACT_MAX_ATTEMPTS, waiting a full-jitter exponential backoff between them — 1 s doubling toward a 30 s ceiling, randomized so concurrent chunks under --parallel don't all wake up and retry in lockstep. A 429 that carries Retry-After as delta-seconds uses that delay instead, verbatim (clamped to the same 30 s ceiling), since the server's own instruction beats a guess; HTTP-date values are not recognized and fall back to the computed backoff. A non-retryable 4xx fails immediately, spending none of the retry budget. Beyond the last attempt, of either kind, the segment fails, the remaining segments continue, and the run exits 1 — a re-run re-extracts only the failures, because the manifest records only successes.

Structured output and the output budget

This is the call step's request shape — everything here governs a completion, never what precedes or follows it.

Not to be confused with --schema above. Everything in this section is about response_format on the OpenAI-compatible wire — how the model's answer is shaped. --schema is Taguru's own context schema document — what types and relations are allowed to mean in the target context. The two names collide; the concepts do not.

By default nothing above changes: the request stays the plain {model, temperature, messages} it has always been. Two opt-in controls put the reliability strategy of ADR 0001 on the wire; engaging either switches the length handling from the shorter-please correction above to the deterministic ladder below.

--structured-output selects a rung of the capability ladder. json-schema sends the canonical extraction schema as response_format: {"type": "json_schema", …, "strict": true} — a backend with constrained decoding (Ollama, llama.cpp, vLLM) then cannot answer with anything but the schema's shape. json-object sends JSON mode: syntax forced, shape not. auto probes the endpoint once at startup — a tiny completion carrying exactly the extraction response_format, with an ask that invites prose — and keeps the strongest rung the answer verifies, falling from json_schema to json_object to bare prompted JSON and reporting the resolution on stderr. A backend may accept a parameter without honoring it, which is why auto verifies behavior rather than trusting a 200. The pinned modes skip the probe and trust the operator: a backend that rejects the parameter fails loudly on the first segment. One caveat by design: OpenAI's own strict mode requires every property listed in required, which the canonical schema's optional weight/paragraph deliberately are not — against api.openai.com, auto therefore lands on json_object today (a strict-mode schema variant is a possible future refinement). A schema-constrained answer still passes through every check in the contract above — the wire narrows shapes; validation stays the authority — and one that fails anyway is reported on stderr as provider non-conformance. A probe that passes says nothing about a real segment: some local models loop under constrained decoding — the tiny probe answers cleanly, then a 2 KB abstract generates 15,000 tokens of schema-shaped output and never stops (measured on Ollama with a 30B MoE). So auto also demotes at run time (ADR 0021): when a chunk exhausts the ladder under a constrained rung — length at the budget and again at the escalated resend, or a timeout — the run drops one rung (json_schema → json_object → prompted JSON), reports it on stderr, and restarts that chunk at the ladder's top; only a chunk that exhausts the ladder with nothing left to demote splits. The demotion is run-wide and never reverses: the finding is about the backend, not the chunk. The pinned modes never demote — a looping model under json-schema splits down to the floor and fails, so pin json-object or off for a model known to loop.

--max-output-tokens N makes the output cap an explicit request parameter instead of ambient serving configuration. When a completion still ends with finish_reason: length, the next action is deterministic, and it is never "re-ask under the very limit that just proved too small": first the budget escalates once — the same ask resent neutrally at TAGURU_EXTRACT_ESCALATION_FACTOR times the budget (default 2×; 0 sends no cap at all), the truncated answer discarded (its prefix is never salvaged, even when it happens to parse); if the escalated ask still overruns, the chunk splits — each half re-labeled with its paragraph numbers and run through the ladder from the top at the configured budget; a piece too small to split fails the segment with a named diagnosis rather than importing a truncated extraction. The escalated resend is capped on purpose (ADR 0019): a model that loops under constrained decoding never ends an uncapped resend with length — it ends with the TAGURU_EXTRACT_TIMEOUT_SECS timeout, retried as a transport failure, and the split rung is never reached (measured at 10–25 minutes per chunk on a looping local model). Capped, the loop ends with length within a couple of the budget's wall-clock and falls through to the split. Raise the factor for a provider whose honest answers routinely need more than twice the budget; set it to 0 only for one that is known not to loop. Both rungs assume the output's size is a function of the input's — and a fabricating model breaks that assumption (#854: a piece whose body was one heading line answered with 21 KB of associations built from words the input never contained; escalation got length again, and every split sub-piece reproduced it). So a length-limited answer whose bytes exceed TAGURU_EXTRACT_RUNAWAY_RATIO × the piece's bytes (default 8 — measured successful answers top out near 5.5×, observed runaways start at 10×; 0 disables the judgment) is treated as a runaway (ADR 0035): no escalated resend, no split — under an auto-resolved rung, a demotion is still tried first (a constrained rung looping produces the same signature; a pinned rung never demotes, same as every other exhausted-ladder case), and with nothing left to demote the segment fails immediately with both sizes named, rather than paying for rounds that cannot converge. A completion that runs TAGURU_EXTRACT_TIMEOUT_SECS out under the ladder is treated the same way as length (ADR 0020): the piece is too big for the time budget, so it goes straight to the split rung — never retried at the same size (the split is the retry; transport failures, 429 and 5xx keep their four attempts), never escalated (a larger output cap cannot make a slow piece faster) — and at the split floor it fails the segment with the timeout named and the two knobs that would have helped. The Stage 2 cross-chunk corrective turn climbs the same ladder as far as it goes (ADR 0032): a corrective answer that ends at the cap is resent once at the escalated budget, the truncated answer discarded; but there is no piece to split, so a resend cut off too — or a cut-off with no budget configured — is treated as a correction that left its issues standing: the output keeps its accepted Stage 1 answer and ADR 0022 rules on what stands (an alias is removed with accounting, anything else fails the segment). A truncated correction is never imported. Policy refusals (finish_reason: content_filter) are terminal immediately — no corrective turn argues with a policy. Both controls are manifest compute inputs like --questions: changing them re-extracts.

Chunk context: what a chunk is told about its place in the segment

These are the structure and annotate steps (ADR 0033, #782). "Context" here is what a reader needs in mind to read a chunk — never the context that --context ID targets. By default (--chunk-context off, or TAGURU_EXTRACT_CHUNK_CONTEXT) a chunk is sent alone: part K of N, and the labeled text. It does not know the chapter it sits in, the definitions an earlier article gave, or which act 同法 names three chunks back — the gap the v0.9.5 baseline measured as near-zero cross-unit recall on statutes, minutes, and technical segments.

--chunk-context structure closes the mechanical half of that gap with no model call. The structure step reads the segment's own lines for headings: Markdown # levels; statute 第N章/第N節/第N条 (a parenthesized (見出し) line names its article, 第三条の二 branches ride along); minutes ◆ sections and ○ speaker lines; dotted or § numbering (1.2 Setup, § 3 — never a bare 1. item, which is a list). Each heading opens a unit with a level and the paragraph range up to the next heading. The annotate step then prefixes every chunk's user turn with a chunk context block, in this order and only while a cap (a quarter of --chunk-bytes, never under 512 bytes) holds: Position: the heading path in force at the chunk's first paragraph, outermost first (第二章 › 第三条); References: the heading and opening of every unit the chunk names and does not itself hold — an article by number, 前条, §2, a heading quoted verbatim — in mention order, at most four; Preceding text: the paragraphs just before the chunk, whole ones as far as the cap allows and the nearest one's tail otherwise. The block's own first line tells the model to extract from the chunk only.

Three rules keep the block from leaking into the source file. It carries no [N] paragraph label, so nothing in it can be the paragraph a fact is tagged with — the passage and every locator are exactly what they were. It is the segment's own text, so the occurrence check reads it: 電子署名法 answered for a chunk that only says 同法 is kept when the act's name is on the heading path, where the chunk alone would have had it removed as unattested. And it is recorded: the trace carries one structure record per unit and one chunk_context record per chunk (its sha256 and bytes, the unit indices on the position path and among the references, and the overlap_paragraphs range), so a gained fact can be credited to the kind of context that carried it. The mode is a computation input: switching it re-extracts, and a manifest or checkpoint from before the flag matches off. Split sub-pieces (the ladder's split rung) keep their chunk's block — a sub-piece is still part K, read in part K's context.

--chunk-context overview adds the model's half. Before any extraction, the overview step asks the model once per chunk — with the units opening in that chunk listed by number and heading, and the chunk exactly as extraction will see it — for a one-to-two-sentence synopsis of each such unit (what it establishes: definitions made, decisions taken) and the cast the chunk introduces or relies on (people, organizations, products, defined terms, each with a gloss), as one JSON object. Every chunk's block then carries, after position and references and before the preceding text, Cast: the merged cast (first-seen order, duplicate names folded, at most 40) and Before: the synopses of the units wholly before the chunk — cut at structural boundaries, each summarized from its own text, never a running summary. Two rules keep this honest: the cast and synopsis lines are the overview model's words, not the segment's, so the occurrence check leaves them out (a name that occurs only there is still unattested); and the pass is advisory — an answer the pass cannot land (cut off even at ADR 0019's one escalated resend, refused, empty, or not JSON) is reported once on stderr and that chunk contributes no synopsis or cast, never a failed segment — and is recorded in the checkpoint as an empty answer, so a resumed segment neither re-asks it nor (a different answer changing the overview) re-binds its extraction units; --force starts over (ADR 0034, which also fixes the unit of one ask as the chunk, not the structural unit). Each completion is an attempts-log record with stage: "overview", so its cost is priced apart; the trace carries one overview record per answered chunk. The answers are cached in the segment's checkpoint by chunk hash and reused on a resume; the extraction units are bound to the merged overview's digest, so a changed overview discards them (they would have been extracted against a different block) and says so. Under the json_schema rung the pass sends json_object — the extraction schema is the wrong shape for this answer.

--chunk-context ingested adds the second source ADR 0033 §3.2 names: what the target context already holds. It needs --vocabulary — the same export of the target context — and reads, beside the names, every association the export carries, keeping for each concept its strongest few (at most five by |weight|, export order among equals). A chunk's block then carries, right after Cast:, a Known: line: for each cast name — and then each --candidates name — the export knows, that name's relations, an outgoing one as label → other, an incoming one as other → label (Known: 委員会 — 決定 → 予算案; 設置 → 作業部会). No further model call; the minutes case is the point (the earlier meeting's bills and decisions are in the export, not in this segment). A live lookup against a running server is deliberately not what this is (ADR 0033 §3.2): an export is a file with a digest, so the run stays resumable and replayable. The line is the export's words, not this segment's, so the occurrence check leaves it out too (its names already pass through the export's allowlist). The manifest records the mode as ingested:<digest>, the digest being that of the relations the export offers — a changed export re-extracts, as a changed vocabulary already did. The trace's chunk_context record lists the known names carried.

Chunking

This is the plan step. Segments are sent in chunks of at most --chunk-bytes (24 KiB by default; TAGURU_EXTRACT_CHUNK_BYTES sets the default), cut at paragraph boundaries (aliases and duplicate folding stay coherent across chunks — an alias in chunk 1 whose canonical first appears in chunk 3 still lands). A fact whose evidence straddles a chunk boundary can be missed; that is why the cap leans large. Passages are never chunked: the passage line carries the full segment text as-is. Segments over 8 MiB are refused. Their passage could not fit in the source file. Split the segment. Under the escalation ladder (above) a length-limited or timed-out chunk can split further at run time, so --dry-run's chunk count is a lower bound on model calls, not an exact one. The cap is a computation input: a segment extracted under a different --chunk-bytes re-extracts (the default cap matches manifests written before the flag existed). Lower it for a slow local provider or for output-dense segments — statutes, minutes — whose answers outrun the time or token budget at the default cap (ADR 0020). Under --chunk-context structure the packing gains one preference: a paragraph that opens a unit at the segment's outermost level (a chapter, a top-level heading, a speaker block) ends the current chunk once that chunk is past half the cap — so boundaries fall on chapters when they can, no chunk exceeds the cap, and a segment with no structure chunks exactly as before.

--parallel N (or TAGURU_EXTRACT_PARALLEL; the flag wins when both are set) runs up to N of one segment's chunk completions concurrently instead of one at a time — the chunks still merge in their original order, so output is identical to a sequential run, just faster wall-clock. The first chunk to fail (by index, not by which thread finishes first) still fails the segment: no worker claims a new chunk once the failure is recorded, but a chunk already claimed and in flight at that moment still runs to completion — its result is simply discarded. Parallelism never crosses segments — each segment's relation-label vocabulary is offered to the next segment's prompt, so running segments concurrently would race that hand-off and let labels diverge between them. Fanning out chunks multiplies request pressure on the provider, which is also why the retry policy above leans toward more attempts and jittered spacing rather than fixed timing.

Questions (doc2query)

With --questions N, the same extraction call also proposes up to N realistic retrieval questions per paragraph — questions the paragraph answers, phrased the way a user would type them, kept away from the paragraph's own wording — and the source file carries them as question lines. Questions ride the same server-numbered paragraphs as the associations, so the indexes written out match exactly what the server validates and cannot drift. Generation riding the extraction call is deliberate: there is no cheap questions-only pass, so changing N means re-extracting (the manifest records it as a compute input). Every server indexes each question's terms into its paragraph's BM25 postings, so question-shaped searches land lexically; a server with TAGURU_EMBED_PASSAGES additionally embeds each question next to its paragraph.

The manifest: skipping what's already extracted

The manifest sits above the pipeline — it is the gate that decides whether a segment runs at all, not a step within it. Extraction being the expensive stage, --out carries an .extract-manifest.json: one entry per segment. It records the "content hash × model × prompt version × target context" its source file was computed from. Segments whose compute inputs are unchanged are skipped, --force overrides, and a missing or corrupt manifest degrades to re-extraction — never to a false "unchanged". Keep the out directory between runs and a nightly extract-and-import pays model calls only for changed segments; import's retract-then-apply makes the re-application exact.

PROMPT_VERSION is one of the compute inputs the hash covers, so it bumping (2 → 3, for the schema vocabulary block above) invalidates every manifest entry computed under the old prompt — the next run against an existing --out re-extracts everything once, whether or not --schema is even in use.

The context being a compute input is deliberate: it is baked into every header written, so re-running the same out directory with a different --context re-extracts instead of leaving files aimed at the old destination. Still, use one out directory per context — the manifest has one entry per segment, so alternating two contexts in one directory re-extracts everything on every switch and the later context's files overwrite the earlier's. Source files from several out directories can be applied together in one import (taguru import sake/ code/) — every file's header names its own context, so "extract once per context, import everything at once" is the intended shape.

Chunk checkpoints: surviving an interruption mid-segment

The manifest above protects a whole segment that hasn't changed — but a segment is not the smallest unit of work. A long segment can take many chunk completions, and until the last one lands, an interruption (Ctrl+C, a preemptible instance reclaimed, a later segment's panic) threw away every chunk already extracted for it. --out also carries a .extract-checkpoints/ directory, one JSON file per segment, holding every chunk unit successfully extracted so far. A rerun over the same --out picks up mid-segment: chunks already checkpointed are never re-sent to the model. Each checkpointed unit is already, in effect, one piece's prompt, call, parse, and validate output, keyed by the piece's own text hash — an unnamed precedent for the per-step storage #781 is building on. One run per --out at a time: the manifest and each checkpoint file are rewritten whole from the running process's memory, so a second extract on the same directory is refused up front (output directory … is held by another taguru process) by an advisory .extract.lock that dies with its holder; --dry-run takes none.

Each checkpointed unit is keyed by the hash of its own text, not by chunk index. This matters because the ADR 0001 §7 length ladder can split an oversized chunk into smaller sub-pieces on its own recursive re-attempt — a resumed run's split points are not guaranteed to line up with a prior run's. Keying on content hash instead of position means a completed sub-piece is recognized as done regardless of how either run divided the chunk around it, and a not-yet-completed sub-piece is correctly treated as new work. That same hash is the unit's piece_id in the trace, and since 0.9.5 each unit also records the completion that produced it (attempt: {run_id, attempt_seq}), so a resumed segment's trace still names the original run for every reused unit.

A checkpoint file also carries the same compute-input fingerprint the manifest checks (content hash, model, prompt version, context, --questions, --fact-budget, --structured-output, and so on). Any mismatch — the source edited, a setting changed — invalidates the whole file, the same "unreadable or incompatible degrades to re-extraction, never to a false reuse" posture the manifest takes. --force discards a segment's existing checkpoints outright, one level deeper than bypassing the manifest skip. A segment that finishes successfully has its checkpoint file deleted; a segment that ultimately fails (Stage 2 correction, merge, self-validation) keeps it — the chunks it did complete are still good work worth keeping for the next attempt, and the failure line says how many units are checkpointed and that a rerun without --force resumes from them, re-asking only what failed. There is deliberately no "write what succeeded" mode: a source file is the source's complete, valid truth or nothing (ADR 0001 §8), and the checkpoints already make the retry cost only the failed part.

--dry-run reads checkpoints too (never writes or calls anything) and folds a "reusable" count into its existing per-segment line, e.g. "3 chunk(s), 1 reusable from checkpoint". The count is top-level-chunk granularity only — dry-run resolves no ladder, so a chunk that would end up split on a real run is honestly reported as pending rather than guessed at.

Ctrl+C (or a SIGTERM) during a run is cooperative: the first signal finishes the chunk in flight, checkpoints it, and stops before starting the next one — between chunks within a segment under the default sequential mode, and between segments under --parallel (its concurrent chunk dispatch is not interrupted mid-flight, so stop takes effect at the next segment boundary rather than the next chunk). The process exits with code 130 and prints a line naming how many segments were reached before stopping; rerunning the same command resumes from the checkpoints just as after any other interruption. A second signal forces an immediate exit, also with code 130, for when the graceful path is itself stuck (a hung connection, a provider that never returns).

Operationally, this is what makes a long extraction job viable on a spot/preemptible instance: schedule it to run against a persistent --out, let the scheduler kill it whenever the instance is reclaimed, and requeue the same command — the manifest skips finished segments, checkpoints skip finished chunks within the segment that was in flight, and nothing already paid for is ever paid for twice. Composing this with taguru import's own atomicity into a full run/interrupt/resume operating picture — bounded windows, work-unit enumeration, failure/re-submission — is the long-running ingestion guide.

Diagnostics sidecar

By default a failed segment earns one stderr line and nothing else — enough to know that a segment failed, not enough to tell a truncated answer apart from a model syntax error or an empty-answer thinking burn. --diagnostics-out FILE (or TAGURU_EXTRACT_DIAGNOSTICS; the flag wins when both are set) opts into a JSONL sidecar, written incrementally — a killed run keeps every record already flushed. It is off by default, and without it stdout/stderr are unchanged byte for byte: the sidecar is purely additive.

The sidecar is a tagged stream: a type field discriminates "run" (the first line — run_id, the identity of this invocation; ADR 0023), "chunk", "attempt", and "segment" records, described below. Filter on type == "attempt" to read only the attempts and skip any record type this sidecar adds later — an attempt record's own shape only ever gains fields. The discriminator was named kind before ADR 0042: a consumer written against that name has to read type instead, since nothing writes kind any more.

Every attempt record carries: run_id, attempt_seq (one-based, in issue order over every extraction completion of the run — the corrective budget's own attempt below restarts per round, this never does) and piece_id (the sha256 of the text this completion asked about — the chunk, or the split sub-piece; for cross_chunk, the piece whose answer is being corrected), the three keys the trace file joins on; source, chunk_index, attempt (one-based), max_attempts, stage (item for the per-chunk loop, cross_chunk for the Stage 2 alias correction above), state — the ADR 0001 §7 vocabulary this producer classifies every attempt into before any parsing happens: stop_valid, stop_malformed, length_limited, empty, refusal, timeout, transport — length_limited, parse_error (the diagnosis, present for every state but stop_valid), validation_issues (path-addressed, when the answer parsed but failed the contract — under --schema this includes "domain"/"range" entries from schema_output_issues, the same issue vocabulary the server's own write-time check uses), removed_items (path-addressed, on a stop_valid attempt whose accepted answer had items mechanically removed — ADR 0013; omitted when nothing was), piece_bytes and requested_max_tokens (escalation ladder only, omitted otherwise: the byte length of the split sub-piece this round asked about — sub-pieces share one chunk_index — and the max_tokens the round sent, when one was), elapsed_seconds, and a nested provider_metadata (finish_reason as received, input_tokens/output_tokens/total_tokens when the backend reports usage — null for timeout/transport, where no response ever arrived). Four transport-layer retries inside one attempt (see above) are one record, not four: the sidecar reports at the extraction level, matching what a chunk actually experienced. Field names mirror the Python LangChain SDK's event stream (ProviderMetadata, AttemptFailed) wherever the concept matches, so tooling built against one producer's diagnostics reads the other's.

One chunk record is written per chunk, before that chunk's first attempt: source, chunk_index, chunk_total, chunk_sha256 (the chunk text as sent to the model), chunk_bytes, and paragraph_first/paragraph_last (inclusive) — the range of the server's own canonical paragraph numbering (the same locator a source file's paragraph field, passage store, BM25 lane, and vector lane all already share) that chunk covers. This is a paragraph-index range, never a byte offset: chunking runs on a relabeled rendering of the segment, not the original bytes, so a byte offset into it would not resolve to anything a reader has on disk. Given (source, paragraph) and the segment's own bytes, a byte range and the verbatim text follow from one deterministic, offline call to the server's own paragraph splitter — see ADR 0003 §7. An oversized paragraph that itself straddles more than one chunk repeats its number across each one, rather than guessing at a boundary that was never byte-addressable to begin with.

One segment record is written per segment, once it lands successfully — a structured counterpart to the single human-readable summary line every run already prints: source, batch_path, associations, concepts, labels (counted separately, unlike the summary line's combined "alias(es)" figure), questions, duplicates, dropped, removed (the mechanical-validation count the summary line reports — ADR 0013), and uncovered (the coverage-gap count under --coverage — ADR 0016; 0 when the flag is off). A segment that fails never reaches this record — its absence marks exactly that, the same "absence marks incomplete" convention --diagnostics-out consumers should apply to any record kind this sidecar might add in the future.

Raw model text is never captured by default — metadata only. Chain-of-thought is never captured at all, under any setting. TAGURU_EXTRACT_DIAGNOSTICS_RAW_BYTES opts into the model's final answer text on each attempt record (the only kind that carries one at all) — the full text of every prompt and answer lives in the attempts log beside the source file instead, byte-capped at capture (the same treatment TAGURU_EXTRACT_CORRECTIVE_CONTEXT_BYTES gives a replayed bad answer above). A raw response can embed the segment's own text, so an uncapped default would leak it into a file meant for troubleshooting model behavior.

The sidecar is truncated fresh on every run — it describes this run, never a log appended across runs — so a rerun the manifest skips entirely leaves it empty, and a path inside --out risks collision with a source file or the manifest itself. Keep it outside --out. It is a diagnostic aid, never a source of truth: the manifest stays exactly what it has always been, a skip-index of successes, with no diagnostics mixed in. For watching this sidecar (or the SDK's equivalent event stream) live against a multi-hour run, see long-running ingestion.

Trace: from any source-file item back to the text and the completion that produced it

See by step for which of the pipeline's steps each record kind below reports on. Every written source file gets a sibling under <out>/.extract-trace/, named like the source file, always on, no flag (ADR 0023). Where the diagnostics sidecar describes a run, the trace describes a source: it is written atomically in the same step as the source file. It is replaced when the segment is re-extracted, left alone when the manifest skips it, and never written for a segment that fails (its checkpoint keeps what matters for the resume). It is never a manifest input — a source file written before this feature existed simply has none. It is hidden and in a subdirectory so taguru import DIR never reads it as a source file. The source file format itself is unchanged: import refuses unknown fields, so an item is identified by its content, exactly as the source file makes it unique — the association triple, the alias spelling within concept/label, the question's (paragraph, text).

The file is a tagged JSONL stream like the sidecar (type; skip types you do not know), in this order: one segment (run_id, source, segment_sha256 — the manifest's hash of the text, batch_path, chunk_total); under --redact, one redaction per match (rule, paragraph, placeholder, bytes, and preexisting when the input already carried it — never the matched text); under --chunk-context structure, one structure per unit (unit, level, heading, paragraph_first/paragraph_last); one chunk per chunk of the plan (the same chunk_index/chunk_sha256/ chunk_bytes/paragraph_first/paragraph_last the sidecar's chunk record carries), each followed under that mode by — under overview — its overview record (chunk_index, units as [{unit, summary}], cast as [{name, gloss}]) and by its chunk_context (chunk_index, the block's sha256/ bytes, position and references as unit indices, cast names, known names, and synopsis unit indices when carried, overlap_paragraphs); one piece per answer the chunk loop kept — a chunk, or each sub-piece the split rung made of one, so several pieces can share a chunk_index — with piece_id (sha256 of the piece text as sent; the checkpoint unit's key, so an unsplit chunk's piece_id equals its chunk_sha256), chunk_sha256, piece_bytes, its own paragraph range, reused (the answer came from a checkpoint rather than a completion of this run), and attempt — {run_id, attempt_seq} of the completion whose answer this piece's output is: the accepted Stage 1 answer, or the Stage 2 corrective answer when one replaced it; for a reused unit, the completion of the run that produced it, which the checkpoint now records (null only for a checkpoint written before 0.9.5). Then one item per source-file line, in file order (item: association | concept | label | question, the content key, and piece_id). Then one steering record (after segment — listed here in reading order) with what taguru itself put into the prompt, as data (ADR 0027): candidates (ADR 0014's list as offered), vocabulary ([{label, count}] in prompt order — the reuse list's own ranking and cap, computed by the same code that renders the block), context_names (--vocabulary's capped list), schema ({types, constrained_relations}; null when no schema block was prompted), system_sha256 (the system prompt actually sent, by hash — pinned or recomputed alike), and pinned_from (the run_id the system prompt was pinned from under --replay; absent from the record whenever this run computed its own); chunk_index is null — segment scope, every chunk sees the same lists today; per-chunk context (a future control) adds records with the index set. Then one loss per item the model's accepted answer held that the source file does not (ADR 0024): item (association | alias | question), reason — removed (mechanical validation, ADR 0013, including the Stage 2 alias prunes), dropped (the contract refused it as written: an empty name, a zero weight, a dangling alias, a question over the --questions cap… and, under --lossy, an array element that was not an object, dropped at parse), or duplicate (an identical item was already kept) — rule in the report's own words, path (removals only: the item's address in the answer), raw (the item exactly as the model wrote it), piece_id, attempt, paragraph, and text: the original text the item was about — the cited paragraph when the item cited a valid one, else the whole piece the model was shown. A duplicate adds kept_piece_id. Removals come first, piece by piece, then merge's drops and duplicates; the segment record's removed/dropped/ duplicates counts are these records' lengths by reason. The loss rate — lost ÷ (kept + lost) per piece, rolled up — follows from the item and loss records alone. After the losses come the segment's own side (ADR 0026): one paragraph record per canonical paragraph — paragraph, bytes, items (kept associations and questions citing it), covered, and text present exactly when nothing cites it, so the unreflected paragraphs are listable in the original and the paragraph coverage rate (count- or byte-weighted) is one fold over the records — and, under --coverage, one uncovered record per coverage gap, line for line with stderr but carrying the full sentence (stderr's quote stays byte-capped), the paragraph's text, and the owning chunk (chunk_index/chunk_sha256).

taguru anchoring OUT_DIR --json report.json judges the written source files against their own passage text — the anchoring rate (strict and alias-group) and locator validity, hallucination's mechanical floor; source files are all it needs, so pre-0.9.5 output qualifies (taguru anchoring --help). The rates alone cannot point at a fabricated name, so after the table every association that is not strictly anchored — or cites a paragraph holding neither name — is named (#864): its source-file line, subject, label, object, cited paragraph, and the reason (unanchored, alias-only, invalid locator), and every one under unanchored in the --json report with the three judgments as booleans. The first --list N prints per segment on stdout (default 3, 0 for none). A source file that cannot be parsed is named, counted under failed, and skipped rather than stopping the run (exit 1, report intact). Read the strict rate within a segment type, never across types. Strict is defined as "the subject and the object both occur, under the same folding the server resolves names with, in the cited paragraph (else the whole passage)" — exactly what the implementation checks. A segment whose subject lives only in the title and whose facts live in table rows (a product specsheet: the product name heads the segment, every row cites itself) cannot satisfy that definition for any association that cites its row, however faithful, because the row never repeats the product name — only an association that cites no paragraph (or an out-of-range one) is judged against the whole passage, where both names occur; the same model in the same run measured strict 0.68 on prose news and 0.00–0.06 on such specsheets with locator validity at 1.0 and coverage at 0.96. A low strict rate on a table-shaped segment is that structure showing through, not a hallucination floor — compare runs of the same segment type against each other, and let locator validity and the with-aliases rate carry the cross-type comparison. scripts/extract_metrics.py (python3, standard library only) folds these records — and the attempts log below — into the #784 metric tables per segment, context, group, and run (loss rates by reason, paragraph coverage, correction success, attempt-state and ladder-move counts, label concentration, graph shape, and time/token/money cost, with --compare for run-to-run deltas); --help documents the ledger and price inputs. A segment that failed leaves an attempts log and no trace. There was no source file to trace. The tables count it under failed, apart from segments, with its attempts, moves, and cost in every scope's sums and its quality columns empty — so the cost of failing is part of the run's cost, not invisible. Joins: item → piece by piece_id; piece → chunk by chunk_index or chunk_sha256; piece → the sidecar's attempt record by (run_id, attempt_seq) when that run's sidecar was kept; any record → the segment by the file itself. A duplicate triple two chunks both answered is attributed to the copy merge kept, the first — the source file holds one line, so the trace holds one item. Given a piece's paragraph range and the segment's own bytes, the text the model was shown follows from the server's paragraph splitter exactly as for the sidecar's chunk record (ADR 0003 §7). A trace that cannot be written earns one stderr line and never fails the segment. The source file and the manifest are the truth; the trace is advisory.

The attempts log: every prompt and every answer, in full

A failed segment's line names the piece and points here. Under the ladder a chunk can be split, so chunk K/N: alone points at the unit before the split; the failure line now continues with piece <12 hex> (paragraphs a–b, N B): — the innermost piece the ladder was working on, its [N] label range, its bytes — before the message, and ends with — records: <attempts log> (taguru inspect '<attempts log>' --piece <id>) — the command's path shell-quoted when it needs it — and, under --diagnostics-out, , diagnostics: <path> (ADR 0037). Each part appears exactly when its file exists: with the attempts log off the sidecar's path still shows. Paste that command to read the piece. A checkpoint file that cannot be read, or one written under other settings, is said on stderr too (ignoring an unreadable checkpoint at <path>: <error> — every unit of this segment re-extracts — an unparseable file has no unit count to give — / checkpoint at <path> was written under different settings — N unit(s) re-extract) instead of silently re-asking the model for the whole segment.

Read it with taguru inspect (ADR 0037, #850): taguru inspect <out>/.extract-trace/<source stem>.attempts.jsonl prints one line per completion in issue order — chunk, piece id, the paragraphs the piece spans, stage and attempt, state, token cap, time, tokens — with the ladder's moves (escalate, split, demote, runaway) between them and a tally at the end, plus the one command to type next: the piece that never reached a valid answer. --piece ID (the id or any prefix of it) or --paragraph N (every piece whose paragraphs cover [N]) then prints that piece's attempts with the piece text exactly as the model saw it, the corrective ask of a retry, and the answer, unescaped between --- fences — so "which paragraphs did the failed piece hold, and what came back" is two commands, not a script over the JSONL. --json renders the same rows as one segment ({target, type: "attempts", segment, runs, settings, filter, attempts, moves, unreadable_lines, matched}; the texts ride only under a filter). A torn last line (a killed run) is counted and said, never a failure.

Beside the trace, <out>/.extract-trace/<source stem>.attempts.jsonl keeps the text of every conversation the segment had with the model, by default (ADR 0025): one segment record per run over the segment (run_id, source, segment_sha256, resumed); right after it, one settings record (ADR 0031) — a diagnostic snapshot of this run's compute inputs (model, prompt_version, questions_n, fact_budget, structured_output, max_output_tokens, chunk_bytes, chunk_context, lossy, schema_digest, candidates, vocabulary_digest, and the current rung), the same field set the manifest and checkpoint fingerprint check, minus what only names the segment or never reaches the model (sha256, context, no_passage, description, escalation_factor). Never a manifest/checkpoint input itself — a hint for spotting why a later run's completions stop matching this one's, not a gate; the system prompt once, in full, as a system record (sha256, bytes, content) — it is fixed for the segment, so attempts name it by hash; and one attempt per completion, in issue order, carrying the sidecar's own fields (run_id, attempt_seq, piece_id, stage, attempt, state, finish_reason, token counts, …) plus messages — every turn as sent, the system turn as {role, system_sha256} and the rest as {role, content} in full, the replayed prior answer and corrective ask of a retry included — and answer, the model's final text in full (null for timeout/transport). Every attempt record also carries transport_retries — how many transport-layer tries (429, 5xx, transport errors) failed inside this one attempt before its outcome (0 = a clean first try; the four-retries-are-one-record ruling is unchanged, the count makes it visible). Every attempt record also carries rung — the structured-output rung it was asked under (json_schema/ json_object/prompted JSON), absent off the ladder (the legacy path). It is never part of how a later run matches this one's completions — under --structured-output auto with --parallel, which rung a piece demotes to can interleave differently run to run — but it is here to read: an operator pinning --structured-output can see what rung a recording was actually taken under, Stage 2's cross-chunk correction included (read fresh, so a demote between the chunk loop and the correction still shows up). A corrective attempt also carries corrects — the {run_id, attempt_seq} of the attempt whose answer it replays and asks to fix (Stage 1: the same piece's previous attempt; Stage 2: the accepted attempt whose output is being corrected; absent exactly when that attempt has no recorded identity — a Stage 2 correction of a unit reused from a pre-0.9.5 checkpoint, written before attempts had ids) — so the correction tuple "what was flagged → what was asked → what came back → what was adopted" is a join over records already on disk (ADR 0028): the flagged issues sit on the corrected attempt, the ask is the corrective attempt's own last turn, adoption is the trace piece record naming this attempt, and what was flagged-then-removed instead lands in the trace's loss records. A completion satisfied from a recorded conversation instead of a live call (--replay) carries replayed_from — the same {run_id, attempt_seq} shape as corrects, but naming the original attempt this one's answer came from, absent on every live completion (ADR 0031 §3.2, #823). Its own elapsed_seconds/input_tokens/output_tokens describe the replay itself — no model call made, so the time is near zero and the tokens are the original's own, restated — never the replay's real cost, which is nothing; a rollup that sums these across a file must skip any record carrying this field, or double-count every replayed completion's tokens while zeroing out its time ( scripts/extract_metrics.py already does). Nothing is capped. The ladder's own actions land beside the attempts as type: "move" records (ADR 0029): move is escalate (ADR 0019, and ADR 0032's Stage 2 resend — the reason names the cross-chunk correction; from_max_tokens/to_max_tokens), demote (ADR 0021; from_rung/to_rung), split (ADR 0001 §7 / ADR 0020; piece_bytes/split_cap/sub_pieces, the reason telling the output cap from a timeout), or runaway (ADR 0035; piece_bytes/answer_bytes, recorded at the judgment — before the demotion or failure it leads to), each with run_id, piece_id, chunk_index, and reason in the stderr line's own words — so per-segment-kind frequency and cost of every retry-machinery action is a fold over this file. It is written incrementally (a killed run keeps every completion made), kept when the segment fails — that is what it is for. On a checkpoint resume it is appended to rather than truncated, so it spans exactly the runs that built the source file. A fresh start (--force, a changed segment) truncates it. Joins: the sidecar by (run_id, attempt_seq), the trace's piece by piece_id and attempt, a loss's path into answer. Measured on the 0.9.3 field corpus it is 5–70 KiB per segment. TAGURU_EXTRACT_TRACE_ATTEMPTS=off switches it off. The diagnostics sidecar is unchanged: metadata only, TAGURU_EXTRACT_DIAGNOSTICS_RAW_BYTES still the capped opt-in — the sidecar is the file to hand to someone else. The attempts log stays with the data — it exposes no more of the underlying text than the source file and checkpoint beside it already do.

Replaying a recorded run

The mechanism below (ADR 0031) is live in this build — --replay/--replay-from, the manifest/checkpoint bypass, the replay/replay_summary records, and the system-prompt pin described below.

FlagEnvironmentMeaning
--replay {auto|strict|off} TAGURU_EXTRACT_REPLAY off (default): never consults the log. auto: a request with no matching record falls through to a live call. strict: it fails the segment instead — for a run with no model endpoint at all, see below.
--replay-from DIR TAGURU_EXTRACT_REPLAY_FROM Where to read attempts logs from (default: the same --out/.extract-trace a run already writes its own to).
--resume-from STEP — Selects a --replay mode by naming a pipeline step instead — mutually exclusive with --replay itself. See Naming a step to resume from for the exact fold.

Every completion the attempts log holds can stand in for a live model call on a later run instead of being paid for again, once --replay engages it. A request is matched by the exact conversation it would send — every turn's role and content (the system turn by its recorded hash) plus the requested output-token cap — never by piece_id or attempt number alone: a request whose actual content changed (a different validation rule, a different vocabulary offer, a different --candidates setting) is a different question, and falls through to a live call on its own rather than silently reusing a stale answer. This is what makes "change one rule, replay the rest" work without an explicit invalidation step. --replay strict needs no TAGURU_EXTRACT_URL at all (TAGURU_EXTRACT_MODEL is still required, since it is a manifest computation input); combining --structured-output auto with no endpoint configured this way is a usage error, since the ADR 0021 probe needs a live call by construction.

A replay run's own attempts log is appended to, never truncated — including under --force — so a run replaying from the very log it is about to add to never destroys its own input mid-read; the file already tolerates spanning several runs (ADR 0025), and a new run_id is simply added alongside the ones already there. Two new record kinds mark what happened, right after settings: replay (the mode and source directory, once) and, once the segment lands, replay_summary (this segment's replayed/live completion counts — a completion is always exactly one or the other, so their sum is the segment's total) — echoed as a replayed N/M completions (K live) stderr line. A settings record recorded under different settings than this run's own is named field by field on stderr (e.g. fact_budget 0 → 3) — a hint, never a gate: matching itself is still decided by the conversation.

Replay bypasses the manifest skip (a replayed completion is free either way, so the skip's reason to exist is gone) and reads neither the manifest nor the checkpoint store — a checkpoint's cached unit was parsed and mechanically validated under whichever code ran the original extraction, which for "replay after changing the validation rule" is exactly the answer replay must not silently reuse. Replaying is never a manifest or checkpoint computation input either: a replayed completion is byte-identical to its recorded original, so the source file it produces is the same function of the same inputs a live run would have produced.

The system prompt is pinned verbatim from the recorded run (ADR 0031) whenever a segment's attempts log names exactly one distinct system record — system_prompt(...) is never called for the actual value used. This is what lets replaying segment two of a multi-segment run succeed even though the label vocabulary every later segment's prompt offers depends on what earlier segments in the original run happened to settle on, not on this run's own (possibly empty, possibly different) history: pinning is decided per segment, from that segment's own recorded system text, never from this run's live accumulation. When the pinned text's hash differs from what this run would have recomputed, that is reported once on stderr (the recorded system prompt differs from this run's — replaying with the recorded one) — a hint, never a gate, alongside the trace's steering record carrying system_sha256 (the hash actually sent) and, when pinned, pinned_from (the run_id the pin was recorded under). When a segment's log names more than one distinct system record — a checkpoint-resumed segment whose log spans a run where the vocabulary differed — pinning is skipped rather than guessing which one applies, reported once on stderr, and the system prompt is recomputed as an unreplayed run's would be. What is never pinned: the segment text driving each chunk's user turn (changing it should fall through to a live call), and a corrective round's own ask (it is a genuinely new question when the rule that triggered it changed) — so a segment whose original extraction needed a corrective turn can still fail --replay strict after a validation-rule change, a permanent limit of content-addressed matching, not an oversight.

Three known limits of matching by conversation content, not oversights: (1) a segment whose original extraction needed a corrective turn can fail --replay strict after a validation-rule change, since a corrective turn's own ask is never pinned (above); (2) a segment whose log names more than one distinct system record never pins one (above) — a checkpoint-resumed segment spanning a run where the vocabulary differed; (3) rung (the structured-output rung an attempt was asked under) is never part of the matching key — under --structured-output auto with --parallel, which piece demotes to which rung can interleave differently run to run (the attempts log), so a two-machine replay's single pinned rung (below) need not match what any individual piece actually recorded; a hit is still decided purely by conversation content, never by rung.

Recording here, replaying there

Running the expensive step on one machine and the rest on another is what --replay strict without a model endpoint is for — the machine that replays needs no TAGURU_EXTRACT_URL at all; TAGURU_EXTRACT_MODEL is still required even there, since model is a manifest computation input (writing an empty one would be a lie the manifest believes on its next live run). Combining this with --structured-output auto is a usage error: the ADR 0021 probe needs a live call by construction, so pin a rung explicitly instead — the recorded attempt.rung field names the one the original run settled on. --resume-from is not a substitute here: every one of its step names except read/plan/steer folds onto --replay auto, not strict, so an auto run still needs a live endpoint for whatever falls through — use --replay strict directly for the machine with none.

  1. On the machine with model access, run taguru extract as usual — with --diagnostics-out/--force or not, the attempts log is on by default (ADR 0025).
  2. Carry three things to the machine with no model endpoint: the segment files themselves (the attempts log's messages hold piece text, not the whole segment — reconstructing it from the log alone is not attempted), --out/.extract-trace/ *.attempts.jsonl, and the --schema document if one was used.
  3. Nothing else needs to travel: the source file, the manifest, and the checkpoint directory are all either reproduced by the replay run itself or simply not read under --replay (the manifest skip, the checkpoint store).
  4. On the second machine, set TAGURU_EXTRACT_MODEL (the same model name — a different one is a different computation input) and run the same taguru extract invocation with --replay strict --replay-from DIR pointed at the carried .extract-trace directory (or place it at --out/.extract-trace, the default).

Because matching is by exact conversation content, a setting left out of sync between the two machines is self-diagnosing: it changes what would be sent, which either falls through to a live call (impossible without a URL — --replay strict fails loudly) or is reported as a settings mismatch on stderr — never a silent "used the wrong prompt".

Reading the records: tracing one item, and what to adjust

Everything above says what is written. This section is the reader's path through it (#784): start from one line of a finished source file and end with the original text, the exact prompt, and the model's full answer side by side — then, when a number looks wrong, which knob answers it. jq is enough; no taguru command is involved. Throughout, for a source file out/a.jsonl:

t=out/.extract-trace/a.jsonl               # the trace
l=out/.extract-trace/a.attempts.jsonl      # the attempts log

By step: which record shows which step's output

The pipeline names the steps; this is where each one's output actually lands today. parse and render have no record independent of the steps around them — see their rows below for what does capture them.

StepWhere its output is today
read trace segment (source/segment_sha256/ batch_path/chunk_total); attempts log segment; the manifest entry's sha256. Paragraph numbering only shows up indirectly, through paragraph records (also verify's).
structure trace structure, one per unit (none under --chunk-context off).
plan trace chunk, one per chunk; the diagnostics sidecar's own chunk record; the --dry-run per-segment line.
overview attempts log attempt records with stage: "overview" (one per chunk, plus an escalated resend when the first was cut off); trace overview, one per chunk the pass answered for; the checkpoint file's overview map and overview_digest.
annotate trace chunk_context, one per chunk that got a block, right after its chunk; the block text itself is in each attempt's messages (the user turn's preamble section).
steer trace steering — one record, one to one with the step.
prompt attempts log system (once, by hash) and each attempt's messages (every turn sent, replays included).
call attempts log attempt (answer/finish_reason/ token counts/elapsed/transport_retries/state); the diagnostics sidecar's attempt (metadata only); the move record right after it when the ladder acted.
parse no independent trace/attempts record — parse issues surface inside the corrective ask an attempt record carries, and --lossy's parse-time drops appear as trace loss (reason: "dropped"). An accepted piece's parsed output is, however, held in its checkpoint unit until the source file lands and reused on resume — not a first-class step record of its own, but not entirely absent either.
validate trace loss (reason: "removed", with path/ rule/raw/text); the corrective ask inside the next attempt; ADR 0028's corrects link.
reconcile the Stage 2 attempt (stage: "cross_chunk", corrects); the trace piece.attempt it replaces; its own prunes as trace loss (reason: "removed" — the one place this record kind is shared with validate, disambiguated only by rule's prose today).
merge trace item, one per source-file line, in file order; trace loss (reason: "dropped"/"duplicate", with kept_piece_id); the segment record's counts.
render the source file itself is the persisted artifact — trace segment.batch_path and the manifest entry point at it. Nothing computes it independently of merge's output plus the header fields, so there is no separate render-only record beyond the file.
verify trace paragraph (covered/items/ bytes, plus text when uncovered); trace uncovered under --coverage; the matching stderr lines.

No record kind gained a step field for this table — most already map to one step by name (chunk→plan, steering→steer, item→merge); the ones that serve more than one step, like loss, are already told apart by an existing field (reason) that a second identifier would only risk contradicting. This table is the correspondence; the records on disk are unchanged.

From one source-file line back to everything that produced it

1. The item. A source-file line is identified by its content, so select the trace item record with the same key — the association triple, the alias spelling, or the question's (paragraph, text):

jq -c 'select(.type=="item" and .subject=="酵母"
  and .label=="作る" and .object=="アルコール")' "$t"
# → {"type":"item","item":"association",…,"piece_id":"3f2a…"}

(An alias selects on (.item=="concept" or .item=="label") and .alias==… and .canonical==… — a concept alias and a label alias are separate namespaces, each its own item value; a question on .question and .paragraph.)

2. The piece. piece_id names the text the item came from:

p=3f2a…   # from step 1
jq -c --arg p "$p" 'select(.type=="piece" and .piece_id==$p)' "$t"
# → chunk_index, piece_bytes, paragraph_first/last, reused,
#   attempt: {run_id, attempt_seq}

attempt is the completion whose answer this piece's output is; paragraph_first/paragraph_last locate the text in the original segment by the server's canonical paragraph numbering.

3. The conversation, in full. The attempts log has every completion that asked about this piece, in issue order — the accepted one and every failed or corrected try before it. The accepted one is the record matching step 2's attempt on both run_id and attempt_seq: a resumed segment's log spans several runs, and attempt_seq restarts per run, so alone it is ambiguous:

jq -c --arg p "$p" 'select(.type=="attempt" and .piece_id==$p)
  | {run_id, attempt_seq, state, messages, answer}' "$l"
jq -r 'select(.type=="system") | .content' "$l"   # the system turn, by hash

The user turn inside messages is the exact text the model was shown — the original passage needs no separate lookup — and answer is the reply in full. state, finish_reason, token counts, and transport_retries ride on the same record.

4. The correction chain. A record carrying corrects is a corrective attempt; its target is the record with that {run_id, attempt_seq}. The whole tuple — what was flagged, what was asked, what came back, what was adopted — reads off records already in hand: the flagged issues are the corrected attempt's validation_issues, the ask is the corrective attempt's last messages turn, the reply is its answer, and adoption is the piece record from step 2 naming (or not naming) it:

jq -c 'select(.type=="attempt")
  | {run_id, attempt_seq, stage, state, corrects,
     issues: .validation_issues}' "$l"

5. The losses. What the accepted answer held that the source file does not, each with the item exactly as the model wrote it (raw) beside the original text it was about (text) — read the pair before concluding anything about why:

jq -c --arg p "$p" 'select(.type=="loss" and .piece_id==$p)
  | {reason, rule, raw, paragraph, text}' "$t"

6. The segment's side. The reverse direction — what the output does not reflect — is the paragraph records (uncited paragraphs carry their text) and, under --coverage, the uncovered sentences:

jq -c 'select(.type=="paragraph" and .covered==false)
  | {paragraph, bytes, text}' "$t"
jq -c 'select(.type=="uncovered") | {paragraph, sentence}' "$t"

What taguru itself contributed to the prompt is the trace's single steering record (candidates, reuse vocabulary with counts, context names, schema block); what the retry machinery did on its own is the attempts log's move records. For the aggregate view of all of this — loss rates, coverage, correction success, attempt states, moves, label concentration, cost — run scripts/extract_metrics.py over the same --out; anchoring rate and locator validity come from taguru anchoring.

When a number looks wrong: observation → knob

Each row pairs an observation — as the metric tables or the records spell it — with the control that answers it. The knobs are all described earlier on this page.

ObservationWhat to adjust
length_limited share high; frequent escalate moves, or split moves whose reason is the output cap Raise --max-output-tokens (the escalated resend's ceiling is TAGURU_EXTRACT_ESCALATION_FACTOR × it), or lower --chunk-bytes — less text per call means shorter answers.
timeout share high; split moves whose reason is the timeout Raise TAGURU_EXTRACT_TIMEOUT_SECS (local models often need it), or lower --chunk-bytes.
Runaway under schema-constrained decoding: pieces exhausting the ladder (length_limited at the escalated resend, or timeout) under an auto-resolved rung, and a demote move appearing run after run --structured-output json-object (or off) — pin the rung auto keeps demoting to, instead of paying the failed ladder again at the start of every run.
runaway moves: sources failing with "the answer outgrew the piece" Read the recorded answer beside its piece (walkthrough step 3) — the usual cause is fabrication on a near-empty piece, not a budget problem. If the answers turn out honest for your corpus, raise TAGURU_EXTRACT_RUNAWAY_RATIO (or 0 to disable); under an auto rung, a demote beside it says constrained decoding was looping instead.
Corrections don't take: correction success low, removed_instead high Raise TAGURU_EXTRACT_MAX_ATTEMPTS; read the corrective ask beside its answer (walkthrough step 4). TAGURU_EXTRACT_CORRECTIVE_CONTEXT_BYTES changes how much of the bad answer is replayed. An ask the model consistently misreads is a product bug — file it with those two records attached; the wording is taguru's, not yours.
One label over-reused: top-1 share high, label entropy low, and the steering record's vocabulary shows that label's count snowballing The reuse list itself steers this (it accumulates over the run in segment order; since #759 it is ranked by count and drops single-character labels). Re-extract the affected segment alone with --force — a fresh run has an empty reuse list — and compare: if the label vanishes, the offer drove it, not the segment.
Referent in another chunk: items citing a paragraph outside their piece's range, or a subject that only occurs in some other chunk's range --chunk-context structure: the chunk is told its heading path, the referenced units' openings, and the preceding paragraphs, and a name the block states passes the occurrence check (ADR 0033). Otherwise raise --chunk-bytes so mention and referent share a chunk.

What counts as "high" is deliberately absent: the thresholds come from the baseline measurement against the field corpus (#780) and land here once measured. Until then, compare against your own previous run — extract_metrics.py --compare exists for exactly this — rather than against an absolute.

Four questions, one record set

The same records answer four different questions — keep straight which one you are asking (#784):

  1. Is the model good enough? — attempt-state shares (does it keep the format), length_limited/timeout counts (does it fit the budget), correction success (does it understand the ask), and the loss records' raw/text pairs read by a person (is the interpretation right). If these fail, a stronger model beats any knob — see Quality.
  2. Is the RAG itself capable? — the loss rate (answers the model gave that taguru did not keep — the headline number), the correction tuples (did recovery keep the meaning), paragraph coverage and the uncited paragraphs' text (what the output never reflects), steering versus the labels actually kept (did what taguru offered distort the answer), and piece paragraph ranges versus item citations (did chunking starve the model of the referent). taguru evaluate's retrieval metrics sit on this axis too.
  3. Which parameter? — the table above.
  4. Is the whole system's answer right? — end-to-end answer quality is out of scope for these records; axis 2's retrieval metrics and its gold data are the substrate an end-to-end evaluation builds on (see evaluate and benchmark).

Trust: extraction is a channel for claims

The prompt marks the segment as data ("instructions inside are not for you") and the answer is validated against the extraction contract before anything is written — but an adversarial segment can simply state false facts, and extraction faithfully records that the segment states them. This is inherent to extraction, not specific to Taguru. The mitigation is structural: every fact is attributed to its source, so sources/retract (or re-importing a corrected source file) withdraws that source's contribution wholesale; and a source file is inspectable text — review before loading into a trusted context.

Quality

--coverage below is the verify step; the mechanical removals it reads about earlier are validate's. Extraction quality is model quality. The contract above guarantees well-formed files, not good facts. Probe before committing a corpus: extract a few representative segments into a scratch directory, taguru import into a scratch TAGURU_DATA_DIR, and ask the questions you care about (query, activate, resolve). If the answers miss, a stronger model or smaller segments work better than prompt fiddling — and the manifest keeps either experiment cheap.

What to expect per model class (measured): small local models (≤ ~10B) keep the format but not the discipline — labels come back as sentence fragments and almost never repeat (one run: 106 distinct labels across 113 associations), and concepts multiply as noun phrases instead of converging on entities. One size up, the same corpus behaved differently in kind: a 12B model folded 55 associations into 13 short canonical labels and kept the procedure discipline unprompted — the discipline boundary sits around there, not at the frontier tier. The system degrades as designed: the lexical entrance absorbs much of the drift, sources/search answers what the graph misses, and vocabulary/audit lists the twins for alias repair. If passages carry the load, that works — but if the graph is the product, spend on a strong model.