Persoonsreconstructies voor Open Archieven - uitleg versie 1 (5 juli 2026)

Open Archieven ontsluit miljoenen persoonsvermeldingen: losse waarnemingen van personen in gescande akten en registers - de bruidegom in een huwelijksakte, een kind in een doopboek, de overledene in een overlijdensakte. Dezelfde historische persoon komt in tientallen bronnen voor, maar de brondata zelf zegt niet dat die vermeldingen bij elkaar horen. Dit document beschrijft het closure algoritme die deze vermeldingen samenbrengt tot persoonsreconstructies: één knoop per (vermoedelijke) historische persoon, met provenance die terugwijst naar elke onderliggende vermelding.

Belangrijk om vooraf te weten - en het grootste verschil met een zelfstandige matcher zoals die van de Gouda Tijdmachine: deze pipeline matcht zelf niet. De beslissing "vermelding X en vermelding Y zijn dezelfde persoon" wordt bovenstrooms genomen en als paarsgewijze owl:sameAs-bewering in de tabel oai.person_sameas gezet. Dit closure algoritme consumeert die beweringen: hij sluit ze transitief tot clusters, aggregeert per cluster de bronvermeldingen tot één reconstructie, en valideert het resultaat. Blocking, scoren en fuzzy matching zitten dus níet in deze codebase; hier draait het om closure, aggregatie en conformiteit.

Requirements

Voordat de implementatie aan bod komt, eerst de eisen die de pipeline moet inlossen. Ze sturen elke ontwerpkeuze verderop.

Functionele eisen

Kwaliteits- en randvoorwaarden

De rest van dit document laat zien hoe elke fase deze eisen invult: het conceptuele model (R1–R2) in §1, de closure (R1, R8) in §5, de aggregatie en relatie-lifting (R3–R4) in §6, het outputmodel (R3, R5) in §7, de validatie (R6) in §8, en de robuustheids-maatregelen (R7, R9) verspreid door §5–§6.

1. Conceptueel model

De pipeline volgt het Persons in Context-model (PiCo), dat een strikt onderscheid maakt tussen twee soorten entiteiten:

Twee ontwerpprincipes sturen de hele pipeline:

  1. Interpretatie los van bron. De reconstructielaag raakt de observaties nooit aan. De observatievelden in een reconstructie worden bij elke run vers uit de bron geregenereerd, zodat een correctie in de brondata of in de A2A→PiCo-transformatie automatisch in de volgende reconstructie-run doorwerkt.
  2. De matcher staat buiten dit closure algoritme. Wat "dezelfde persoon" is, wordt bovenstrooms bepaald. Deze pipeline is bewust dom over identiteit: hij vertrouwt de owl:sameAs-beweringen en beperkt zich tot het correct sluiten, aggregeren en publiceren ervan.

2. Standaarden en vocabulaires

Standaard / vocabulaire Namespace Rol in de pipeline
Persons in Context (PiCo) https://personsincontext.org/model# Kerntypen PersonObservation en PersonReconstruction; observatie-eigen predikaten hasAge, hasRole, deceased (op de reconstructie weggelaten)
PNV (Person Name Vocabulary) https://w3id.org/pnv# Naamdelen op de observaties, die als niet-relationele "tails" mee-overgenomen worden
schema.org https://schema.org/ Feitelijke persoonsvelden (givenName, familyName, birthDate, birthPlace, gender, ...) en de relationele velden parent/spouse/children
PROV-O http://www.w3.org/ns/prov# Herleidbaarheid: wasDerivedFrom (reconstructie → observatie), wasGeneratedBy + Activity/Agent, startedAtTime/endedAtTime, wasAssociatedWith; invalidatedAtTime op een gepensioneerde reconstructie-URI
Dublin Core Terms http://purl.org/dc/terms/ isReplacedBy: verwijst een gepensioneerde (samengevoegde) reconstructie-URI naar haar opvolger; zie de tombstone-output
owl:sameAs http://www.w3.org/2002/07/owl#sameAs Het paarsgewijze invoersignaal (bovenstrooms), zichtbaar op de live .../id/person_*-endpoints
N-Triples RDF 1.1 Uitwisselformaat van de output (gzip)
eigen namespace oa: https://www.openarchieven.nl/id/ URI-basis voor reconstructies (reconstruction_<uuid>), de run-activiteit (reconstruction_activity_01) en de robot (reconstruction_robot_0.1)

