Rifqi Hafizuddin Claude Opus 5 commited on
Commit
1160b03
·
1 Parent(s): ae0e456

[NOTICKET] fix(knowledge-domain): a domain entry was being read as a document brief

Browse files

Found 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>

Files changed (1) hide show
  1. src/knowledge_domain/compose.py +46 -11
src/knowledge_domain/compose.py CHANGED
@@ -93,14 +93,23 @@ class DomainComposer:
93
  rules = await self._store.list_entries(
94
  scope_id=scope_id, kind="rule", limit=1000
95
  )
96
- # v3 still writes the per-document brief under kind="domain"; v4 splits
97
- # it to kind="document" (V4-4). Read both so composition works either
98
- # side of that change.
 
 
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
- total = len(glossary) + len(formulas) + len(rules) + len(briefs)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
262
  context = DomainContext(
263
  scope_id=scope_id,
264
- # Derived only. `domain_name` and `boundary` are declared and stay
265
- # empty until an expert rules on them.
266
- identity=Identity(subdomains=subdomains),
267
  measures=measures,
268
- conventions=Conventions(units=units),
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=False,
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
  )