Rifqi Hafizuddin Claude Opus 5 commited on
Commit
b5de3b4
·
1 Parent(s): d19a981

[NOTICKET] feat(knowledge): DocumentBrief split + cross-document measure identity

Browse files

Two changes that belong together, both made BEFORE the authorised extraction run
so the fresh rows land in the right shape and nothing needs re-keying after.

V4-4 - THE DOCUMENT BRIEF SPLIT. The per-document object is now `DocumentBrief`,
`kind='document'`, prefix `b_`, and `domain` is reserved for the scope-level
semantic model composed in src/knowledge_domain/.

Prefix history, and it is not churn: b_ (BriefContext) -> d_ (DomainContext,
2026-09-01) -> b_ again (DocumentBrief, 2026-09-08). The middle move renamed
the object without changing what it held - a per-document card. v4 builds the
real domain context and gives d_ to it, so this one goes back to the name that
describes it. Safe only because nothing is approved against it: an id prefix
change after an expert has ruled is a migration that loses their approvals.

WHAT A DOMAIN IS THE UNIT OF (Rifqi's question, answered in the plan section 3.0).
Per SCOPE - the company - composed across all its documents and runs.

Per ingest run is wrong: a run is an operational event, not a semantic
boundary. Re-running a document would mint a new domain.

Per subdomain is wrong AT THIS TAG GRANULARITY: the single BUMA standard
already spans six subdomains, so keying on them shatters one standard into six
thin domains. Subdomain is a per-term FACET, not a partition - it survives as
a filter on the Tier-1 card, which is progressive disclosure applied twice.

Shaped as (scope_id, domain_key) with domain_key defaulting to "default", so a
company that genuinely needs separable domains later is a value change rather
than a re-key.

CROSS-DOCUMENT MEASURE IDENTITY, which that decision immediately requires.
`term_id` is document-scoped by design - right for review, wrong for a domain -
so two documents defining PA produced two measures: the duplication just fixed
INSIDE one document, reappearing ACROSS documents. Measures now merge on their
canonical surface using the SAME `AbbrevIndex.canonical_key` that clusters
mentions, so there is one normalisation rule applied at both scales.

A DISAGREEMENT IS SURFACED, NEVER RESOLVED. When two documents define a term
differently the measure keeps both accounts with their source documents named
and sets `contested`. Picking a winner is an expert judgement, and the whole
pipeline exists to put that judgement in front of a human rather than make it
for them. Agreeing documents merge silently and do not set the flag.

Everything else unions rather than overwrites: term_ids, source_docs, surface
forms, governing rules and mention counts. The shortest name wins for display,
because "PA" reads better in a card than "Physical of Availability (PA)".

Verification: ruff clean on touched paths; `import main` OK; suite 617 passed /
7 skipped (612 + 5 new cross-document merge tests).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

docs/knowledge/KNOWLEDGE_DOMAIN_CONTEXT_V4.md CHANGED
@@ -109,6 +109,40 @@ DomainContext(scope_id, version)
109
  as_of, approved_by, coverage ← see §3.3
110
  ```
111
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  ### 3.1 · `boundary` — the field that makes refusal first-class
113
 
114
  An explicit statement of what the domain does *not* cover. The planner's honest-data-gap path (Q2)
 
109
  as_of, approved_by, coverage ← see §3.3
110
  ```
111
 
112
+ ### 3.0 · What a domain IS the unit of (decided 2026-09-08)
113
+
114
+ Three candidates were on the table. **The domain is per `scope_id` — the company —
115
+ composed from every approved entry across all its documents and runs.**
116
+
117
+ | Candidate | Verdict |
118
+ |---|---|
119
+ | **Per ingest run** | ✗ A run is an operational event, not a semantic boundary. Re-running a document would mint a new domain, and two batches of one company's documents would produce two domains of the same thing. Runs are already recorded in `knowledge_extraction_runs`; the domain must be stable across them |
120
+ | **Per subdomain** | ✗ **Not at this tag granularity.** The single BUMA standard already spans six subdomains (`production`, `equipment`, `other`, `planning`, `hauling`, `loading`), so keying on them shatters one standard into six thin domains. Subdomain is a per-term **facet**, not a partition |
121
+ | **Per scope, composed across documents** | ✅ Stable, matches how a company thinks about "our knowledge", and matches the MCP product — a client connects to *their* domain |
122
+
123
+ **Subdomain tags survive as a filter, not a key.** A Tier-1 card can be narrowed to the subdomains a
124
+ question touches, which is progressive disclosure applied a second time and the natural answer to
125
+ corpus growth.
126
+
127
+ **Designed so it can subdivide later without a migration.** A company may genuinely have separable
128
+ domains eventually — mining operations and HR policy share no vocabulary. The identity is therefore
129
+ shaped as `(scope_id, domain_key)` with `domain_key` defaulting to `"default"`, so splitting is a
130
+ value change rather than a re-key.
131
+
132
+ ### 3.0b · Cross-document measure identity
133
+
134
+ `term_id` is **document-scoped by design** — that is right for review, and wrong for a domain
135
+ context. Two documents both defining PA produce two `term_id`s and therefore two measures: the exact
136
+ duplication fixed inside one document on 2026-09-08, reappearing across documents.
137
+
138
+ **Measures are therefore merged at composition time on their canonical surface**, reusing the same
139
+ `AbbrevIndex.canonical_key` that clusters mentions — one normalisation rule, applied at both scales.
140
+
141
+ **A disagreement is surfaced, never silently resolved.** When two documents define the same term
142
+ differently the measure keeps both, marked, with its sources named. Picking a winner is an expert
143
+ judgement, and the whole pipeline exists to put that judgement in front of a human rather than make
144
+ it for them.
145
+
146
  ### 3.1 · `boundary` — the field that makes refusal first-class
147
 
148
  An explicit statement of what the domain does *not* cover. The planner's honest-data-gap path (Q2)
src/knowledge_domain/compose.py CHANGED
@@ -20,12 +20,14 @@ from src.knowledge_domain.models import (
20
  Authority,
21
  Conventions,
22
  Coverage,
 
23
  DomainContext,
24
  Identity,
25
  Measure,
26
  SourceRef,
27
  Unit,
28
  )
 
29
  from src.knowledge_ingest.knowledge_store import KnowledgeStore
30
  from src.middlewares.logging import get_logger
31
 
@@ -135,7 +137,17 @@ class DomainComposer:
135
  else:
136
  policies.append(row.entity_id)
137
 
138
- measures: list[Measure] = []
 
 
 
 
 
 
 
 
 
 
139
  for row in _usable(glossary):
140
  payload = _payload(row)
141
  formula_id = payload.get("defining_formula_id")
@@ -143,12 +155,25 @@ class DomainComposer:
143
  if not any(payload.get(k) for k in _MIN_USEFUL) and not formula:
144
  continue
145
 
146
- measures.append(
147
- Measure(
148
- term_id=row.entity_id,
149
- name=payload.get("term") or row.label or row.entity_id,
 
 
 
 
 
 
 
150
  surface_forms=_surface_forms(payload),
151
- definition=payload.get("definition"),
 
 
 
 
 
 
152
  unit=(formula or {}).get("unit"),
153
  formula_id=formula_id,
154
  formula_latex=(formula or {}).get("formula_latex"),
@@ -157,12 +182,50 @@ class DomainComposer:
157
  for v in ((formula or {}).get("variables") or [])
158
  if v.get("symbol")
159
  ],
160
- governed_by=governs.get(row.entity_id, []),
161
  catalog_binding=None,
162
  mention_count=row.mention_count or 0,
163
- approved=row.entity_id in approved_ids,
164
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
166
 
167
  # Most-established first: a term the corpus keeps returning to is the one
168
  # a reader should meet first, and it is the sensible truncation order
 
20
  Authority,
21
  Conventions,
22
  Coverage,
23
+ Definition,
24
  DomainContext,
25
  Identity,
26
  Measure,
27
  SourceRef,
28
  Unit,
29
  )
30
+ from src.knowledge_extraction.cluster.normalize import AbbrevIndex, normalize
31
  from src.knowledge_ingest.knowledge_store import KnowledgeStore
32
  from src.middlewares.logging import get_logger
33
 
 
137
  else:
138
  policies.append(row.entity_id)
139
 
140
+ # `term_id` is document-scoped by design - right for review, wrong for a
141
+ # domain. Two documents defining PA would otherwise be two measures, the
142
+ # same duplication fixed inside one document on 2026-09-08 reappearing
143
+ # across documents. Merge on the canonical surface, using the SAME
144
+ # normalisation that clusters mentions: one rule at both scales.
145
+ index = AbbrevIndex([])
146
+ index.learn_inline(
147
+ form for row in _usable(glossary) for form in _surface_forms(_payload(row))
148
+ )
149
+
150
+ merged: dict[str, Measure] = {}
151
  for row in _usable(glossary):
152
  payload = _payload(row)
153
  formula_id = payload.get("defining_formula_id")
 
155
  if not any(payload.get(k) for k in _MIN_USEFUL) and not formula:
156
  continue
157
 
158
+ name = payload.get("term") or row.label or row.entity_id
159
+ key = index.canonical_key(name) or normalize(name) or row.entity_id
160
+ approved = row.entity_id in approved_ids
161
+ definition = payload.get("definition")
162
+
163
+ existing = merged.get(key)
164
+ if existing is None:
165
+ merged[key] = Measure(
166
+ key=key,
167
+ term_ids=[row.entity_id],
168
+ name=name,
169
  surface_forms=_surface_forms(payload),
170
+ definition=definition,
171
+ definitions=(
172
+ [Definition(text=definition, doc_id=row.doc_id,
173
+ term_id=row.entity_id, approved=approved)]
174
+ if definition else []
175
+ ),
176
+ source_docs=[row.doc_id],
177
  unit=(formula or {}).get("unit"),
178
  formula_id=formula_id,
179
  formula_latex=(formula or {}).get("formula_latex"),
 
182
  for v in ((formula or {}).get("variables") or [])
183
  if v.get("symbol")
184
  ],
185
+ governed_by=list(governs.get(row.entity_id, [])),
186
  catalog_binding=None,
187
  mention_count=row.mention_count or 0,
188
+ approved=approved,
189
  )
