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.
Voordat de implementatie aan bod komt, eerst de eisen die de pipeline moet inlossen. Ze sturen elke ontwerpkeuze verderop.
Functionele eisen
owl:sameAs-beweringen dezelfde
persoon betreffen, worden gegroepeerd tot één persoonsreconstructie per
vermoedelijke historische persoon.DB_Record + Pico_RDF).prov:wasDerivedFrom terug naar elk van haar observaties, en via
prov:wasGeneratedBy naar de run (prov:Activity) en de robot
(prov:Agent) die haar produceerde, met start- en eindtijd.Kwaliteits- en randvoorwaarden
flock-lock
tegen dubbeldraaien beschermd; een afgebroken run laat geen halve of
corrupte output achter en geen stale lock.replaced_by) achter. Zie §5, Stabiele reconstructie-URI's.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.
De pipeline volgt het Persons in Context-model (PiCo), dat een strikt onderscheid maakt tussen twee soorten entiteiten:
picom:PersonObservation) is één waarneming
van een persoon in één bron: "Dirk van Vreumingen" als bruidegom in een
huwelijksakte is een andere observatie dan "D. van Vreumingen" als vader in
de geboorteakte van zijn kind - ook als het dezelfde mens betreft.
Observaties zijn brongetrouw en onveranderlijk; Open Archieven publiceert ze
al als linked data onder .../id/record_<archief>_<guid>....picom:PersonReconstruction) is een
interpretatie: de bewering dat een verzameling observaties over dezelfde
historische persoon gaat. Reconstructies zijn herleidbaar (elke bewering
wijst terug naar haar observaties), gedateerd (elke run is een
prov:Activity) en vervangbaar (een betere matcher levert betere clusters
zonder de brondata te raken).Twee ontwerpprincipes sturen de hele pipeline:
owl:sameAs-beweringen en beperkt zich tot het correct
sluiten, aggregeren en publiceren ervan.| 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.
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.
Bewuste keuzes:
DB_Record +
Pico_RDF-transformatie gehaald als de live .../id/record-endpoints. Eén
bron van waarheid: wat de reconstructie erft, is precies wat de site
publiceert.$db_pdo_conn) vóór de eerste db_conn() te
vullen met een verbinding naar de read-only replica, loopt élke query in de run - óók die binnen DB_Record —
over de replica, zonder de site-brede config aan te raken..tmp.gz geschreven en pas bij succes atomair hernoemd naar
oa_personreconstructions.nt.gz. Een afgebroken run laat dus nooit een
half bestand achter.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>:
hga, aal, hco, gmb); bevat geen
_._.Person-<uuid> (aal), Person<cijfers> (hco), of een kaal getal
(gmb).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" 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)!
ancestors.php vertrekt bij
een proband/echtpaar en bouwt een kwartierstaat (Sosa/Ahnentafel): de
proband op positie 1, en de ouders van persoon x op 2x en
2x+1. zoek_huwelijk() zoekt recursief de huwelijksakte van elk
paar om de ouders - de volgende generatie omhoog - te vinden. Elke persoon heeft hoogstens
twee ouders, dus de boom is van nature begrensd.descendants.php is
het spiegelbeeld maar waaiert combinatorisch uit via recursieve
expand_couple(): per echtpaar de kinderen, en per kind diens eigen huwelijk(en)
als nieuw echtpaar. Omdat dat kan ontsporen, is het hard begrensd door
MAX_GENERATIONS = 6 en een knopbudget MAX_NODES = 1500, met een
$seen_couples-lusbewaking tegen herhaald uitklappen.PersonNamePhonetic / OtherPersonsPhonetic, met edit-distance-slop
~1). De precisietruc is tweezijdige intersectie: een kandidaat
telt pas mee als hij in beide gespiegelde queries opduikt - de bruidegom-kant én de
bruid-kant bij ancestors.php, de vader- én de moeder-kant in
do_children() bij descendants.php. Een enkelzijdige query is
"te ruim" en levert valse treffers.OaOwlSameAs vergelijkt de namen in de gevonden akten en schrijft per
regel een owl:sameAs-paar. Die vergelijking is exact op voornaam + achternaam; alleen
het kind/overledene ↔ bruid/bruidegom-paar (en kind ↔ overledene) mag sinds 2026-10 een
spellingvariant van de achternaam hebben, en dan uitsluitend nadat beide ouders al exact
gematcht zijn. Zo komt "Adriana Watzeels" (geboorteakte) bij "Adriana Watseels" (bruid) terecht
zonder dat de ouder-regels losser worden.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).
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:
closure=0 LIMIT 1) en al haar rijen laden,
oplopend op created (oudste vooraan).person.
Een mention die in meerdere clusters voorkomt, verbindt die clusters:
ze zijn dezelfde persoon, dus worden ze geünificeerd.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.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
naamde 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.
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:
oai.reconstruction_register - één rij per (uuid, mention): de
ledenmomentopname van de vorige run.oai.reconstruction_register_meta - per UUID de levenscyclus
(first_seen, last_seen, status active/retired,
replaced_by).Per naam, ná de union-find, wijst assign_stable() elk cluster zijn UUID toe via
wederzijds-beste ledenoverlap:
uuid4 - de enige plek waar een nieuwe identiteit wordt aangemaakt.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.
OaPersonReconstructions.php bouwt uit elke closure=1-cluster één
picom:PersonReconstruction.
De pipeline leeest in drie korte, gebufferde stappen:
SELECT DISTINCT person ... WHERE closure=1 in één
korte gebufferde query die meteen leegloopt (~253k id's).closure=1-rijen bouwt een in-memory map <archief>_<guid>_<canonPid> →
cluster-id (~miljoenen entries, past ruim in RAM). Deze index is nodig om relaties te heffen (§hieronder):
gegeven de vermelding van een verwante persoon, in welke reconstructie zit
die? canonPid normaliseert de eerste Person-separator naar Person_,
precies dezelfde transformatie die parse_record op observatie-subjecten
toepast, zodat de sleutels op elkaar aansluiten.SELECT ... WHERE person=:p AND closure=1, direct met fetchAll()
leeggetrokken, zodat de verbinding vrij is voor de record-loads eronder.Een LRU-cache (max 8000 records) zorgt dat een record dat door meerdere clusters gedeeld wordt, maar één keer getransformeerd wordt.
Voor elke mention in het cluster:
<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).DB_Record → Pico_RDF::to_string('ntriples')) en parse het (zie
parse_record hieronder) tot een map pid → {uri, tails}.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 hoeparse_record verwerkt de N-Triples van een record in drie passes:
rdf:type picom:PersonObservation zijn.
Dit is robuust over alle pid-vormen heen en sluit bewust de bron-node
(schema:ArchiveComponent) en de Event-nodes uit..../record_<archief>_<guid>-prefix en één optionele leidende _; de
guid→pid-separator is inconsistent tussen archieven, dus niet reconstrueren
door string-bouwen maar door subject-matching) en bouwt een <uri> → pid-
inverse.rdf:type, alle prov:*, picom:hasAge, picom:hasRole,
picom:deceased, schema:address, en elke triple met een blank-node als
object.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.
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.
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:
.../id/reconstruction_<person>, waarbij <person> voortaan de
stabiele UUID uit het register is (§5, Stabiele reconstructie-URI's) - aan de
ledensamenstelling verankerd en met wederzijds-beste overlap opnieuw gehecht, dus stabiel
over reruns heen ook als de bovenstroomse beweringen herschikken (R8). Gepensioneerde URI's
(samengevoegd of verdwenen) verschijnen als tombstones in een apart bestand
oa_personreconstructions_retired.nt.gz: per URI een
dcterms:isReplacedBy naar de opvolger (indien aanwezig) en een
prov:invalidatedAtTime.prov:wasDerivedFrom.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:
wasDerivedFrom-objecten.DB_Record + Pico_RDF) zodat de shape echte, getypeerde
observatiedata te zien krijgt.-Xmx2g), en schrijf het
rapport naar oa_personreconstructions.shacl-report.ttl.Twee subtiliteiten in het regenereren:
_:genid1; zonder ingrijpen versmelten de blanks van verschillende
records bij het samenvoegen. Elk record krijgt daarom een eigen salt
(_:r<archief><guid>_...), alleen op echte blank-node-tokens, nooit binnen een
literal.schema:associatedMedia → _:x rdf:type schema:ImageObject), zodat
gekoppelde blank nodes niet wegvallen.Cijfers (de aantallen schuiven mee met de bovenstroomse beweringen; peildatum 5 juli 2026):
closure=1-rijen, gegroepeerd tot 253.361 clusters.~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.
owl:sameAs-beweringen volledig. Foute
samenvoegingen of gemiste koppelingen ontstaan bóven deze pipeline en zijn
hier niet te repareren, alleen te herstellen door de invoer te corrigeren en
opnieuw te draaien.uuid4; bij een fusie
overleeft maar één UUID (de andere wordt retired met replaced_by); en de
overlap-heuristiek kan bij een grote gelijktijdige herschikking een UUID aan een net iets ander
cluster hechten dan een mens zou kiezen.person_sameas en de daadwerkelijk
geëmitteerde observaties; ze worden veilig overgeslagen maar niet
teruggemeld naar de bron (is nog een TODO).Zoek uw voorouders en publiceer uw stamboom op Genealogie Online via https://www.genealogieonline.nl/