De pipeline consumeert dus PiCo/PNV/PROV/schema.org zoals Open Archieven die al uitgeeft, en produceert PiCo-reconstructies met PROV-provenance in dezelfde stijl.

3. Architectuur

Drie losse CLI-scripts rond de gedeelde MariaDB-database oai (oai.person_sameas + oai.records, plus het zelf-beheerde stabiele-URI-register oai.reconstruction_register + ..._meta). Elk script is idempotent, via flock tegen dubbeldraaien beveiligd, en los aanroepbaar.

Open Archieven Closure Algoritme bovenstroomse matcher - buiten closure algoritme beslist ‘zelfde persoon’ → paarsgewijze owl:sameAs oai.person_sameas naam · person · person1 · person2 · closure · created 5.218.765 rijen (closure=1) 1 · OaOwlSameAsDeDup.php - transitieve sluiting union-find per naam · mention in meerdere clusters ⇒ unie union by age: oudste cluster-id wint · path compression → closure=1 · 253.361 clusters oai.records A2A-XML per record DB_Record + Pico_RDF read-only replica :3316 (zelfde transform als live) 2 · OaPersonReconstructions.php - aggregatie per cluster: observaties vers uit de bron naar PiCo transformeren ‘tails’ overnemen · relaties heffen (obs-URI → reconstructie-URI) mention→cluster-index (RAM) · LRU record-cache · provenance ~96% clusters → reconstructie · ~15% mentions = ruis (missing) oa_personreconstructions.nt.gz picom:PersonReconstruction + PROV · N-Triples (gzip) streamend geschreven, atomaire rename van .tmp 3 · OaPersonReconstructionsValidate.php - validatie steekproef van N reconstructies + referentie-observaties regenereren Apache Jena `shacl validate` tegen de PiCo-shapes ‘ons’ (reconstructie/prov) vs ‘bovenstrooms’ (record_*) gesplitst PiCo SHACL-shapes CBG-github (gecached als pico_shacl.ttl) oa_personreconstructions.shacl-report.ttl sh:conforms true (peildatum 28 jun 2026) exit 0/1 op basis van alleen de eigen output-nodes bron tabel/data closure algoritme output validatierapport buiten closure algoritme

Technische componenten BRONNEN · (MariaDB, read-only replica) oai.person_sameas paarsgewijze owl:sameAs + cluster-id + closure-vlag oai.records A2A-XML brondocumenten (content_compressed) VERWERKING · CLI (flock-lock, idempotent, los herdraaibaar) OaOwlSameAsDeDup.php union-find per naam transitieve sluiting oudste cluster-id wint → closure=1 OaPersonReconstructions.php cluster → 1 reconstructie aggregatie + relatie-lifting mention→cluster-index (RAM) LRU record-cache (8000) OaPersonReconstructionsValidate.php steekproef + observaties regenereren Jena `shacl validate` ons vs bovenstrooms gedeelde site-pipeline (hergebruikt via chdir naar root/includes), óók door de live /id/record-endpoints: DB_Record decomprimeert A2A uit oai.records · Pico_RDF + EasyRdf → PiCo/PROV N-Triples OUTPUT + VALIDATIE oa_personreconstructions.nt.gz picom:PersonReconstruction + PROV (N-Triples, gzip) 5.218.765 rijen → 253.361 clusters Apache Jena 5.6.0 shacl validate (-Xmx2g) → ...shacl-report.ttl sh:conforms true PiCo SHACL-shapes CBG-github (gecached als pico_shacl.ttl)

Bewuste keuzes:

4. Invoer

De invoer is de tabel oai.person_sameas. Elke rij is één paarsgewijze owl:sameAs-bewering: twee record-vermeldingen (person1, person2) zijn volgens de bovenstroomse matcher dezelfde persoon, gegroepeerd onder een cluster-id person en gestempeld met een created-tijd. De kolom naam (de naam zoals de matcher die in de akte aantrof) bepaalt via de naamsleutel de partitie van het werk, en closure markeert of de naam al gesloten is.

