- _filter_passes: 'zwak-alleen in samenvatting' en 'sterk+zwak uitsluitend in
samenvatting' reduceren tot één regel: zonder enig titel-signaal (familie-hit,
whitelist-term of storingszin) mag de score geen zwak woord bevatten.
Equivalentie bewezen over de volledige beslissing-ruimte (420 configuraties).
- Nieuwe dienststoring-context 'vervoerswoord rijdt/rijden niet' (gedeeld
patroon): sterk titel-signaal (+2) en familie (2) — 'sprinters rijden niet
tussen Utrecht en Amsterdam', 'metro rijdt niet tussen Rotterdam en
Schiedam' passeren nu; bus/tram/metro/sprinter zelf blijven zwak/medium.
- Overlappende regexes opgeruimd: busbaan/bushokje uit de halte-familie.
- Blacklist 29 -> 2 woorden: restant (stationsstraat, stationair, stationcar,
stationeren ...) was inert sinds de woordgrens-familie; vastgelegd in tests.
- validate_filter.py: 56 -> 90 regressie-cases + invariant 4 (whitelist-term
in familie, blacklist-term door familie gematcht).
- _filter_summary/beleidsdocumentatie geactualiseerd.
'busbaan' stond in de bus-samenstelling-familie (fam 4) én de
halte/busbaan-familie (fam 13): één woord scoorde 2+2=4 en liet zo
een niet-OV-bericht door (Omroep Brabant politie-achtervolging 'via
de busbaan', 13-09-2026). Latente dubbeltellingen: 'ov-chipkaart'
bevat 'chipkaart', 'openbaar vervoer' bevat 'vervoer'.
_fam_hits voegt nu overlappende span van verschillende families samen
met het hoogste gewicht (retour blijft (familie-index, gewicht), dus
de familie-één-signaal-regel over titel+samenvatting is intact).
Beleidscommentaar + regressie (56 cases) bijgewerkt; 45-dagen-replay
(90 geposte entries): precies 1 gedragsverschil — het fatbikers-
bericht wordt nu geweerd, delftopzondag (tram+halte, twee termen)
blijft door (besluit 2026-09-13).
(r"\b(?:ns|arriva|qbuzz|connexxion|cxx|transdev|keolis|ret|gvb|ebs|htm|syntus|u-ov|allgo|flixbus|blauwnet|r-net|valleilijn|waterbus|eurostar|thalys|randstadrail|gvu|prorail|hermes|rrreis|qliner|ov)\b",3),# vervoerders ('Breng' staat in de zwakke groep: botst met het werkwoord "brengen")
(r"\b(?:spoorweg|spoorba(?:an|nen)|spoorlijn|spoorverkeer|spoorwegovergang|spoorstoring|perron|seinstoring|wisselstoring|spoorstaking)\w*\b",3),# spoorinfra; let op de klinkerwisseling: 'ontsporing' (open lettergreep, één o) vs 'ontspoord'/'ontspoorde' (dicht, dubbel o) — vandaar beide stammen. Géén 'spoorbrug': die is medium (vaak locatie-referentie, besluit 2026-09-12)
(r"\b(?:treinstation|treinstations|metrostation|metrostations|busstation|busstations)\b",2),# station-samenstellingen: onmiskenbaar OV, maar MEDIUM — één station-woord náást een ander onderwerp ('pand naast busstation', besluit 2026-09-11) is geen OV-bericht; in de samenvatting moet een tweede OV-woord bijkomen
# sterk (2) — onmiskenbaar OV, maar één hit is onvoldoende (max-2-stelsel,
# besluit 2026-09-12): elke echte OV-bericht combineert met een tweede signaal.
(r"\b(?:ns|arriva|qbuzz|connexxion|cxx|transdev|keolis|ret|gvb|ebs|htm|syntus|u-ov|allgo|flixbus|blauwnet|r-net|valleilijn|waterbus|eurostar|thalys|randstadrail|gvu|prorail|rrreis|qliner|ov|teso|deutsche bahn|european sleeper|ns international|italo|nightjet)\b",2),# vervoerders; 'hermes' is zwak (achternaam-botsing, Margraten 2026-09-12), 'Breng' ook (werkwoord "brengen"); internationale vervoerders: teso (veer), deutsche bahn/european sleeper/italo/nightjet/ns international (audit 2026-09-12)
(r"\b(?:spoorweg|spoorba(?:an|nen)|spoorlijn|spoorverkeer|spoorwegovergang|spoorstoring|perron|seinstoring|wisselstoring|spoorstaking|spoorsector|spoorbeheerder|spoornet|spoorvervoerder|ertms)\w*\b",2),# spoorinfra; let op de klinkerwisseling: 'ontsporing' (open lettergreep, één o) vs 'ontspoord'/'ontspoorde' (dicht, dubbel o) — vandaar beide stammen. Géén 'spoorbrug': die is 2 maar vaak louter locatie (besluit 2026-09-12). Uitbreiding 2026-09-12 (completheids-audit): spoorsector, spoorbeheerder, spoornet, spoorvervoerder, ertms
(r"\b(?:treinstation|treinstations|metrostation|metrostations|busstation|busstations)\b",2),# station-samenstellingen: onmiskenbaar OV, maar één station-woord náást een ander onderwerp ('pand naast busstation', besluit 2026-09-11) is geen OV-bericht; in de samenvatting moet een tweede OV-woord bijkomen
(r"(?<![\w-])\bstations?\b",1),# los 'station(s)' is ZWAK: ook tv-/radio-/regionale omroepstations ('de lokale stations'), moet combineren met een andere OV-familie
(r"\b(?:bus|tram|metro)-?(?:halte|haltes|lijn|lijnen|verkeer|net|staking|ba(?:an|nen)|hok(?:je|jes)|chauffeur)\w*\b",3),# voertuig-samenstelling (bushalte/buslijn/bushokje/busbanen/…); géén 'station': *station-samenstellingen zitten in de station-familie, anders telde 'busstation' dubbel mee (via 'bus' én 'station') — fix 2026-09-11
(r"\b(?:treinstoring|treinverkeer|treinstaking|treinongeval|treinbotsing|treinongeluk)\w*\b",3),# trein-jargon-samenstellingen: onmiskenbaar OV
(r"\b(?:bus|tram|metro)-?(?:halte|haltes|lijn|lijnen|verkeer|net|staking|ba(?:an|nen)|hok(?:je|jes)|chauffeur)\w*\b",2),# voertuig-samenstelling (bushalte/buslijn/bushokje/busbanen/buschauffeur/…); géén 'station': *station-samenstellingen zitten in de station-familie, anders telde 'busstation' dubbel mee — fix 2026-09-11. Géén 'ongeluk'/'vervoer': die zijn bewust zwak (busongeluk = ook touringcar, busvervoer = ook bedrijfsvervoer, audit 2026-09-12). 'busbaan'/'bushokje' stonden óók in de halte-familie — verwijderd bij de refactor 2026-09-13 (geen dubbelwoorden in twee families)
(r"\b(?:treinstoring|treinverkeer|treinstaking|treinongeval|treinbotsing|treinongeluk|treinvertraging|treinhinder|treinuitval|treinverstoring)\w*\b",2),# trein-jargon-samenstellingen: onmiskenbaar OV; uitgebreid met vertraging/hinder/uitval/verstoring (audit 2026-09-12)
(r"\b\w*trein\w*\b",1),# losse 'trein' en algemene samenstellingen (stoptrein, goederentrein, treinverkeer al in bovenstaande familie) — ZWAK: 'de trein naar mijn nieuwe huis' is geen OV-jargon, moet combineren met een andere familie
(r"\b(?:intercity|streekvervoer|stadsvervoer|veerdienst|veerboot|veerboten|veerpont|voetveer|veerverbinding|buurtbus|belbus)\w*\b",3),# treintypes, vervoerssectoren en veren ('sprinter' staat in de zwakke groep: atleet-botsing)
(r"\blijn\s?\d{1,3}\b",3),# 'lijn 12' — vervoerslijn met nummer
# medium (2)
(r"\b(?:intercity|streekvervoer|stadsvervoer|veerdienst|veerboot|veerboten|veerpont|voetveer|veerverbinding|buurtbus|belbus|maaslijn|nedersaksenlijn|kamperlijn)\w*\b",2),# treintypes, vervoerssectoren, veren en lijnnamen ('sprinter' staat in de zwakke groep: atleet-botsing); maaslijn/nedersaksenlijn/kamperlijn via audit 2026-09-12
(r"\blijn\s?\d{1,3}\b",2),# 'lijn 12' — vervoerslijn met nummer
(r"\bice[\s\-–—]*(?:berlijn|hamburg|brussel|bremen|parijs|frankfurt|keulen|amsterdam|bazel|milaan|kopenhagen|zurich|zürich|duitsland|international)\b|\bice-trein\w*\b",2),# ICE-frasen: onmiskenbaar trein; los 'ice' is NIET toevoegbaar (case-insensitive botsing met 'ijs', besluit 2026-09-12)
# onmiskenbaar OV maar iets algemener (2, moet ook combineren)
(r"\b(?:openbaar vervoer|lightrail|halte)\w*\b",2),# frasen en halte-hits; 'bushokje'/'busbaan' matchen al de bus-samenstelling-familie — bewust niet dubbel in twee families (refactor 2026-09-13)
(r"\bspoorbrug(?:gen)?\b",2),# spoorinfra, maar MEDIUM: vaak locatie-referentie, geen OV-nieuws ('bij de spoorbrug', Stuifmail 2026-09-12); moet combineren — zelfde regel als station-samenstellingen (besluit 2026-09-11)
(r"\bspoorbrug(?:gen)?\b",2),# spoorinfra, maar vaak locatie-referentie ('bij de spoorbrug', Stuifmail 2026-09-12); moet combineren — zelfde regel als station-samenstellingen
# zwak (1) — dubbelzinnig, moet combineren
(r"(?<!-)\bbus(?:sen)?\b",1),# ná een koppelteken geen OV: 'VW-bus', 'school-bus'
(r"\b(?:pendelbus|shuttlebus)\w*\b",1),# evenement-/hotel-/parkeerpendel ('pendelbus naar de golfbaan') is geen OV; synoniemen bewust in één familie
(r"\b(?:inchecken|uitchecken)\w*\b",1),# ook op luchthavens/hotels — geen OV-jargon, moet combineren
(r"\bsprinters?\b",1),# ook atleet ('sprinter verpulvert record') — aparte familie, moet combineren
(r"\bvervoerders?\b",1),# ook logistiek ('vrachtvervoerder') — aparte familie, moet combineren
(r"\breizigers?\b",1),# ook algemeen ('zakenreizigers vliegen') — aparte familie, moet combineren
(r"\b(?:reizigers?|passagiers?)\b",1),# ook 'zakenreizigers vliegen' / vliegtuigpassagiers — synoniemen bewust één familie (audit 2026-09-12)
(r"\bdienstregeling\w*\b",1),# dubbelzinnig (modelaudit) — meestal OV, maar moet combineren
(r"\bchipkaart(?:en)?\b",1),# dubbelzinnig (bankpas/algemeen) — aparte familie
(r"\breisproduct(?:en)?\b",1),# dubbelzinnig — aparte familie
(r"\bopwaarderen\b",1),# ov-chipkaart opladen vs werkwoord "een wijk/park opwaarderen"
(r"\brails\b",1),
(r"\bbusongeluk(?:ken)?\b",1),# audit 2026-09-12: ook touringcar/schoolbus; combineert met buschauffeur/bus → door (busongeluk Zwitserland)
(r"\bbusvervoer\b",1),# audit 2026-09-12: ook touringcar-/bedrijfsvervoer
(r"\bspoor\b",1),# figuurlijk 'op het spoor van de daders', 'het spoor bijster'; écht spoornieuws combineert met spoorlijn/trein/perron
(r"\bstaking(?:en)?\b",1),# arbeidsstaking in alle sectoren; 'spoorstaking'/'busstaking'/'ov-staking' zitten in de sterke families (besluit 2026-09-12)
(r"\bvervoer\b",1),# ook vracht-/autovervoer; 'openbaar vervoer' zit in de sterke familie; samenstellingen (streekvervoer e.d.) matchen hier niet (geen \b)
(r"\bvervoersbedrijf(?:ven)?\b",1),# ook goederen-/taxibedrijf (audit 2026-09-12)
(r"\bvervoerregio'?s?\b",1),# OV-autoriteit ('Vervoerregio Amsterdam'), maar audit: ook algemener vervoersgebied
(r"\britten\b",1),# ook fiets-/taxiritten (audit 2026-09-12)
(r"\bbovenleiding(?:en)?\b",1),# audit 2026-09-12: ook water/gas/elektra-bovenleiding; spoor-combinatie (spoorlijn/ertms) haalt de drempel
# --- Invariant 4 (refactor 2026-09-13): sterke-signaal-garantie + geen dode
# blacklist. Elke whitelist-term moet óók in een familie zitten (anders telt een
# whitelist-titel niet als sterk signaal 2+2) en elke zwartgelijste term moet door
# minstens één familie worden gematcht (anders is de blacklist-regel dood gewicht).
print("== invariant 4: whitelist-term in familie; blacklist-term door familie gematcht ==")
forwinsorted(ns["MATCH_FILTER_WHITELIST"]):
check(any(p.search(w)forp,_inCOMPILED),f"whitelist-term {w!r} zit ook in een familie")
forwinsorted(ns["MATCH_FILTER_BLACKLIST"]):
check(any(p.search(w)forp,_inCOMPILED),f"blacklist-term {w!r} wordt door een familie gematcht")
# --- Regressie: gedrag van _filter_passes op bekende cases (precision-first)
GOLDEN=[
# (titel, samenvatting, verwacht_door)
@ -183,6 +204,78 @@ GOLDEN = [
("Lijn 12 rijdt vanaf maandag een andere route","De route gaat via het centrum.",True),
("Werkzaamheden aan perron","Het perron wordt volgende maand opgeknapt.",True),
("Reactie Nederlandse Spoorwegen","De Nederlandse Spoorwegen zeggen meer in te zetten op punctualiteit.",True),
# max-2-stelsel (besluit 2026-09-12): geproduceerd op 205 productie-artikelen
# + LLM-audit. Geen enkele familie weegt meer dan 2 → één hit kan nooit solo
# doorlaten; whitelist-term in de titel telt +2 (titel-anker).
("Veel animo voor 'adopteren' graf op Amerikaanse begraafplaats Margraten",
"…'Het voelde echt alsof je in de rij stond voor festivaltickets'… 'Ik ben bang dat mensen met de slaapzak voor de laptop gaan liggen', zei Ton Hermes eerder.",False),
# ^ echte false positive 2026-09-12: 'hermes' (achternaam) scoorde vroeger 3 via de vervoerdersfamilie
("TESO onderzoekt komst derde veerboot tussen Texel en Den Oever",
"Veerdienst TESO onderzoekt of het haalbaar is om een derde veerboot in te zetten tussen Oudeschild en Den Oever.",True),# teso(2) + veerboot(2) = 4
("Vrouw buschauffeur Rob zwaar aangeslagen na dodelijk ongeval",
"Bij het dodelijke busongeluk in Zwitserland zat de politicus achter het stuur.",True),# buschauffeur(2) + busongeluk(1) = 3
("Vakbonden noemen stakingsdag in het OV 'een geslaagde dag'",
"Gisteren was de landelijke stakingsdag in het OV; medewerkers legden in de spoorsector 24 uur het werk neer.",True),# ov(2) + staking(1) + spoorsector(2)
("Dodental veerbootbrand Filipijnen loopt verder op","Zeker 76 mensen kwamen om bij de brand op de veerboot.",False),# één veerboot-hit (2) = geen NL-OV
("ICE Berlijn voegt na Hamburg vanaf december ook stop in Bremen toe",
"De ICE Berlijn gaat vanaf december via Bremen en Hamburg Hauptbahnhof naar Berlijn rijden. Dat meldt NS International.",True),# ice-berlijn(2) + ns(2) = 4
("Laatste noot voor vernielde stationspiano Eindhoven",
"De piano in het station was jarenlang een begrip reizigers produceerden er filmpjes.",False),# cultuurstukje; 'station'/'reizigers' zwak-alleen zonder titel-anker
("Meerdere doden na busongeluk in Zwitserland","Het busongeluk gebeurde naast de snelweg bij een afrit.",False),# busongeluk(1) alleen = zwak-alleen
("Antwerpen ontvangt eerste ICE","De eerste ICE van Deutsche Bahn rijdt vanaf volgende maand naar Antwerpen.",False),# bewust geweerd: 'ice van' is NIET opgenomen (ijs-botsing); deutsche bahn(2) alleen < 3 — was vóór de wijziging óók al geweerd
("NS verlengt noodregeling tot volgend jaar","De vervoerder kampt met personeelstekorten.",True),# ns(2) + vervoerder(1) = 3
# fix 2026-09-13: één term mag nooit via twee families dubbel wegen.
# Echte case: Omroep Brabant (13-09-2026) — politie-achtervolging van fatbikers
# 'via de busbaan' scoorde 2+2=4 omdat 'busbaan' in de bus-samenstelling-familie
# (fam 4) én in de halte/busbaan-familie (fam 13) staat. Nu één signaal (2):
# zonder tweede OV-term onder de drempel.
("Groep jonge fatbikers door Eindhoven achtervolgd na beroving met geweld",
"Agenten reden met zwaailichten achter de groep aan, onder meer via de busbaan en een fietspad.",False),
("De politie achtervolgde de groep via de busbaan","De achtervolging duurde enkele minuten.",True),# whitelist-titel-anker: 'busbaan' in de TITEL blijft door (+2); de merge raakt alleen de teller
# recall bewaakt: echt busbaan-nieuws blijft door (whitelist-titel-anker +2) en
# twee VERSCHILLENDE termen blijven samen tellen (geen over-merge).
("Busbaan wordt afgesloten voor het doorgaande verkeer","De omleiding duurt tot het voorjaar.",True),
("Bus rijdt om, busbaan is afgesloten","De bussen rijden via de wijk.",True),# bus(1) + busbaan(2) = 3
# refactor 2026-09-13 — positieve gevallen die de dienststoring-context én
("Metro rijdt niet tussen Rotterdam en Schiedam","De metro's stoppen na de storing.",True),# 'metro rijdt niet'-context (2) + metro(2)
("Buslijn 12 vervalt tijdelijk","De vervanging rijdt via de wijk.",True),# buslijn(2) + vervoer(1)
("Werkzaamheden aan perron","Het perron wordt nieuwe maand opgeknapt.",True),# perron-titel-anker (2+2)
("Nieuwe bushalte geopend","De halte ligt aan de rand van de wijk.",True),# bushalte-whitelist (2+2)
("Sprinters rijden niet tussen Utrecht en Amsterdam","De oorzaak is nog onbekend.",True),# dienststoring-context(2) + sprinter(1) = 3 — prompt: echte sprinter-context
("Stoptreinen rijden niet naar Den Haag vanwege een storing","De NS zet bussen in.",True),# 'stoptreinen rijden niet'(2) + trein(1) + ns(2)
# refactor 2026-09-13 — negatieve gevallen (prompt): ambigue woorden en
# straatnamen mogen niet zelfstandig voldoende zijn.
("Nieuwe woning bij station","",False),# woning naast station: station(1) < 3
("Restaurant opent nieuwe vestiging","Het restaurant ligt tegenover het station waar dagelijks veel reizigers komen.",False),# station+reizigers als bijzin — titel zegt niets over OV
("Stationstraat krijgt nieuwe woningen","",False),# straatnaam-inert (oudere blacklist-entries verwijderd, refactor 2026-09-13)
("Werkzaamheden aan de stationsweg","",False),
("Het stationsplein wordt heringericht voor de markt","",False),
("De auto stond stationair te draaien","",False),
("Nieuwe stationwagen van het merk","",False),
("Stationcar uit de jaren zestig","",False),
("De Metro publiceert vandaag een interview","De krant verschijnt wekelijks.",False),# krant 'De Metro': metro(2) alleen
("Tram is een populaire naam voor het café","",False),# café 'De Tram': tram(2) alleen
("Schoolbus betrokken bij ongeval","De kinderen werden opgevangen.",False),# schoolbus: 0 OV-hits
("Bus en station tijdelijk gesloten voor het feest","",False),# bus(1)+station(1) = 2 — geen auto-pass
("Wijkbewoners klagen over het station en de bussen","De gemeente komt langs.",False),# station(1)+bussen(1)=2 — bus+station is geen auto-pass
("Passagiers klagen over de incheckbalie","De balie opende te laat.",False),# passagiers(1)+inchecken(1) = 2
("Gemeente verhuist naar het stationsgebouw","Het station en het stationsgebied worden vernieuwd.",False),# semantische stapel: station-varianten tellen niet als 4 signalen — alleen station(1) in samenvatting
# combinaties (prompt): twee onafhankelijke context-signalen zijn genoeg,
# tenzij de context niet-OV is (bovenstaande negatieve cases).
("Bus en tram krijgen in december een nieuwe dienstregeling","",True),# bus(1)+dienstregeling(1)+tram(2) = 4 — echte OV-context van bus+dienstregeling
("Tram krijgt nieuwe dienstregeling","De wijziging geldt vanaf december.",True),# tram(2)+dienstregeling(1) = 3
("De halte wordt verplaatst voor de bus","De nieuwe halte komt bij de rotonde.",True),# halte(2)+bus(1) = 3
("Metro rijdt niet vanwege een storing","Er is onbekend wanneer het rijden hervat.",True),# metro(2)+storing(1) = 3 (prompt: metro+storing)
("Trein rijdt niet meer op de spoorlijn naar Den Bosch","Werkzaamheden duren tot maart.",True),# trein(1)+spoorlijn(2) = 3 (prompt: trein+spoorlijn)