Skip to content

Commit 2fc45b9

Browse files
authored
chore(submodules): pin ontology-management-base to gx 2.2.1 + range-inference fix (#144)
* chore(submodules): pin ontology-management-base to gx 2.2.1 + range-inference fix Advances the OMB pin over its PR #83 squash: gx artifacts refreshed to service-characteristics v2.2.1 (adds gx:ComplianceCredential; removes gx:ServiceEntity, gx:spiffeId, gx:trustDomain, gx:operatedBy) and the upstream validators' rdfs:range entailment now covers blank-node objects. Full validate ran green against these artifacts (11 typecheck + 19 test tasks): schema graph loads, the term index builds with the new gx term set, retrieval/fragment/reference suites pass, prompt-size bounds hold. Nothing here referenced the removed gx terms — the retrieval surface is discovery-driven by design. Signed-off-by: jdsika <carlo.van-driesten@vdl.digital> * fix(ontology,llm): adapt discovery, validation, and prompt bounds to the OMB refresh The OMB pin (eb49b92 -> 0dc1a42) advances 32 upstream commits, restructuring the artifacts well beyond the gx 2.2.1 bump: ISO-34503 ODD hierarchy, openlabel-v2 v1-v2 gap closure, and hdmap constraint reshaping. Three downstream assumptions broke -- two surfaced in CI; the third (llm) was masked because turbo cancels siblings once the ontology task fails first. - domain-registry mis-selected openlabel-v2's primary asset class. The new ISO 34503 ODD annotation shapes (OddDynamicElements, OddEnvironment, OddScenery) are declared AHEAD of Scenario, and "first-declared non-sub-component" is order-fragile. Select the composition ROOT instead -- the target class whose shape references the most other target classes -- an order-independent, ontology-name-free signal (computeCompositionScores). Scenario composes five sub-shapes; the standalone ODD shapes compose zero. [SHACL 2.3] - shacl-validator sh:in happy path. hdmap roadTypes' enum moved onto the version-conditional SEQUENCE path ( hasContent roadTypes ); sequence/inverse paths are intentionally out of scope for bare-value slot validation, so the enum test is re-pinned to hdmap:trafficDirection (a simple-path sh:in still in the validator's remit). - prompt-composer boundedness. Enlarged shapes plus new cross-domain references lift the retrieved tail ~3x (~61k chars, still an order of magnitude under a ~300k whole-file dump). Raise the calibration thresholds; boundedness -- not a specific size -- stays pinned. Adds submodule-independent regression tests for composition-root selection. Full `pnpm run validate` green on the new pin (19 tasks). Signed-off-by: Carlo van Driesten <carlo.van-driesten@bmw.de> --------- Signed-off-by: jdsika <carlo.van-driesten@vdl.digital> Signed-off-by: Carlo van Driesten <carlo.van-driesten@bmw.de>
1 parent 79efc1f commit 2fc45b9

6 files changed

Lines changed: 250 additions & 19 deletions

File tree

packages/llm/src/__tests__/prompt-composer.test.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -121,12 +121,16 @@ describe('buildRequestPrompt (integration)', () => {
121121

122122
const core = buildStaticCore()
123123
expect(prompt.startsWith(core)).toBe(true)
124-
// Measured on the shipped 22-domain ontology: tail ≈ 21k chars (default
125-
// budgets: 40 property fragments + full catalog), total ≈ 40k. The
126-
// thresholds carry headroom over the measurement so an ontology edit
127-
// doesn't flake the suite; boundedness is what's pinned.
128-
expect(tail.length).toBeLessThan(30_000)
129-
expect(prompt.length).toBeLessThan(50_000)
124+
// Measured on the pinned OMB ontology: this card-derived query routes to
125+
// `environment-model` plus its transitive references (`hdmap`, `ositrace`),
126+
// ~69 fragments, tail ≈ 61k / total ≈ 80k. The service-characteristics /
127+
// ISO-34503 refresh enlarged individual shapes and added cross-domain
128+
// references, so the tail is ~3× the pre-refresh measurement — still an
129+
// order of magnitude under a whole-file SHACL dump (~300k). The thresholds
130+
// keep headroom over the measurement so a further ontology edit doesn't
131+
// flake the suite; BOUNDEDNESS (not a specific size) is what's pinned.
132+
expect(tail.length).toBeLessThan(80_000)
133+
expect(prompt.length).toBeLessThan(100_000)
130134
expect(retrieved.confidence).toBeGreaterThan(0)
131135
expect(retrieved.domains).toContain(card!.domain)
132136
})

packages/ontology/src/__tests__/domain-registry-parse.test.ts

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,15 @@
1616
*/
1717
import { describe, expect, it } from 'vitest'
1818

19-
import { extractNamespace, extractTargetClasses, parseTtl } from '../domain-registry-parse.js'
19+
import {
20+
computeCompositionScores,
21+
extractCompositionAdjacency,
22+
extractNamespace,
23+
extractShapeTargets,
24+
extractTargetClasses,
25+
parseTtl,
26+
selectPrimaryAssetClass,
27+
} from '../domain-registry-parse.js'
2028

2129
const NS = 'http://example.org/v1/'
2230

@@ -105,3 +113,62 @@ scenario:X a sh:NodeShape ; sh:targetClass scenario:X .`
105113
expect(extractNamespace(parseTtl(ttl), 'nomatch')).toBeNull()
106114
})
107115
})
116+
117+
/**
118+
* Regression: a domain's primary asset class must be its composition ROOT (the
119+
* shape aggregating the domain's sub-shapes), NOT merely the first-declared
120+
* target class. This defends against SHACL declaration-order changes upstream —
121+
* e.g. ISO-34503 ODD annotation shapes (`OddDynamicElements`, …) that
122+
* `openlabel-v2` now declares AHEAD of `Scenario`. Before composition scoring,
123+
* `selectPrimaryAssetClass` returned the first-declared `Leaf` here; it must now
124+
* return `Root`, which references `Leaf` and thus scores higher.
125+
*/
126+
describe('domain-registry-parse — composition-root primary selection', () => {
127+
// `Leaf` is declared first but composes nothing; `Root` is declared second and
128+
// references `Leaf` via a property shape (sh:node). Neither is a sub-component.
129+
const ttl = `@prefix sh: <http://www.w3.org/ns/shacl#> .
130+
@prefix ex: <${NS}> .
131+
132+
ex:Leaf a sh:NodeShape ; sh:targetClass ex:Leaf ;
133+
sh:property [ sh:path ex:label ; sh:minCount 1 ] .
134+
135+
ex:Root a sh:NodeShape ; sh:targetClass ex:Root ;
136+
sh:property [ sh:path ex:hasLeaf ; sh:node ex:Leaf ] .`
137+
138+
function scoresFor(parsed: ReturnType<typeof parseTtl>) {
139+
const targets = extractTargetClasses(parsed, NS)
140+
return computeCompositionScores(
141+
extractCompositionAdjacency(parsed.quads),
142+
extractShapeTargets(parsed.quads),
143+
new Set(targets.map((t) => t.iri))
144+
)
145+
}
146+
147+
it('scores the aggregating shape above the leaf it composes', () => {
148+
const scores = scoresFor(parseTtl(ttl))
149+
expect(scores.get(`${NS}Root`)).toBe(1)
150+
expect(scores.get(`${NS}Leaf`) ?? 0).toBe(0)
151+
})
152+
153+
it('selects the composition root even though the leaf is declared first', () => {
154+
const parsed = parseTtl(ttl)
155+
const targets = extractTargetClasses(parsed, NS)
156+
expect(targets.map((t) => t.localName)).toEqual(['Leaf', 'Root'])
157+
158+
const primary = selectPrimaryAssetClass(
159+
targets,
160+
'somedomain',
161+
new Set(),
162+
new Map(),
163+
scoresFor(parsed)
164+
)
165+
expect(primary?.localName).toBe('Root')
166+
})
167+
168+
it('without scores, falls back to first-declared (documents prior behavior)', () => {
169+
const parsed = parseTtl(ttl)
170+
const targets = extractTargetClasses(parsed, NS)
171+
const primary = selectPrimaryAssetClass(targets, 'somedomain', new Set(), new Map())
172+
expect(primary?.localName).toBe('Leaf')
173+
})
174+
})

packages/ontology/src/__tests__/shacl-validator.test.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,13 @@ import { ShaclValidator } from '../shacl-validator.js'
1818

1919
const GEOREF_COUNTRY = 'https://w3id.org/ascs-ev/envited-x/georeference/v5/country'
2020
const HDMAP_ROAD_TYPES = 'https://w3id.org/ascs-ev/envited-x/hdmap/v6/roadTypes'
21+
// A simple-path `sh:in` enum ("left-hand"/"right-hand"). `roadTypes` also has an
22+
// `sh:in`, but upstream now expresses it on the version-conditional SEQUENCE path
23+
// `( hdmap:hasContent hdmap:roadTypes )`; sequence/inverse paths are deliberately
24+
// out of scope for bare-value slot validation (see shacl-validator-loader.ts),
25+
// so the enum happy-path is pinned to a property still constrained on a simple
26+
// path — the validator capability under test.
27+
const HDMAP_TRAFFIC_DIRECTION = 'https://w3id.org/ascs-ev/envited-x/hdmap/v6/trafficDirection'
2128

2229
describe('ShaclValidator', () => {
2330
it('builds from the workspace ontology and exposes covered properties', async () => {
@@ -52,7 +59,7 @@ describe('ShaclValidator', () => {
5259

5360
it('rejects a value outside an sh:in enumeration', async () => {
5461
const validator = await ShaclValidator.fromWorkspace()
55-
const result = await validator.validateValue(HDMAP_ROAD_TYPES, 'spaceway')
62+
const result = await validator.validateValue(HDMAP_TRAFFIC_DIRECTION, 'diagonal')
5663

5764
expect(result.conforms).toBe(false)
5865
const constraints = result.violations.map((v) => v.sourceConstraintComponent)
@@ -61,7 +68,7 @@ describe('ShaclValidator', () => {
6168

6269
it('accepts a value inside an sh:in enumeration', async () => {
6370
const validator = await ShaclValidator.fromWorkspace()
64-
const result = await validator.validateValue(HDMAP_ROAD_TYPES, 'motorway')
71+
const result = await validator.validateValue(HDMAP_TRAFFIC_DIRECTION, 'left-hand')
6572

6673
expect(result.conforms).toBe(true)
6774
expect(result.violations).toEqual([])

packages/ontology/src/domain-registry-parse.ts

Lines changed: 136 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,27 @@ const OWL_ONTOLOGY = `${RDF_PREFIXES.owl}Ontology`
2121
const RDFS_SUBCLASS_OF = `${RDF_PREFIXES.rdfs}subClassOf`
2222
const SH_TARGET_CLASS = `${RDF_PREFIXES.sh}targetClass`
2323

24+
// Composition-graph predicates: a NodeShape "composes" the shapes and classes
25+
// it references through its property shapes. Following these edges — the direct
26+
// structural links (sh:property, sh:node, sh:class, sh:qualifiedValueShape), the
27+
// logical combinators (sh:and/sh:or/sh:xone/sh:not), and the rdf:first/rdf:rest
28+
// list plumbing those combinators use — reconstructs which shapes nest which
29+
// [SHACL §2.3]. Used to pick a domain's primary asset class as the composition
30+
// ROOT (the shape aggregating the domain's sub-shapes) rather than by SHACL
31+
// declaration order.
32+
const COMPOSITION_PREDICATES: ReadonlySet<string> = new Set([
33+
`${RDF_PREFIXES.sh}property`,
34+
`${RDF_PREFIXES.sh}node`,
35+
`${RDF_PREFIXES.sh}class`,
36+
`${RDF_PREFIXES.sh}qualifiedValueShape`,
37+
`${RDF_PREFIXES.sh}and`,
38+
`${RDF_PREFIXES.sh}or`,
39+
`${RDF_PREFIXES.sh}xone`,
40+
`${RDF_PREFIXES.sh}not`,
41+
`${RDF_PREFIXES.rdf}first`,
42+
`${RDF_PREFIXES.rdf}rest`,
43+
])
44+
2445
/** A parsed Turtle document: resolved quads plus its declared prefix map. */
2546
export interface ParsedTtl {
2647
quads: Quad[]
@@ -255,22 +276,114 @@ export function computeComponentBases(
255276
return bases
256277
}
257278

279+
/**
280+
* Build the composition adjacency for a set of quads: subject term value →
281+
* object term values, for every edge whose predicate continues a
282+
* shape-composition traversal ({@link COMPOSITION_PREDICATES}). Blank-node
283+
* identifiers are kept so nested property shapes and `sh:or`/`sh:xone`
284+
* alternative lists are walked; the caller resolves reached nodes back to
285+
* target classes [SHACL §2.3].
286+
*/
287+
export function extractCompositionAdjacency(quads: Quad[]): Map<string, string[]> {
288+
const adjacency = new Map<string, string[]>()
289+
for (const q of quads) {
290+
if (!COMPOSITION_PREDICATES.has(q.predicate.value)) continue
291+
const list = adjacency.get(q.subject.value)
292+
if (list) list.push(q.object.value)
293+
else adjacency.set(q.subject.value, [q.object.value])
294+
}
295+
return adjacency
296+
}
297+
298+
/**
299+
* Map every shape subject to the target class(es) it declares via
300+
* `sh:targetClass` [SHACL §2.1.3.3]. A shape may target several classes and a
301+
* class may be targeted by several shapes, so values are arrays. Lets a
302+
* composition-reachable shape node be resolved back to the asset class(es) it
303+
* stands for.
304+
*/
305+
export function extractShapeTargets(quads: Quad[]): Map<string, string[]> {
306+
const map = new Map<string, string[]>()
307+
for (const q of quads) {
308+
if (q.predicate.value !== SH_TARGET_CLASS || q.object.termType !== 'NamedNode') continue
309+
const list = map.get(q.subject.value)
310+
if (list) list.push(q.object.value)
311+
else map.set(q.subject.value, [q.object.value])
312+
}
313+
return map
314+
}
315+
316+
/**
317+
* Score each target class by how many OTHER target classes its shape composes
318+
* (references transitively through the composition graph). A domain's primary
319+
* asset is the composition ROOT — the shape that aggregates the domain's
320+
* sub-shapes — which scores highest; standalone leaf/annotation shapes score
321+
* zero. This makes primary-asset selection independent of SHACL declaration
322+
* order (a shape reordered or newly inserted upstream cannot displace the
323+
* asset). Pure over its inputs (adjacency + shape→target map + the set of all
324+
* target-class IRIs), so it is unit-testable without I/O.
325+
*/
326+
export function computeCompositionScores(
327+
adjacency: Map<string, string[]>,
328+
shapeTargets: Map<string, string[]>,
329+
targetClassIris: ReadonlySet<string>
330+
): Map<string, number> {
331+
const reachedByTarget = new Map<string, Set<string>>()
332+
333+
for (const [subject, targets] of shapeTargets) {
334+
// Depth-first walk of the composition graph from this shape subject,
335+
// collecting reachable target classes — either a target-class IRI reached
336+
// directly (sh:class) or a shape node that itself declares a target class
337+
// (sh:node / sh:qualifiedValueShape). A path-agnostic `seen` set bounds the
338+
// walk on cyclic shape graphs.
339+
const reached = new Set<string>()
340+
const seen = new Set<string>([subject])
341+
const stack = [...(adjacency.get(subject) ?? [])]
342+
while (stack.length) {
343+
const node = stack.pop()!
344+
if (seen.has(node)) continue
345+
seen.add(node)
346+
if (targetClassIris.has(node)) reached.add(node)
347+
for (const t of shapeTargets.get(node) ?? []) reached.add(t)
348+
for (const next of adjacency.get(node) ?? []) stack.push(next)
349+
}
350+
for (const target of targets) {
351+
const set = reachedByTarget.get(target) ?? new Set<string>()
352+
for (const r of reached) if (r !== target) set.add(r)
353+
reachedByTarget.set(target, set)
354+
}
355+
}
356+
357+
const scores = new Map<string, number>()
358+
for (const [target, set] of reachedByTarget) scores.set(target, set.size)
359+
return scores
360+
}
361+
258362
/**
259363
* Select a domain's primary asset class. PascalCase-of-domain match wins
260-
* first, otherwise the first declared target class that is not a sub-component
261-
* — with "sub-component" derived structurally from
364+
* first, otherwise the non-sub-component target class that composes the most
365+
* of the domain's other shapes — with "sub-component" derived structurally from
262366
* {@link computeComponentBases} instead of a hard-coded name list. A class is
263367
* a sub-component when it is, or transitively subclasses, a component-base; an
264368
* asset class (e.g. `<prefix>:<Class>`) subclasses an asset base instead, so
265-
* it survives the filter even when not named after its domain. `targetClasses`
266-
* keeps its SHACL declaration order so the
267-
* first-non-sub-component fallback is deterministic and matches prior behavior.
369+
* it survives the filter even when not named after its domain.
370+
*
371+
* Among the surviving candidates the composition ROOT wins: the class whose
372+
* shape references the most other target classes ({@link computeCompositionScores}),
373+
* i.e. the top of the domain's containment tree. This is order-independent — a
374+
* standalone annotation shape declared before the asset (as upstream ISO-34503
375+
* ODD shapes now are, ahead of `Scenario`) cannot displace it. `targetClasses`
376+
* keeps its SHACL declaration order, so ties — including the all-zero case where
377+
* no candidate composes another target class — fall back to the first-declared
378+
* candidate, matching prior behavior. When no scores are supplied the selection
379+
* is exactly the previous first-non-sub-component rule.
268380
*/
269381
export function selectPrimaryAssetClass(
270382
targetClasses: { localName: string; iri: string }[],
271383
domainName: string,
272384
componentBases: Set<string>,
273-
superOf: Map<string, Set<string>>
385+
superOf: Map<string, Set<string>>,
386+
compositionScore?: ReadonlyMap<string, number>
274387
): { localName: string; iri: string } | null {
275388
if (targetClasses.length === 0) return null
276389

@@ -293,6 +406,21 @@ export function selectPrimaryAssetClass(
293406
const exact = targetClasses.find((tc) => tc.localName === domainPascalCase(domainName))
294407
if (exact) return exact
295408

296-
// Otherwise the first declared non-sub-component target class.
297-
return targetClasses.find((tc) => !isSubComponent(tc.iri)) ?? targetClasses[0] ?? null
409+
// Otherwise the composition root among non-sub-components (falling back to all
410+
// target classes if every candidate is a sub-component). Iterating in SHACL
411+
// declaration order and replacing only on a STRICTLY higher score keeps the
412+
// first-declared winner on ties and when no scores disambiguate.
413+
const candidates = targetClasses.filter((tc) => !isSubComponent(tc.iri))
414+
const pool = candidates.length > 0 ? candidates : targetClasses
415+
let best = pool[0]!
416+
let bestScore = compositionScore?.get(best.iri) ?? 0
417+
for (let i = 1; i < pool.length; i++) {
418+
const tc = pool[i]!
419+
const score = compositionScore?.get(tc.iri) ?? 0
420+
if (score > bestScore) {
421+
best = tc
422+
bestScore = score
423+
}
424+
}
425+
return best
298426
}

packages/ontology/src/domain-registry.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,16 @@
1313
*/
1414
import { OntologySourcesError } from '@ontology-search/core/errors'
1515
import { RDF_PREFIXES } from '@ontology-search/core/rdf/prefixes'
16+
import type { Quad } from '@rdfjs/types'
1617
import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
1718
import { join } from 'path'
1819

1920
import {
2021
computeComponentBases,
22+
computeCompositionScores,
23+
extractCompositionAdjacency,
2124
extractNamespace,
25+
extractShapeTargets,
2226
extractSubClassOfEdges,
2327
extractTargetClasses,
2428
extractVersion,
@@ -119,6 +123,8 @@ export async function buildDomainRegistry(): Promise<DomainRegistry> {
119123
filePrefixes: Record<string, string>
120124
targetClasses: { localName: string; iri: string }[]
121125
subClassEdges: { sub: string; super: string }[]
126+
/** Parsed SHACL quads — retained so pass 2 can score composition roots. */
127+
shaclQuads: Quad[]
122128
}
123129
const raw: RawDomain[] = []
124130

@@ -194,6 +200,7 @@ export async function buildDomainRegistry(): Promise<DomainRegistry> {
194200
filePrefixes,
195201
targetClasses,
196202
subClassEdges,
203+
shaclQuads: shacl.quads,
197204
})
198205
}
199206
}
@@ -211,8 +218,26 @@ export async function buildDomainRegistry(): Promise<DomainRegistry> {
211218
}
212219
const componentBases = computeComponentBases(allEdges, targetClassDomain)
213220

221+
// Composition scores are global (an asset may reference another domain's
222+
// shapes) and structural — derived from the whole shapes graph, independent of
223+
// per-file declaration order. `selectPrimaryAssetClass` uses them to prefer a
224+
// domain's composition root over a standalone shape that merely happens to be
225+
// declared first.
226+
const allShaclQuads = raw.flatMap((d) => d.shaclQuads)
227+
const compositionScore = computeCompositionScores(
228+
extractCompositionAdjacency(allShaclQuads),
229+
extractShapeTargets(allShaclQuads),
230+
new Set(targetClassDomain.keys())
231+
)
232+
214233
for (const d of raw) {
215-
const primaryClass = selectPrimaryAssetClass(d.targetClasses, d.entry, componentBases, superOf)
234+
const primaryClass = selectPrimaryAssetClass(
235+
d.targetClasses,
236+
d.entry,
237+
componentBases,
238+
superOf,
239+
compositionScore
240+
)
216241
if (!primaryClass) continue
217242

218243
domains.set(d.entry, {

0 commit comments

Comments
 (0)