Naamsleutel. Sinds 2026-10 is de eenheid van werk niet de rauwe naam maar een genormaliseerde sleutel: eerste + laatste naamtoken (voornaam en achternaam, zonder patroniem en tussenvoegsel), klein geschreven, naar ASCII gevouwen (accenten weg) en spellingsgesoftend (z→s, ij/ei/y→i, ck→k, dubbele letters ingeklapt, …). "Adriana Watzeels" en "Adriana Watseels" delen zo de sleutel adriana watsels. De afbeelding rauwe naam → sleutel staat in de bijtabel oai.person_sameas_naamkey; person_sameas zelf is niet herschreven.

Een vermelding heeft de vorm <archief>_<guid>_<pid>:

Omdat archief en guid geen underscore bevatten, splitst de pipeline op de eerste twee underscores; de rest is de pid. Dezelfde beweringen zijn ook live zichtbaar: op .../id/person_<archief>_<guid>_<pid> verschijnt per bewering een owl:sameAs naar de tegenhanger.

De bovenstroomse matcher

De "bovenstroomse matcher" is geen aparte dienst maar de genealogische boom-walkers achter de publieks-viewers /ancestors/ (voorouders) en /descendants/ (afstammelingen). Hun primaire taak is een stamboom voor de viewer opbouwen, maar als bijproduct wordt elke record die in die boom belandt bovenstrooms als owl:sameAs aangeboden - en dat is precies het invoersignaal dat dit closure algoritme consumeert.

Let op: de boom-walkers doen hun werk on-demand en realtime als een Open Archieven gebruiker een akte bekijkt, de set van matches groeit dus dagelijks (er is geen matching gedaan op alle op Open Archieven gepubliceerde akten)!

De matcher matcht fuzzy; dit closure algoritme vertrouwt de uitkomst. Het fonetisch matchen, de boom-heuristieken en de generatie-grenzen zitten dus volledig bovenstrooms, buiten deze codebase. Deze pipeline is bewust dom over identiteit (§1, principe 2): een betere matcher levert vanzelf betere clusters, zonder dat hier iets verandert - maar de kwaliteit van de reconstructies is nooit beter dan die van de aangeleverde owl:sameAs-beweringen (§10).

5. Transitieve sluiting

