[NOTICKET] fix(knowledge-domain): a domain entry was being read as a document brief
Browse filesFound by Rifqi asking the obvious question of the fresh run: the entries are
`document: 1, glossary: 66, rule: 16, formula: 5` - where is the domain?
The answer is that nothing writes one yet, and that is correct: the SCOPE-level
DomainContext is composed at read time, not stored, because a persisted derived
view drifts from what it was derived from - the same reason the review queue is
not stored. What IS missing is the DECLARED half (identity, boundary,
conventions, entities, dimensions), which is expert-authored and therefore must
be an entry to be reviewable, since knowledge_reviews keys on entity_id. That is
what `kind='domain'` is reserved for, and V4-9/V4-10 have not been built.
THE BUG the question surfaced: `compose.py` read `kind='domain'` rows and
treated them as document briefs. That line was transitional, written to handle
v3 rows before the V4-4 split, and after the split it is wrong - the first
declared domain entry ever written would have been silently folded into
`authority.sources[]` as if it were a file, taking its subdomains and title with
it. Briefs are now read as `kind='document'` only.
The declared seam is wired at the same time rather than left for later, because
a reserved kind nothing reads is how the same confusion comes back: a `domain`
entry, once approved, now supplies domain_name, boundary and conventions, and
sets `coverage.declared`. Declared subdomains beat the tags rolled up from
briefs - an expert saying what the domain covers beats an aggregate of per-term
guesses. Nothing writes one yet, so this is normally inert.
The TBox goes through the same review gate as everything else: an unapproved
declaration is ignored in strict mode.
A test caught the semantic change, which is what tests are for: the fixture
still used kind="domain" for a brief and started failing the moment the two
kinds stopped meaning the same thing. Updated, plus 3 new tests - a domain entry
is not a source, the declared half is applied when present, and an unapproved
declaration is ignored.
Live re-check after the fix: 5 measures, sources ['144bb3e9'] (the PDF only),
declared=False, subdomains still rolled up from the brief.
Verification: ruff clean; `import main` OK; suite 638 passed / 7 skipped
(635 + 3).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- src/knowledge_domain/compose.py +46 -11
|
@@ -93,14 +93,23 @@ class DomainComposer:
|
|
| 93 |
rules = await self._store.list_entries(
|
| 94 |
scope_id=scope_id, kind="rule", limit=1000
|
| 95 |
)
|
| 96 |
-
#
|
| 97 |
-
#
|
| 98 |
-
#
|
|
|
|
|
|
|
| 99 |
briefs = await self._store.list_entries(
|
| 100 |
-
scope_id=scope_id, kind="domain", limit=200
|
| 101 |
-
) + await self._store.list_entries(
|
| 102 |
scope_id=scope_id, kind="document", limit=200
|
| 103 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
|
| 105 |
reviews = await self._store.latest_reviews(scope_id)
|
| 106 |
approved_ids = {
|
|
@@ -258,14 +267,40 @@ class DomainComposer:
|
|
| 258 |
)
|
| 259 |
)
|
| 260 |
|
| 261 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 262 |
context = DomainContext(
|
| 263 |
scope_id=scope_id,
|
| 264 |
-
# Derived
|
| 265 |
-
#
|
| 266 |
-
identity=
|
| 267 |
measures=measures,
|
| 268 |
-
conventions=
|
| 269 |
policies=policies,
|
| 270 |
authority=Authority(
|
| 271 |
sources=sources,
|
|
@@ -279,7 +314,7 @@ class DomainComposer:
|
|
| 279 |
approved_ratio=(
|
| 280 |
(len(approved_ids) / total) if (total and reviews) else None
|
| 281 |
),
|
| 282 |
-
declared=
|
| 283 |
),
|
| 284 |
),
|
| 285 |
)
|
|
|
|
| 93 |
rules = await self._store.list_entries(
|
| 94 |
scope_id=scope_id, kind="rule", limit=1000
|
| 95 |
)
|
| 96 |
+
# The per-document brief. Since the V4-4 split this is kind="document";
|
| 97 |
+
# kind="domain" now means something else entirely and must NOT be read
|
| 98 |
+
# here. Reading both was a transitional line from before the split, and
|
| 99 |
+
# leaving it would have made the first declared domain entry ever
|
| 100 |
+
# written get folded into `authority.sources[]` as if it were a file.
|
| 101 |
briefs = await self._store.list_entries(
|
|
|
|
|
|
|
| 102 |
scope_id=scope_id, kind="document", limit=200
|
| 103 |
)
|
| 104 |
+
# The DECLARED half - identity, boundary, conventions, entities,
|
| 105 |
+
# dimensions. Expert-authored (the model proposes, the expert approves),
|
| 106 |
+
# so it must be an entry to be reviewable: `knowledge_reviews` keys on
|
| 107 |
+
# `entity_id`. Nothing writes one yet (V4-9/V4-10), so this is normally
|
| 108 |
+
# empty - but the seam is here so that when one lands it is applied
|
| 109 |
+
# instead of silently ignored.
|
| 110 |
+
declared_rows = await self._store.list_entries(
|
| 111 |
+
scope_id=scope_id, kind="domain", limit=5
|
| 112 |
+
)
|
| 113 |
|
| 114 |
reviews = await self._store.latest_reviews(scope_id)
|
| 115 |
approved_ids = {
|
|
|
|
| 267 |
)
|
| 268 |
)
|
| 269 |
|
| 270 |
+
# Apply the declared half when an expert has ruled on one.
|
| 271 |
+
declared = next(
|
| 272 |
+
(_payload(r) for r in declared_rows if include_unapproved
|
| 273 |
+
or r.entity_id in approved_ids),
|
| 274 |
+
None,
|
| 275 |
+
)
|
| 276 |
+
identity = Identity(subdomains=subdomains)
|
| 277 |
+
conventions = Conventions(units=units)
|
| 278 |
+
if declared:
|
| 279 |
+
identity = Identity(
|
| 280 |
+
domain_name=declared.get("domain_name"),
|
| 281 |
+
# Declared subdomains win over the tags rolled up from briefs:
|
| 282 |
+
# an expert saying what the domain covers beats an aggregate of
|
| 283 |
+
# per-term guesses.
|
| 284 |
+
subdomains=list(declared.get("subdomains") or subdomains),
|
| 285 |
+
boundary=declared.get("boundary"),
|
| 286 |
+
)
|
| 287 |
+
conv = declared.get("conventions") or {}
|
| 288 |
+
conventions = Conventions(
|
| 289 |
+
time_grain=conv.get("time_grain"),
|
| 290 |
+
calendar=conv.get("calendar"),
|
| 291 |
+
units=units or [Unit(**u) for u in (conv.get("units") or [])],
|
| 292 |
+
)
|
| 293 |
+
|
| 294 |
+
total = len(glossary) + len(formulas) + len(rules) + len(briefs) + len(
|
| 295 |
+
declared_rows
|
| 296 |
+
)
|
| 297 |
context = DomainContext(
|
| 298 |
scope_id=scope_id,
|
| 299 |
+
# Derived, overlaid with the declared half when an expert has
|
| 300 |
+
# approved one. `domain_name` and `boundary` stay empty until then.
|
| 301 |
+
identity=identity,
|
| 302 |
measures=measures,
|
| 303 |
+
conventions=conventions,
|
| 304 |
policies=policies,
|
| 305 |
authority=Authority(
|
| 306 |
sources=sources,
|
|
|
|
| 314 |
approved_ratio=(
|
| 315 |
(len(approved_ids) / total) if (total and reviews) else None
|
| 316 |
),
|
| 317 |
+
declared=declared is not None,
|
| 318 |
),
|
| 319 |
),
|
| 320 |
)
|