Skip to content

feat(search)!: generate extraction CONSTRUCTs from the search schema#630

Open
ddeboer wants to merge 3 commits into
mainfrom
feat/extraction-generator
Open

feat(search)!: generate extraction CONSTRUCTs from the search schema#630
ddeboer wants to merge 3 commits into
mainfrom
feat/extraction-generator

Conversation

@ddeboer

@ddeboer ddeboer commented Jul 21, 2026

Copy link
Copy Markdown
Member

Implements #548.

Retires the hand-written extraction CONSTRUCTs by generating extraction from the search schema, and closes the loop the projection was already reaching for: the reader and the projection now agree on their intermediate vocabulary by construction, so they cannot drift.

What changed

@lde/search (breaking) – projection reads the IR Alias, not the source path

  • Adds irAlias(searchType, field)urn:lde:‹Type›/‹field› beside physicalFields in the adapter surface: the one home for the minted-predicate convention both the reader and the projection must agree on.
  • The projection now reads node[irAlias(type, field)] instead of node[field.path]. path becomes purely source-side (the reader adapter’s grammar); the declaring type is threaded down, so a root field mints against the root type and an inline referent’s field against its reference type. A subject that is a root of two types no longer collides on a shared field name.

@lde/search-pipeline (breaking) – the Extraction generator

  • extractionQuery / extractionQueryString: a pure SearchType → QueryConstruct generator built with Traqula’s AstFactory (never sparqljs), stringified with the Traqula Generator. It mints:
    • a template of one ?root <irAlias> ?value triple per path-bearing field – IRIs only, single subject (a property path cannot be a CONSTRUCT verb, so the flattened value is minted onto its subject);
    • a WHERE of one UNION branch per field (?root <path> ?value), each field’s path lifted into a literal SPARQL property path (sequence / alternative / inverse) – UNION, never a conjunction, so a multi-valued field does not cross-multiply;
    • a free ?root subject for the pipeline’s existing injectValues (VALUES ?root { … }) – root selection stays a separate, deployment concern (selectByClass / the stage’s itemSelector);
    • for an inline reference, one CONSTRUCT with a nested template ({ ?root <…/ref> ?r . ?r <…/field> ?v }), recursing to the schema’s declared depth.
  • SearchStageType.readers is now optional: a SPARQL stage defaults to a SparqlConstructReader running the generated Extraction CONSTRUCT, so the query no longer has to be hand-passed.
  • A schema↔CONSTRUCT contract test (the drift guard Close the derive hole: make path the whole statement of what the projection reads #607 was a precondition for): the alias set the generator mints for a type equals the field set the projection reads for it.

Verification

  • Synthetic, in CI. Unit tests for the generator (template, UNION-per-field WHERE, property-path lifting, nested inline template), the projection reading aliases, and the contract test. An end-to-end round-trip against a local @lde/local-sparql-endpoint proves generate → read → frame → project on a Drapo-shaped CreativeWork / Person schema – including the default (schema-generated) reader wiring. The Drapo dump never enters CI.
  • Drapo dump, report-only (not a CI gate). The full N-Quads dump (https://data.drapo.nl/dataset/schemaorg.export.nq, ~20 MB, 112 008 quads) was loaded into a local Comunica endpoint and the generated CONSTRUCTs run over the real roots (given-roots + UNION-per-field), matching what searchStages produces:
    • CreativeWork – 1 073 distinct roots → 2 269 triples (name 1 073, description 814, creator 382), raw == distinct: zero duplicates.
    • Person – 158 distinct roots → 158 label triples, raw == distinct: zero duplicates.
    • No CONSTRUCT-dedup blow-up. The only duplication observed came from the source typing 114 Person subjects a schema:Person more than once: a non-DISTINCT selector returns each such root twice, so given-roots repeat and the output triples repeat 1:1 with the duplicate roots – linear, not the multiplicative explosion the issue warns about (a template that projects away WHERE variables, e.g. a constant ?s a <Type>, which this generator never emits). The pipeline collapses it regardless: projectRoots dedups roots via new Set, and buildSubjectIndex dedups triples. Re-running Person over the 158 distinct roots gives 158 raw = 158 distinct, confirming the template is duplicate-free per root.
    • Inline references were not exercised at scale. The current consumer (Drapo / Linked Open Limburg) is labelOnly with single-predicate paths – no inline references, no property paths. The nested-template ?s → ?r link and its QLever-dedup risk are therefore covered by synthetic fixtures only; an at-scale check awaits a consumer that inlines (e.g. the Dataset Register). The generator builds the full mechanism regardless, so the contract test covers the whole model.

Out of scope / for your eyes

ddeboer added 3 commits July 21, 2026 20:01
- Add irAlias(searchType, field) → urn:lde:‹Type›/‹field›, the minted
  extraction predicate both the reader and the projection agree on, beside
  physicalFields in the adapter surface.
- Flip the projection to read node[irAlias(type, field)] instead of
  node[field.path]: path becomes purely source-side (the reader adapter’s
  grammar), threading the declaring type down so a root field mints against
  the root type and an inline referent’s field against its reference type.
- Update the projection and framing test fixtures to key framed nodes by the
  minted alias rather than the source predicate.

BREAKING CHANGE: the projection now reads each value under its field’s IR
alias, not its source path; a reader must emit values under irAlias(type,
field). path no longer keys the framed node.
…h schema

- Add extractionQuery / extractionQueryString: a pure SearchType →
  QueryConstruct generator (Traqula AstFactory) that mints one IR-Alias
  template triple per path-bearing field, a UNION branch per field reading its
  source path as a SPARQL property path, a free subject for the pipeline’s
  VALUES injection, and a nested template for inline references (recursing to
  the schema’s declared depth).
- Default a stage’s reader to this generated Extraction CONSTRUCT: readers on
  SearchStageType is now optional, so a SPARQL deployment need not hand-write
  the query, and reader and projection agree by construction on the alias set.
- Add a schema↔CONSTRUCT contract test (the minted alias set equals the field
  set the projection reads) and an end-to-end round-trip against a local SPARQL
  endpoint (generate → read → frame → project).
- Drop the stale sparqljs entry from AGENTS.md Key Dependencies and register
  the round-trip endpoint port.

BREAKING CHANGE: SearchStageType.readers is now optional and defaults to the
generated Extraction CONSTRUCT; a stage relying on the default must declare its
fields’ source paths in SPARQL property-path grammar.
… non-deduplicating engines

- Document that the generated extraction CONSTRUCTs (UNION-per-field, given
  roots, single-subject template, no projected-away constant triple) emit one
  triple per genuine solution on a non-deduplicating engine such as QLever, so
  there is no multiplicative CONSTRUCT inflation.
- Note that this removes the need for a client-side post-processing dedup pass,
  which would defeat the batch-bounded streaming memory model; the residual
  linear duplication from duplicate input roots is absorbed by the streaming
  per-quad subject index as a cheap backstop.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Search as a Configurable Pipeline instance: transactional Writer, two update modes, per-reference label sources

1 participant