OaOwlSameAsDeDup.php zet de losse paren om in samenhangende clusters. De paarsgewijze beweringen zijn immers alleen lokaal: als (A≡B) en (B≡C) apart beweerd zijn, moet daar één cluster {A,B,C} van worden. Dat is een klassiek union-find-probleem, per naam uitgevoerd:

  1. Eén open naam pakken (closure=0 LIMIT 1) en al haar rijen laden, oplopend op created (oudste vooraan).
  2. Union-find opbouwen. Elke rij introduceert een cluster-id person. Een mention die in meerdere clusters voorkomt, verbindt die clusters: ze zijn dezelfde persoon, dus worden ze geünificeerd.
  3. Union by age (werk-representant). Bij een unie blijft het oudste cluster-id (kleinste created-rang) de werk-representant; path-compression houdt find snel. Deze keuze bepaalt echter niet langer de reconstructie-URI: die komt uit het register (zie Stabiele reconstructie-URI's hieronder), zodat een herschikking van de bovenstroomse beweringen de URI niet meer laat verspringen.
  4. Stabiele id toewijzen en persisteren. Elk cluster krijgt zijn stabiele reconstructie-UUID uit het register, en al zijn rijen worden naar die UUID herschreven (UPDATE ... SET person=<stabiele uuid>). Daarna krijgt de héle naam closure=1. Zowel de toewijzing als de registermutaties zitten in dezelfde transactie.

Het vlaggen gebeurt bewust per naam en niet per aangeraakt cluster-id: zo valt de naam gegarandeerd uit de closure=0-selectie, ongeacht hoe de merges uitpakten, en kan de buitenlus nooit op dezelfde naam blijven hangen. De hele naam-verwerking zit in één transactie (rollback bij fout).

Geen transitieve sluiting óver naamsleutels heen. De closure blijft binnen één sleutel-partitie. Sleutels sluiten is de eenheid van werk én de natuurlijke rem tegen doorgeslagen megaclusters. Vóór 2026-10 was de rauwe naam de partitie, waardoor spellingvarianten van dezelfde persoon ("Watzeels"/"Watseels") nooit samen konden komen, ook niet als de matcher ze koppelde; het register is bij de eerste sluiting van een sleutel van de rauwe naam naar de sleutel verhuisd.

Stabiele reconstructie-URI's: het register

De reconstructie-URI is .../id/reconstruction_<person>, dus zij is maar zo stabiel als de person-waarde die op de rijen achterblijft. Vroeger was dat simpelweg de bovenstrooms geminte UUID met de oudste created onder de samengevoegde clusters - niet inhoudelijk verankerd. Zodra de bovenstroomse beweringen herschikken (een nieuw ouder paar, andere created, opnieuw ingevoegde rijen) kon die "oudste wint"-keuze omklappen en versprong de publieke URI.

Daarom verankeren we de UUID nu aan de inhoud van het cluster - zijn verzameling mentions - in plaats van aan een vluchtige bovenstroomse id. Het patroon is overgenomen van de Gouda Tijdmachine (bin/pico/55_assign_uris.py). Twee door onszelf beheerde tabellen onthouden de toewijzing over reruns heen:

Per naam, ná de union-find, wijst assign_stable() elk cluster zijn UUID toe via wederzijds-beste ledenoverlap:

  1. Overlap. Voor elke bekende UUID uit het register wordt geteld hoeveel van haar mentions in welk nieuw cluster zitten.
  2. Overerven. Een UUID wordt door een cluster geërfd alleen als zij elkaars beste overlap zijn (het cluster is de grootste afnemer van de UUID én de UUID is de grootste leverancier van het cluster). Gelijke stand breekt deterministisch op de kleinste UUID/root-string, zodat identieke invoer identieke toewijzing geeft.
  3. Nieuw cluster. Een cluster zonder voorganger krijgt een verse uuid4 - de enige plek waar een nieuwe identiteit wordt aangemaakt.
  4. Splitsing. Valt een cluster uiteen, dan behoudt het grootste fragment de UUID (via de wederzijds-beste regel) en krijgen de overige fragmenten een nieuwe.
  5. Fusie. Smelten clusters samen, dan wint de grootste leverancier en worden de verliezers retired met een replaced_by-verwijzing naar de winnaar. Een UUID zonder overlevende leden verdwijnt (retired zonder opvolger).

De gekozen UUID wordt teruggeschreven op person_sameas.person, zodat OaPersonReconstructions.php en de reconstruction_<person>-URI ongewijzigd blijven en vanzelf stabiel worden.

Continuïteit bij invoering. Ziet het register een naam voor het eerst, dan worden de huidige person-id's als stabiele UUID's geadopteerd (bootstrap uit de rauwe, pre-union groepering). Zo blijven bestaande gepubliceerde URI's behouden; alleen echte latere herschikkingen leiden tot een nieuwe UUID of een tombstone.

6. Reconstructies genereren

OaPersonReconstructions.php bouwt uit elke closure=1-cluster één picom:PersonReconstruction.

Leesstrategie: twee fasen plus een index

De pipeline leeest in drie korte, gebufferde stappen:

Een LRU-cache (max 8000 records) zorgt dat een record dat door meerdere clusters gedeeld wordt, maar één keer getransformeerd wordt.

Per cluster: aggregeren

Voor elke mention in het cluster:

  1. Splits <archief>_<guid>_<pid> en normaliseer de pid-separator naar Person_ (de mention slaat -/geen op, de PiCo-subject gebruikt _ waar het A2A-@pid een : had).
  2. Transformeer het hele record vers naar PiCo-N-Triples (DB_Record → Pico_RDF::to_string('ntriples')) en parse het (zie parse_record hieronder) tot een map pid → {uri, tails}.
  3. Zoek de observatie op de genormaliseerde pid op. Ontbreekt ze, tel als missing en sla over.
  4. Anders: neem de observatie-URI op als prov:wasDerivedFrom-doel en voeg haar "tails" (predikaat + object-paren) toe aan de reconstructie.

Levert het cluster geen enkele bruikbare observatie op, dan wordt er geen reconstructie geschreven.

parse_record: welke triples erven, en hoe

parse_record verwerkt de N-Triples van een record in drie passes:

Relatie-lifting (R4). De predikaten schema:parent, schema:spouse en schema:children hebben in PiCo bereik PersonReconstruction. Hun object is in de bron echter de observatie-URI van de verwante persoon binnen hetzelfde record. Die wordt omgezet naar de reconstructie-URI van die persoon, via de mention→cluster-index uit fase 1b. Heeft de verwante persoon geen cluster, dan wordt de relatie-triple gedropt. Effect: dezelfde ouder die in twee bronrecords apart is waargenomen, klapt samen tot één ouder-link naar één reconstructie.

Zelfreferentie eruit. Als een geheven relatie terugverwijst naar de reconstructie die op dat moment gebouwd wordt (het cluster is zijn eigen ouder/partner), wordt die triple weggelaten.

Provenance

Na alle clusters schrijft de run één provenance-blok: een prov:Activity (.../id/reconstruction_activity_01) met startedAtTime/endedAtTime en wasAssociatedWith de robot-prov:Agent (.../id/reconstruction_robot_0.1, met schema:name "Open Archieven Reconstructie Robot (v0.1)"@nl en schema:url). Elke reconstructie draagt prov:wasGeneratedBy → deze Activity.

7. Outputmodel

Datamodel: van observatie naar reconstructie (PiCo + PROV) picom:PersonObservation .../id/record_hga_5D88..._Person2053824761 schema:givenName/familyName · birthDate · gender schema:spouse → mede-observatie in dit record picom:PersonObservation .../id/record_aal_..._Person_<uuid> andere bron, zelfde persoon (owl:sameAs) eigen naamdelen · beroep · plaats picom:PersonObservation .../id/record_gmb_..._<n> (kaal getal als pid) brongetrouw en onveranderlijk hasAge/hasRole/deceased/address → weggelaten oai.records → DB_Record + Pico_RDF A2A-XML vers naar PiCo N-Triples per run zelfde transform als de live /id/record vers geregenereerd picom:PersonReconstruction .../id/reconstruction_<cluster-uuid> stabiel zolang de owl:sameAs-beweringen stabiel zijn schema:givenName/familyName · birthDate · birthPlace schema:gender · ... (niet-relationele ‘tails’ uit de leden) schema:spouse/parent/children → andere reconstructie (zelfreferentie weggelaten) prov:wasDerivedFrom (per lid) · prov:wasGeneratedBy prov:wasDerivedFrom prov:Activity .../id/reconstruction_activity_01 prov:startedAtTime / endedAtTime (deze run) prov:wasGeneratedBy prov:Agent .../id/reconstruction_robot_0.1 schema:name “...Robot (v0.1)”@nl · schema:url prov:wasAssociatedWith Volle pijlen: triples in oa_personreconstructions.nt.gz · gestippeld: herkomst/afleiding tijdens de run (niet in de output) Relatie-lifting: het object van schema:spouse/parent/children is de mede-observatie in de bron, omgezet naar diens reconstructie-URI De observatievelden worden bij elke run vers uit de bron geregenereerd; de reconstructie voegt alleen de interpretatielaag toe

De output is uitsluitend gzip-N-Triples (oa_personreconstructions.nt.gz). Per reconstructie, schematisch (ander voorbeeld ook te bekijken via LDview):

<https://www.openarchieven.nl/id/reconstruction_7c3a9f21-4d8e-5b6a-9f1c-2e0d8a4b6f3d>
    a picom:PersonReconstruction ;
    # niet-relationele "tails", vers uit de bronobservaties:
    schema:givenName "Dirk" ;
    schema:familyName "van Vreumingen" ;
    schema:birthDate "1867-05-02" ;
    schema:gender <https://schema.org/Male> ;
    # geheven relatie: object is de reconstructie-URI van de verwante persoon
    schema:spouse <https://www.openarchieven.nl/id/reconstruction_1b2c...> ;
    # herleidbaarheid: één per onderliggende observatie
    prov:wasDerivedFrom <https://www.openarchieven.nl/id/record_hga_5D88..._Person2053824761> ;
    prov:wasDerivedFrom <https://www.openarchieven.nl/id/record_aal_..._Person_...> ;
    prov:wasGeneratedBy <https://www.openarchieven.nl/id/reconstruction_activity_01> .

<https://www.openarchieven.nl/id/reconstruction_activity_01>
    a prov:Activity ;
    prov:wasAssociatedWith <https://www.openarchieven.nl/id/reconstruction_robot_0.1> ;
    prov:startedAtTime "2026-07-05T18:00:00"^^xsd:dateTime ;
    prov:endedAtTime   "2026-07-05T21:30:00"^^xsd:dateTime .

<https://www.openarchieven.nl/id/reconstruction_robot_0.1>
    a prov:Agent ;
    schema:name "Open Archieven Reconstructie Robot (v0.1)"@nl ;
    schema:url  <https://www.openarchieven.nl/> .

De RDF is ook te bevragen via het SPARQL-endpoint https://sparql.openarchieven.nl/oa-pico-recon (zie het overzicht van SPARQL-endpoints van Open Archieven, met service description en datadump), o.a. via Yasgui.

Elke URI is dereferenceerbaar. Een .../id/reconstruction_<uuid> (en ook .../id/reconstruction_activity_01 en .../id/reconstruction_robot_0.1) geeft een 303 See Other naar .../doc/reconstruction_<uuid>, dat de beschrijving per request met één CONSTRUCT uit de QLever-store haalt en via content negotiation teruggeeft als Turtle (text/turtle), N-Triples (application/n-triples), JSON-LD (application/ld+json) of RDF/XML (application/rdf+xml) - of als extensie: .ttl, .nt, .jsonld, .xml. Een browser krijgt een HTML-weergave. Een ingetrokken URI (tombstone, zie hieronder) geeft 301 naar de opvolger, of 410 Gone met de tombstone-triples als er geen opvolger is. Het endpoint is zo vers als de laatste herindexering van de store: dat is precies de gepubliceerde snapshot waar de URI's in de export naar verwijzen. Voorbeeld: reconstruction_000010cc-….

Ontwerpbeslissingen:

8. Validatie

OaPersonReconstructionsValidate.php toetst de output tegen de officiële PiCo-SHACL-shapes met Apache Jena's shacl validate.

De uitdaging: het outputbestand bevat alleen reconstructies + provenance, maar de PersonReconstruction-shape eist dat elk prov:wasDerivedFrom-doel een getypeerde picom:PersonObservation is. Daarom:

  1. Steekproef. Stream de gzip en houd de triples van de eerste N reconstructies vast (default 1000; één shape geldt voor alle, dus een steekproef volstaat), plus altijd de provenance-triples. Verzamel de wasDerivedFrom-objecten.
  2. Observaties regenereren. Transformeer de betrokken records opnieuw naar PiCo (DB_Record + Pico_RDF) zodat de shape echte, getypeerde observatiedata te zien krijgt.
  3. Valideer de gecombineerde graaf met Jena (-Xmx2g), en schrijf het rapport naar oa_personreconstructions.shacl-report.ttl.

Twee subtiliteiten in het regenereren:

9. Statistieken en schaal

Cijfers (de aantallen schuiven mee met de bovenstroomse beweringen; peildatum 5 juli 2026):

~15% van de closure=1-mentions is ruis: ze verwijzen naar een Person<n> die in het genoemde record geen geëmitteerde PersonObservation is (ook het live /id/record-endpoint geeft er niets voor terug). Deze overslaan - geteld als missing - is correct, geen bug. Daarnaast wordt ~4–5% van de geheven relaties gedropt omdat de verwante persoon in geen enkel cluster zit.

10. Bekende beperkingen

11. Referenties

Zoek uw voorouders en publiceer uw stamboom op Genealogie Online via https://www.genealogieonline.nl/