190
+ continue
191
+
192
+ # Second document, same measure. Union everything; never overwrite.
193
+ existing.term_ids.append(row.entity_id)
194
+ if row.doc_id not in existing.source_docs:
195
+ existing.source_docs.append(row.doc_id)
196
+ for form in _surface_forms(payload):
197
+ if form not in existing.surface_forms:
198
+ existing.surface_forms.append(form)
199
+ existing.governed_by.extend(
200
+ r for r in governs.get(row.entity_id, []) if r not in existing.governed_by
201
+ )
202
+ existing.mention_count += row.mention_count or 0
203
+ existing.approved = existing.approved or approved
204
+ # Prefer the shortest name - "PA" reads better in a card than
205
+ # "Physical of Availability (PA)".
206
+ if len(name) < len(existing.name):
207
+ existing.name = name
208
+ existing.formula_id = existing.formula_id or formula_id
209
+ existing.formula_latex = existing.formula_latex or (formula or {}).get(
210
+ "formula_latex"
211
  )
212
+ existing.unit = existing.unit or (formula or {}).get("unit")
213
+
214
+ if definition:
215
+ existing.definitions.append(
216
+ Definition(text=definition, doc_id=row.doc_id,
217
+ term_id=row.entity_id, approved=approved)
218
+ )
219
+ # A DISAGREEMENT is the finding, not a problem to resolve. Two
220
+ # documents saying different things about one measure is exactly
221
+ # what an expert must rule on, so both are kept and the measure
222
+ # is marked rather than one silently winning.
223
+ distinct = {d.text.strip() for d in existing.definitions}
224
+ existing.contested = len(distinct) > 1
225
+ if existing.definition is None:
226
+ existing.definition = definition
227
+
228
+ measures = list(merged.values())
229
 
