File size: 7,838 Bytes
b68816f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
"""Domain Context v4 - the scope-level semantic model.

Design and rationale: `docs/knowledge/KNOWLEDGE_DOMAIN_CONTEXT_V4.md`.

**One per company (`scope_id`), composed from that company's approved entries.**
This is the object the name always promised: not a description of a file, but a
model of the business an agent can reason about. v3's per-document object keeps
its fields and becomes a `DocumentBrief`, demoted to a source record.

The split that matters, and the reason this is cheap to build:

- **Derived** - `measures`, `surface_forms`, `policies`, `units`, `coverage` all
  fall out of entries the pipeline already extracts. Deterministic, checkable,
  and no prompt is involved.
- **Declared** - `identity`, `boundary`, `conventions`, `entities`, `dimensions`
  are the TBox: the model proposes and an expert approves them through the
  review loop (decided 2026-09-08). Absent until someone rules on them, which is
  why every one of those fields is Optional and the object is useful without
  them.

`catalog_binding` is deliberately **not stored** - it is resolved at read time
against whichever catalog the caller has, the same stopgap shape as
`fk_inference.py`, and self-disabling when a binding cannot be made.
"""

from __future__ import annotations

from datetime import datetime

from pydantic import BaseModel, Field


class Definition(BaseModel):
    """One document's account of a measure, with the document that said it.

    A list of these, rather than a single winning string, because when two
    documents disagree the disagreement IS the finding. Picking a winner is an
    expert judgement and the pipeline exists to put that in front of a human.
    """

    text: str
    doc_id: str
    term_id: str
    approved: bool = False


class Measure(BaseModel):
    """One thing the business counts, assembled from three entry kinds.

    A measure is the join that makes the pipeline's output usable: the glossary
    says what it *is*, the formula says how it is *computed*, and the rules say
    when that computation is *valid*. Any one alone is documentation; together
    they are something a planner can act on.
    """

    # Stable across documents: the canonical surface, normalised by the same
    # rule that clusters mentions inside one document. `term_id` is
    # document-scoped by design, which is right for review and wrong here - two
    # documents defining PA would otherwise be two measures.
    key: str
    # Every document-scoped id that contributed, for provenance back to review.
    term_ids: list[str] = Field(default_factory=list)
    name: str
    # Every way the documents write it, including the ones that disagree with
    # each other - the BUMA standard heads a section "Physical *of* Availability
    # (PA)" while its legend says "Physical Availability". Surfacing the
    # disagreement is a locked product decision, so both forms travel together.
    surface_forms: list[str] = Field(default_factory=list)
    definition: str | None = None
    # Populated only when documents DISAGREE. One entry means one account and
    # `definition` carries it; more than one means the measure is contested and
    # a consumer must not pick silently.
    definitions: list[Definition] = Field(default_factory=list)
    contested: bool = False
    source_docs: list[str] = Field(default_factory=list)
    unit: str | None = None
    grain: str | None = None
    formula_id: str | None = None
    formula_latex: str | None = None
    # What the formula consumes, by symbol. Resolved to `term_id`s where the
    # linker managed it; a bare symbol is expected and not a failure.
    inputs: list[str] = Field(default_factory=list)
    # Rules that constrain this measure - "use joint-survey production, else
    # truck count". This is the field that turns a correct calculation on the
    # wrong input into a correct answer.
    governed_by: list[str] = Field(default_factory=list)
    # Resolved at read time when a catalog is supplied. None means the measure
    # is described but not bound to any column the user actually has.
    catalog_binding: str | None = None
    mention_count: int = 0
    # False when the entry is being read ahead of expert approval (dev only).
    approved: bool = False


class DomainEntity(BaseModel):
    """A thing the business measures - a unit, a shift, a site. Declared."""

    name: str
    grain: str | None = None
    synonyms: list[str] = Field(default_factory=list)
    catalog_binding: str | None = None


class Dimension(BaseModel):
    """How measures are sliced. Declared."""

    name: str
    synonyms: list[str] = Field(default_factory=list)
    catalog_binding: str | None = None


class Unit(BaseModel):
    symbol: str
    meaning: str | None = None


class Conventions(BaseModel):
    """How the operator counts time and quantity. Declared, except `units`."""

    time_grain: str | None = None
    calendar: str | None = None
    units: list[Unit] = Field(default_factory=list)


class Identity(BaseModel):
    """What this domain is, and what it is not."""

    domain_name: str | None = None
    subdomains: list[str] = Field(default_factory=list)
    # What the domain does NOT cover. The field that makes refusal first-class:
    # the planner's honest-data-gap path fires today when the CATALOG cannot
    # answer, and should also fire when the DOMAIN cannot. "This standard
    # governs equipment availability, not cost" is a better answer than a forced
    # mapping onto a cost column that happens to exist.
    boundary: str | None = None
    # The expert's elaboration, when they add one. Free text, so it is theirs
    # to write and never derived.
    boundary_note: str | None = None


class SourceRef(BaseModel):
    """One document this context was built from."""

    doc_id: str
    title: str | None = None
    n_pages: int = 0
    approved_at: datetime | None = None


class Coverage(BaseModel):
    """How much of this domain is actually populated and reviewed.

    The honesty field. A context assembled from one 9-page standard must not
    present itself with the authority of a reviewed library, and a consumer
    should be able to weigh it accordingly.
    """

    n_documents: int = 0
    n_measures: int = 0
    n_policies: int = 0
    n_entries_total: int = 0
    n_entries_approved: int = 0
    # None when nothing has been reviewed - deliberately not 0.0, because "no
    # reviews yet" and "everything was rejected" are different situations.
    approved_ratio: float | None = None
    declared: bool = False


class Authority(BaseModel):
    sources: list[SourceRef] = Field(default_factory=list)
    as_of: datetime | None = None
    coverage: Coverage = Field(default_factory=Coverage)


class DomainContext(BaseModel):
    """The scope-level semantic model. One per company."""

    scope_id: str
    identity: Identity = Field(default_factory=Identity)
    measures: list[Measure] = Field(default_factory=list)
    entities: list[DomainEntity] = Field(default_factory=list)
    dimensions: list[Dimension] = Field(default_factory=list)
    conventions: Conventions = Field(default_factory=Conventions)
    # Domain-wide rules: approved rules governing no specific term. A rule tied
    # to a measure travels on that measure instead, so nothing is listed twice.
    policies: list[str] = Field(default_factory=list)
    authority: Authority = Field(default_factory=Authority)

    @property
    def is_empty(self) -> bool:
        """No measures and nothing declared - there is nothing to give a planner.

        Worth checking explicitly rather than letting an empty card into a
        prompt: a domain context that says nothing still costs tokens and still
        implies authority it does not have.
        """
        return not self.measures and not self.identity.domain_name