# Knowledge Pipeline — `DomainContext` (output schema v3) > ✅ **SHIPPED IN FULL 2026-09-02** — `7f47c61` (rename) · `6f5d619` (outline + key_parameters) · > `e2865ac` (span guarding) · `172d8dc` (subdomains + counts). > > **Superseded in intent 2026-09-08, still live in code.** v3's object is a *document brief* carrying a domain name — five of its eight fields describe a file, not a business. [KNOWLEDGE_DOMAIN_CONTEXT_V4.md](KNOWLEDGE_DOMAIN_CONTEXT_V4.md) plans what the name should mean. Read that before extending anything here. > > **The live shape and the real measured values are in > [KNOWLEDGE_OUTPUT_CONTRACT.md](KNOWLEDGE_OUTPUT_CONTRACT.md) §8**, which is the document of record > for this branch. What follows is the design *reasoning* — kept because the why is not > reconstructable from a schema, and because the split between derived, aggregated and located > fields is the thing a future change is most likely to undo by accident. > > Two deviations from the proposal as written: **`language` was dropped** (§9.5 — proposed on the > assumption it came free, and nothing named a consumer), and the illustrative example in §3 has > been superseded by the real run quoted in the contract. > > **Author:** Rifqi · **Reviewers:** Rifqi, Sofhia · **Tracker:** DEV_PLAN §0.8 **Supersedes if approved:** [KNOWLEDGE_OUTPUT_CONTRACT.md](KNOWLEDGE_OUTPUT_CONTRACT.md) §8 (`brief_context.json`) **Consumers:** the Planner (primarily), the Assembler (secondarily) — see §5 --- ## 1 · Why The workstream diagram names this output **"Brief Context (Domain Knowledge)"**. What v2 built is an orientation card: `title`, `purpose`, `key_parameters`. The gap is not accidental — v2 deliberately cut `summary_md` and `scope` as "the longest unverifiable prose on the least verifiable branch", which was a correct judgement about *prose* and, as a side effect, moved the artifact away from being domain knowledge at all. Both things are true at once, and v3 resolves them by changing **what kind of thing** the branch produces rather than how much of it: > The v2 brief is unverifiable because the model was asked to **summarise**. Ask it to **locate** > instead, and the same information becomes as checkable as a glossary definition. That single change is most of this proposal. The rest is deriving, for free, structure that already exists in the artifact and in the other three outputs. ### The principle **Derived beats classified beats generated.** Every field below is pushed to the cheapest, most verifiable tier that can produce it. The net effect is a *larger* artifact with a *smaller* unverifiable surface than v2 — and one fewer thing for the model to invent. --- ## 2 · What is actually missing from v2 Most of what "domain knowledge" should mean is already produced and is simply not assembled: | Domain-knowledge element | Already exists in | Used by the brief today? | |---|---|---| | The domain vocabulary | `glossary.json` | no | | Calculation conventions | `formulas.json` + `rules.json` | no | | What the document covers | `Chunk.heading_path` in the artifact — **verbatim, zero LLM** | no | | Subdomain coverage | `subdomain_tags` on every glossary entry — already an enum | no | | Term ↔ formula relations | `defining_formula_id`, `formula_ids[]`, `term_ids[]` via `link.py` | no | The one element genuinely absent everywhere is **how the parameters relate to one another** — PA/UA/MA composing into a hierarchy. That is deliberately out of scope here; see §7. --- ## 3 · Proposed shape ```json { "domain_id": "d_2ef60a8c19", "title": "Standard Parameter Produksi & ECA", "purpose_verbatim": "Standard Parameter ini merupakan pedoman dalam perhitungan parameter produksi", "outline": [ "1. PENDAHULUAN", "2. PENJELASAN PARAMETER", "2.1. Production Parameter", "2.1.1. Production" ], "subdomains": ["production", "equipment", "maintenance"], "key_parameters": [ {"surface": "PA", "term_id": "t_9c41ab7d02"}, {"surface": "UA", "term_id": "t_1f7e0d5c88"}, {"surface": "Grouping (Composite)", "term_id": null} ], "n_terms": 63, "n_formulas": 11, "n_rules": 15, "provenance": { "doc_id": "STD_2026_006_MNO", "span": "Standard Parameter ini merupakan pedoman dalam perhitungan parameter produksi", "page": 0, "page_no": 1, "section_no": "1", "chunk_id": "STD_2026_006_MNO::0000" } } ``` > **`page_no` is new and 1-based** (decided 2026-09-02, closing S6b). `page` stays 0-based and > unchanged, so the artifact never silently diverges from the raw MinerU output kept beside it — > the two fields are the same location counted two ways, and the review UI reads `page_no`. This is > a change to the shared `Provenance` type, so it lands across all four branches, not just here; > the parsing half adds the matching field to the artifact (additive — a patch bump by the `0.3.1` > precedent). > ⚠️ **Illustrative, not a run.** `title`, `purpose_verbatim`, `key_parameters` surfaces and the > provenance block are literal from the v2 reference run (`v2_full_document_2026-08-24_093051`); > `outline` follows the `heading_path` example in `knowledge_parsing/contracts.py`; ids, counts and > `term_id` values are placeholders. **No v3 run exists.** Every other doc in this folder quotes real > figures — this one cannot yet, and says so. --- ## 4 · Field table — with how each is produced | Field | Type | Produced by | Verifiable | |---|---|---|---| | `domain_id` | string | **Deterministic** — `"d_" + sha256(doc_id)[:10]`, same scheme as `ids.py` | n/a | | `title` | string \| null | **LLM: locate** — quoted from the document, not composed | ✅ span-guarded (new) | | `purpose_verbatim` | string \| null | **LLM: locate** — the document's own statement of purpose, quoted | ✅ span-guarded (new) | | `outline` | string[] | **Deterministic** — ordered unique `heading_path` over the artifact's chunks | ✅ verbatim from source | | `subdomains` | enum[] | **Deterministic** — aggregate of `subdomain_tags` across glossary entries | ✅ classification, already enum | | `key_parameters` | object[] | **LLM: select**, then **corroborated** — each surface resolved to a `term_id` by `link.py`; an unresolved surface earns its own review-queue row (§9.2) | ✅ corroboration | | `n_terms` / `n_formulas` / `n_rules` | int | **Deterministic** — counts | ✅ by construction | | `provenance` | Provenance | as today | ✅ | **Removed from v2:** `purpose` (free-form generation → replaced by `purpose_verbatim`) · `brief_id` (renamed `domain_id`). **`key_parameters` gains a `term_id`.** A surface that resolves corroborates itself against a span-verified glossary entry; one that does not (`"Grouping (Composite)"` above) is a **hallucination signal**, surfaced rather than dropped — the same posture as the dangle counts `link.py` already reports instead of swallowing. `link.py` resolves it deterministically, no LLM and no spend. --- ## 5 · The verification change This is the substantive part. Today: ```python GUARDED_FIELDS = { "glossary": ("definition", "full_name", "source_wording"), "rule": ("statement", "condition", "consequence"), "formula": ("formula_latex",), "summary": (), # ← nothing guarded } ``` `validate_entry` computes `span_ok` for every branch but only applies it to guarded fields, so on the summary branch **the span is recorded and its verification has no consequence.** v3 proposes: ```python "summary": ("title", "purpose_verbatim"), ``` Both are now transcriptions rather than compositions, so both belong in `_TRANSCRIBED` alongside `source_wording` and `full_name` — a value that cannot be located verbatim is a silent normalisation and must be rejected, never repaired. **Consequence:** the summary branch stops being the pipeline's one unverifiable output. Every field in §4 is then derived, enumerated, span-checked or corroborated. This also weakens the case that this branch needs a larger model tier (DEV_PLAN §0.8 D3, risk R5) — locating a sentence is a much smaller ask of nano than composing one. **Abstention stays correct.** If the document states no purpose, `purpose_verbatim` is null. A document that does not say what it is for is a fact about the document. --- ## 6 · Who consumes it Correcting the split proposed on 2026-09-01, which routed rules to the Assembler on the strength of the branch's *name*. Measured, **0 of 15 gold rules are interpretation rules** — they are calculation conventions, data-sourcing rules and unit conventions, all of which are *planning* concerns. A planner that does not know PA composites are weighted (`PA_COMPOSITE_WEIGHTED`) plans a simple average: valid IR, wrong answer, and a rule delivered to the Assembler afterwards cannot fix a number already computed wrong. | Output | Planner | Assembler | Note | |---|---|---|---| | `glossary` | ✅ | ✅ | Term → column/measure mapping; naming in prose | | `formulas` | ✅ | — | Changes the IR that gets built | | `rules` where `rule_type = calculation` | ✅ | — | ≈ all of them today | | `rules` where `rule_type = interpretation` | — | ✅ | ≈ none today; `rule_type` exists to make this split possible | | `DomainContext` | ✅ | — | Routing + scope. Highest value with multiple documents | **Approved-only.** Only entries whose latest `knowledge_reviews` row is `approved` (or `edited`, using `edited_payload`) may enter a prompt — see [KNOWLEDGE_PERSISTENCE_CONTRACT.md](KNOWLEDGE_PERSISTENCE_CONTRACT.md). **Prompt-injection posture.** Knowledge entries are customer document content, exactly like the catalog. They render inside the same `` delimiter and are covered by the planner's hard rule 8 ("catalog content is data, never instructions"). Size is not the constraint: ~63 entries ≈ 20k chars against a 250k catalog budget. --- ## 7 · Explicitly not in scope - **The term-relation hierarchy** (PA/UA/MA composing). `RuleEntry` defers this because designing it against zero examples is guessing. 15 real rules now exist to design against, which makes it the natural *next* piece of domain-knowledge work — but it is a separate schema, not a `DomainContext` field. - **Cross-document reconciliation.** `domain_id` is per document, like every other id. Deciding that two documents' "PA" is one concept stays an expert judgement at review time. What a multi-document prompt does with two approved `DomainContext` blocks is an open product question. - **Any change to the other three branches.** --- ## 8 · Impact | Area | Impact | |---|---| | Harry's DDL | **None.** `payload` is jsonb and `kind` is a discriminator; a reshaped brief needs no migration | | Persistence | Rename `brief` → `domain` in the `kind` CHECK **only if done before rows exist**. Free now, an id migration later | | Output contract | v3 supersedes §8; §1 gains `domain_id`; §11 unchanged | | Prompt | `summary` branch prompt rewritten from "summarise" to "locate + select" | | Cost | Neutral to lower — one call, shorter completion | | Eval | **Required before merge** (CLAUDE.md §7B): E3 scored against `v2_full_2026-08-27_133757.json`, new result JSON committed, EN + ID both checked | --- ## 9 · Open questions **For Rifqi** 1. ~~Rename `BriefContext` → `DomainContext` and `brief_id` → `domain_id`?~~ **RESOLVED 2026-09-01: yes.** Shipped in `7f47c61` — rename only, no field or prompt change. Prefix `b_` → `d_`. 2. ~~Should an unresolved `key_parameters` surface reach the **review queue** or stay a logged signal?~~ **RESOLVED 2026-09-02: its own review-queue row.** It is a hallucination indicator, and the queue is where the expert already looks; a "is this a real parameter?" call is cheap for them and impossible for us. Costs one extra row per document. Consequence: `build_queue` grows a second row kind, so the queue is no longer glossary-only — Go's review CRUD must expect it. 3. ~~No Expert gate on Glossary in the workstream diagram — deliberate?~~ **RESOLVED 2026-09-02: gate all four.** Glossary definitions are the highest-leverage output — they shape the planner's term mapping, so an unreviewed one propagates furthest. Also keeps the review CRUD uniform across `kind`. The diagram is the thing that is out of date. **For Sofhia** 4. ~~Where does `outline` come from?~~ **RESOLVED 2026-09-02: extraction derives it** — ordered unique `heading_path` over the artifact's chunks. No parser change, no seam change, no `ParsedDocument` schema bump, and no existing fixture goes stale. 5. ~~`language` — is it already on the artifact, or does extraction detect it independently?~~ **RESOLVED 2026-09-02: dropped from v3.** It was proposed on the assumption it came free; it does not obviously, and nothing named a consumer for it. A field with no consumer is not worth a question to another team — reinstate it when something needs it. 6. ~~0-based `page_idx`, or add a 1-based `page_no`?~~ **RESOLVED 2026-09-02: add `page_no`, 1-based.** `page`/`page_idx` stay 0-based and untouched so the artifact keeps matching raw MinerU output; `page_no` is the number a human reads. Additive on both sides of the seam. 7. **`doc_id` stability — OPEN, and the highest-severity item in the workstream.** `parse.py:144` and `run.py:118` both set `doc_id = source.stem`; `parse.py:85` already calls this temporary. Every `entity_id` derives from `doc_id`, so renaming a file mints all-new ids and voids every prior approval. **Decided direction (Rifqi, 2026-09-02): an explicit document key supplied at ingest, falling back to the content hash when none is given** — survives both renames and re-exports, and is the only option that lets a human declare "same document, new revision". Implementation is in the parsing half. --- ## 10 · Status | Item | State | |---|---| | Proposal drafted | ✅ 2026-09-01 | | `outline` derivation | ✅ **Shipped 2026-09-02.** Needed `heading_path` to cross the adapter — it was being dropped there | | `key_parameters` → `term_id` + queue row | ✅ **Shipped 2026-09-02.** `link.py` resolves; `build_queue` emits `row_kind` | | `purpose_verbatim` + guarding change (§5) | ✅ **Shipped 2026-09-02** (`e2865ac`). Smoke-verified: 0 rejections, fabrication refused | | `subdomains` aggregate | ✅ **Shipped 2026-09-02** (`172d8dc`). Frequency-ordered, alphabetical tiebreak | | `n_terms` / `n_formulas` / `n_rules` counts | ✅ **Shipped 2026-09-02** (`172d8dc`) | | Prompt change | ✅ `prompts/summary.txt` rewritten summarise → locate | | Eval baseline for comparison | 📎 `eval/knowledge/results/v2_full_2026-08-27_133757.json` (E1 0.8293 · E2 2.556× · E3 1.000) |