230
  # Most-established first: a term the corpus keeps returning to is the one
231
  # a reader should meet first, and it is the sensible truncation order
src/knowledge_domain/models.py CHANGED
@@ -30,6 +30,20 @@ from datetime import datetime
30
  from pydantic import BaseModel, Field
31
 
32
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  class Measure(BaseModel):
34
  """One thing the business counts, assembled from three entry kinds.
35
 
@@ -39,7 +53,13 @@ class Measure(BaseModel):
39
  they are something a planner can act on.
40
  """
41
 
42
- term_id: str
 
 
 
 
 
 
43
  name: str
44
  # Every way the documents write it, including the ones that disagree with
45
  # each other - the BUMA standard heads a section "Physical *of* Availability
@@ -47,6 +67,12 @@ class Measure(BaseModel):
47
  # disagreement is a locked product decision, so both forms travel together.
48
  surface_forms: list[str] = Field(default_factory=list)
49
  definition: str | None = None
 
 
 
 
 
 
50
  unit: str | None = None
51
  grain: str | None = None
52
  formula_id: str | None = None
 
30
  from pydantic import BaseModel, Field
31
 
32
 
33
+ class Definition(BaseModel):
34
+ """One document's account of a measure, with the document that said it.
35
+
36
+ A list of these, rather than a single winning string, because when two
37
+ documents disagree the disagreement IS the finding. Picking a winner is an
38
+ expert judgement and the pipeline exists to put that in front of a human.
39
+ """
40
+
41
+ text: str
42
+ doc_id: str
43
+ term_id: str
44
+ approved: bool = False
45
+
46
+
47
  class Measure(BaseModel):
48
  """One thing the business counts, assembled from three entry kinds.
49
 
 
53
  they are something a planner can act on.
54
  """
55
 
56
+ # Stable across documents: the canonical surface, normalised by the same
57
+ # rule that clusters mentions inside one document. `term_id` is
58
+ # document-scoped by design, which is right for review and wrong here - two
59
+ # documents defining PA would otherwise be two measures.
60
+ key: str
61
+ # Every document-scoped id that contributed, for provenance back to review.
62
+ term_ids: list[str] = Field(default_factory=list)
63
  name: str
64
  # Every way the documents write it, including the ones that disagree with
65
  # each other - the BUMA standard heads a section "Physical *of* Availability
 
67
  # disagreement is a locked product decision, so both forms travel together.
68
  surface_forms: list[str] = Field(default_factory=list)
69
  definition: str | None = None
70
+ # Populated only when documents DISAGREE. One entry means one account and
71
+ # `definition` carries it; more than one means the measure is contested and
72
+ # a consumer must not pick silently.
73
+ definitions: list[Definition] = Field(default_factory=list)
74
+ contested: bool = False
75
+ source_docs: list[str] = Field(default_factory=list)
76
  unit: str | None = None
77
  grain: str | None = None
78
  formula_id: str | None = None
src/knowledge_extraction/__init__.py CHANGED
@@ -25,7 +25,7 @@ from .models import (
25
  CallUsage,
26
  Chunk,
27
  ClusterResult,
28
- DomainContext,
29
  FilterResult,
30
  FormulaEntry,
31
  GlossaryEntry,
@@ -41,7 +41,7 @@ from .service import ExtractionResult, build_clusters, estimate_cost, extract_al
41
 
42
  __all__ = [
43
  "AbbrevPair",
44
- "DomainContext",
45
  "CallUsage",
46
  "Chunk",
47
  "ClusterResult",
 
25
  CallUsage,
26
  Chunk,
27
  ClusterResult,
28
+ DocumentBrief,
29
  FilterResult,
30
  FormulaEntry,
31
  GlossaryEntry,
 
41
 
42
  __all__ = [
43
  "AbbrevPair",
44
+ "DocumentBrief",
45
  "CallUsage",
46
  "Chunk",
47
  "ClusterResult",
src/knowledge_extraction/cli.py CHANGED
@@ -224,8 +224,10 @@ def main(argv: list[str] | None = None) -> int:
224
  _dump(args.out_dir / "formulas.json", result.formulas)
225
  _dump(args.out_dir / "review_queue.json", result.review_queue)
226
  _dump(args.out_dir / "rejected.json", [r.model_dump(mode="json") for r in result.rejected])
227
- if result.domain:
228
- _dump(args.out_dir / "domain_context.json", result.domain)
 
 
229
  _dump(args.out_dir / "usage.json", [u.model_dump(mode="json") for u in result.usages])
230
  print(f"[write ] {args.out_dir}")
231
  return 0
 
224
  _dump(args.out_dir / "formulas.json", result.formulas)
225
  _dump(args.out_dir / "review_queue.json", result.review_queue)
226
  _dump(args.out_dir / "rejected.json", [r.model_dump(mode="json") for r in result.rejected])
227
+ if result.brief:
228
+ # Named for what it holds: a per-document brief. The scope-level
229
+ # DomainContext is composed elsewhere (src/knowledge_domain/).
230
+ _dump(args.out_dir / "document_brief.json", result.brief)
231
  _dump(args.out_dir / "usage.json", [u.model_dump(mode="json") for u in result.usages])
232
  print(f"[write ] {args.out_dir}")
233
  return 0
src/knowledge_extraction/extract/branches.py CHANGED
@@ -13,11 +13,11 @@ response must not abort a corpus-scale run that has already paid for parsing.
13
  from __future__ import annotations
14
 
15
  from ...middlewares.logging import get_logger
16
- from ..ids import domain_id, formula_id, rule_id, term_id
17
  from ..models import (
18
  CallUsage,
19
  Chunk,
20
- DomainContext,
21
  FormulaEntry,
22
  FormulaVariable,
23
  GlossaryEntry,
@@ -208,7 +208,7 @@ def extract_formula(
208
 
209
  def extract_summary(
210
  chunks: list[Chunk], extractor, doc_id: str
211
- ) -> tuple[DomainContext | None, CallUsage]:
212
  """Whole-document summary — the quiet cost risk. Few calls, but a large
213
  share of all input tokens, because summarisation cannot be filtered: it
214
  needs the whole document.
@@ -228,8 +228,8 @@ def extract_summary(
228
  return None, result.usage
229
 
230
  return (
231
- DomainContext(
232
- domain_id=domain_id(doc_id),
233
  title=draft.title,
234
  purpose_verbatim=draft.purpose_verbatim,
235
  # `outline` is derived from the chunks by the caller, not asked of
 
13
  from __future__ import annotations
14
 
15
  from ...middlewares.logging import get_logger
16
+ from ..ids import brief_id, formula_id, rule_id, term_id
17
  from ..models import (
18
  CallUsage,
19
  Chunk,
20
+ DocumentBrief,
21
  FormulaEntry,
22
  FormulaVariable,
23
  GlossaryEntry,
 
208
 
209
  def extract_summary(
210
  chunks: list[Chunk], extractor, doc_id: str
211
+ ) -> tuple[DocumentBrief | None, CallUsage]:
212
  """Whole-document summary — the quiet cost risk. Few calls, but a large
213
  share of all input tokens, because summarisation cannot be filtered: it
214
  needs the whole document.
 
228
  return None, result.usage
229
 
230
  return (
231
+ DocumentBrief(
232
+ brief_id=brief_id(doc_id),
233
  title=draft.title,
234
  purpose_verbatim=draft.purpose_verbatim,
235
  # `outline` is derived from the chunks by the caller, not asked of
src/knowledge_extraction/ids.py CHANGED
@@ -71,13 +71,17 @@ def rule_id(doc_id: str, chunk_id: str, char_start: int) -> str:
71
  return f"r_{_digest(doc_id, chunk_id, str(char_start))}"
72
 
73
 
74
- def domain_id(doc_id: str) -> str:
75
- """One domain-context entry per document, so the document identifies it.
76
-
77
- Prefix moved `b_` → `d_` with the `BriefContext` → `DomainContext` rename
78
- (2026-09-01). Free to do then because nothing was persisted yet; once an
79
- expert has approved rows keyed on these, changing a prefix is an id
80
- migration that loses the approvals — the same failure the whole scheme
81
- exists to prevent.
 
 
 
 
82
  """
83
- return f"d_{_digest(doc_id)}"
 
71
  return f"r_{_digest(doc_id, chunk_id, str(char_start))}"
72
 
73
 
74
+ def brief_id(doc_id: str) -> str:
75
+ """Identity of one document's brief, so the document identifies it.
76
+
77
+ Prefix history, and it is not churn: `b_` (BriefContext) -> `d_`
78
+ (DomainContext, 2026-09-01) -> `b_` again (DocumentBrief, 2026-09-08). The
79
+ middle move renamed the object without changing what it held - a per-document
80
+ card. v4 builds the real scope-level `DomainContext` and gives `d_` to it, so
81
+ this one goes back to the name and prefix that describe it.
82
+
83
+ Safe to move only because nothing is approved against it: an id prefix change
84
+ after an expert has ruled is a migration that loses their approvals, which is
85
+ the exact failure this whole scheme exists to prevent.
86
  """
87
+ return f"b_{_digest(doc_id)}"
src/knowledge_extraction/models.py CHANGED
@@ -92,7 +92,7 @@ class Chunk(BaseModel):
92
  # headings yields none, and fixtures written before this crossed the seam
93
  # carry nothing.
94
  #
95
- # The `DomainContext.outline` is derived from these, which is the whole
96
  # reason they now cross: the document's own structure is the one piece of
97
  # domain knowledge that needs no model at all, and extraction was dropping
98
  # it on the floor at the adapter — the same way it once dropped `latex`.
@@ -355,7 +355,7 @@ class KeyParameter(BaseModel):
355
  term_id: str | None = None
356
 
357
 
358
- class DomainContext(BaseModel):
359
  """Whole-document domain context. The only branch that cannot be
360
  span-checked — a plausible summary is indistinguishable from a correct one,
361
  which is why it belongs on the larger model tier when one is available.
@@ -373,7 +373,7 @@ class DomainContext(BaseModel):
373
  and `scope` overlapped `purpose`. What remains is what a reviewer needs to
374
  orient before working the queue."""
375
 
376
- domain_id: str
377
  title: str | None = None
378
 
379
  # The document's OWN statement of purpose, quoted verbatim - not a summary
 
92
  # headings yields none, and fixtures written before this crossed the seam
93
  # carry nothing.
94
  #
95
+ # The `DocumentBrief.outline` is derived from these, which is the whole
96
  # reason they now cross: the document's own structure is the one piece of
97
  # domain knowledge that needs no model at all, and extraction was dropping
98
  # it on the floor at the adapter — the same way it once dropped `latex`.
 
355
  term_id: str | None = None
356
 
357
 
358
+ class DocumentBrief(BaseModel):
359
  """Whole-document domain context. The only branch that cannot be
360
  span-checked — a plausible summary is indistinguishable from a correct one,
361
  which is why it belongs on the larger model tier when one is available.
 
373
  and `scope` overlapped `purpose`. What remains is what a reviewer needs to
374
  orient before working the queue."""
375
 
376
+ brief_id: str
377
  title: str | None = None
378
 
379
  # The document's OWN statement of purpose, quoted verbatim - not a summary
src/knowledge_extraction/queue/review_queue.py CHANGED
@@ -94,7 +94,7 @@ def _key_parameter_rows(domain: dict | None) -> list[dict]:
94
  # The decision attaches to the domain entry plus the surface —
95
  # there is no term_id, which is the entire point of the row.
96
  "term_id": None,
97
- "domain_id": (domain or {}).get("domain_id"),
98
  "term": parameter.get("surface"),
99
  "definition": None,
100
  "source_wording": None,
 
94
  # The decision attaches to the domain entry plus the surface —
95
  # there is no term_id, which is the entire point of the row.
96
  "term_id": None,
97
+ "brief_id": (domain or {}).get("brief_id"),
98
  "term": parameter.get("surface"),
99
  "definition": None,
100
  "source_wording": None,
src/knowledge_extraction/service.py CHANGED
@@ -53,7 +53,7 @@ class ExtractionResult:
53
  self.glossary: list[dict] = []
54
  self.rules: list[dict] = []
55
  self.formulas: list[dict] = []
56
- self.domain: dict | None = None
57
  self.review_queue: list[dict] = []
58
  self.rejected: list[RejectedField] = []
59
  self.usages: list[CallUsage] = []
@@ -131,7 +131,7 @@ def extract_all(
131
 
132
  # Links resolve only once every branch has produced entries — a reference
133
  # cannot be made to something that does not exist yet.
134
- out.links = link_all(out.glossary, out.rules, out.formulas, clustered, out.domain)
135
 
136
  out.glossary = diff_glossary(out.glossary, active_glossary or [])
137
 
@@ -142,7 +142,7 @@ def extract_all(
142
  # property anything guarantees.
143
  _finalise_domain(out)
144
 
145
- out.review_queue = build_queue(out.glossary, out.domain)
146
 
147
  prompt, cached, completion = out.total_tokens
148
  logger.info(
@@ -263,7 +263,7 @@ def _finalise_domain(out: ExtractionResult) -> None:
263
  run, or may have failed schema validation, and the other three artifacts
264
  stand on their own.
265
  """
266
- if out.domain is None:
267
  return
268
 
269
  counts: dict[str, int] = {}
@@ -271,12 +271,12 @@ def _finalise_domain(out: ExtractionResult) -> None:
271
  for tag in entry.get("subdomain_tags") or []:
272
  counts[tag] = counts.get(tag, 0) + 1
273
 
274
- out.domain["subdomains"] = [
275
  tag for tag, _ in sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))
276
  ]
277
- out.domain["n_terms"] = len(out.glossary)
278
- out.domain["n_formulas"] = len(out.formulas)
279
- out.domain["n_rules"] = len(out.rules)
280
 
281
 
282
  def _document_outline(chunks) -> list[str]:
@@ -320,7 +320,7 @@ def _run_summary(doc, extractor, out) -> None:
320
  # The haystack is the WHOLE document: unlike the other branches this one is
321
  # given every chunk, so a quote may legitimately come from anywhere in it.
322
  source = evidence_text([c.chunk_id for c in doc.chunks], doc.chunks)
323
- entry, rejections = validate_entry(entry, "summary", source, entry.domain_id)
324
  out.rejected.extend(rejections)
325
 
326
- out.domain = entry.model_dump(mode="json")
 
53
  self.glossary: list[dict] = []
54
  self.rules: list[dict] = []
55
  self.formulas: list[dict] = []
56
+ self.brief: dict | None = None
57
  self.review_queue: list[dict] = []
58
  self.rejected: list[RejectedField] = []
59
  self.usages: list[CallUsage] = []
 
131
 
132
  # Links resolve only once every branch has produced entries — a reference
133
  # cannot be made to something that does not exist yet.
134
+ out.links = link_all(out.glossary, out.rules, out.formulas, clustered, out.brief)
135
 
136
  out.glossary = diff_glossary(out.glossary, active_glossary or [])
137
 
 
142
  # property anything guarantees.
143
  _finalise_domain(out)
144
 
145
+ out.review_queue = build_queue(out.glossary, out.brief)
146
 
147
  prompt, cached, completion = out.total_tokens
148
  logger.info(
 
263
  run, or may have failed schema validation, and the other three artifacts
264
  stand on their own.
265
  """
266
+ if out.brief is None:
267
  return
268
 
269
  counts: dict[str, int] = {}
 
271
  for tag in entry.get("subdomain_tags") or []:
272
  counts[tag] = counts.get(tag, 0) + 1
273
 
274
+ out.brief["subdomains"] = [
275
  tag for tag, _ in sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))
276
  ]
277
+ out.brief["n_terms"] = len(out.glossary)
278
+ out.brief["n_formulas"] = len(out.formulas)
279
+ out.brief["n_rules"] = len(out.rules)
280
 
281
 
282
  def _document_outline(chunks) -> list[str]:
 
320
  # The haystack is the WHOLE document: unlike the other branches this one is
321
  # given every chunk, so a quote may legitimately come from anywhere in it.
322
  source = evidence_text([c.chunk_id for c in doc.chunks], doc.chunks)
323
+ entry, rejections = validate_entry(entry, "summary", source, entry.brief_id)
324
  out.rejected.extend(rejections)
325
 
326
+ out.brief = entry.model_dump(mode="json")
src/knowledge_ingest/knowledge_store.py CHANGED
@@ -54,7 +54,9 @@ _ENTRY_KINDS: dict[str, tuple[str, str]] = {
54
  "glossary": ("term_id", "term"),
55
  "rule": ("rule_id", "statement"),
56
  "formula": ("formula_id", "name"),
57
- "domain": ("domain_id", "title"),
 
 
58
  }
59
 
60
 
 
54
  "glossary": ("term_id", "term"),
55
  "rule": ("rule_id", "statement"),
56
  "formula": ("formula_id", "name"),
57
+ # The per-document brief. `domain` is reserved for the scope-level
58
+ # semantic model composed in `src/knowledge_domain/` (v4).
59
+ "document": ("brief_id", "title"),
60
  }
61
 
62
 
src/knowledge_ingest/pipeline.py CHANGED
@@ -68,9 +68,9 @@ _EXECUTOR = ThreadPoolExecutor(max_workers=1, thread_name_prefix="knowledge")
68
  # the run row so a later comparison knows what was actually ATTEMPTED rather
69
  # than inferring it from what happened to appear.
70
  #
71
- # Note "summary" is the branch that produces the DomainContext - the branch kept
72
  # its original name through the v3 rename, while the entity and the entry `kind`
73
- # are both "domain". Passing "domain" here silently extracts nothing.
74
  BRANCHES = ("glossary", "rule", "formula", "summary")
75
 
76
 
@@ -270,7 +270,7 @@ class KnowledgePipeline:
270
  "glossary": out.glossary,
271
  "rule": out.rules,
272
  "formula": out.formulas,
273
- "domain": [out.domain] if out.domain else [],
274
  }
275
  n = await self._store.save_entries(
276
  run_id=run.id,
 
68
  # the run row so a later comparison knows what was actually ATTEMPTED rather
69
  # than inferring it from what happened to appear.
70
  #
71
+ # Note "summary" is the branch that produces the DocumentBrief - the branch kept
72
  # its original name through the v3 rename, while the entity and the entry `kind`
73
+ # are both "document". Passing "document" here silently extracts nothing.
74
  BRANCHES = ("glossary", "rule", "formula", "summary")
75
 
76
 
 
270
  "glossary": out.glossary,
271
  "rule": out.rules,
272
  "formula": out.formulas,
273
+ "document": [out.brief] if out.brief else [],
274
  }
275
  n = await self._store.save_entries(
276
  run_id=run.id,