<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Data Engineering on Boris Dupoux</title><link>https://borisdupoux.com/categories/data-engineering/</link><description>Recent content in Data Engineering on Boris Dupoux</description><generator>Hugo -- gohugo.io</generator><language>en-us</language><lastBuildDate>Thu, 01 Oct 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://borisdupoux.com/categories/data-engineering/index.xml" rel="self" type="application/rss+xml"/><item><title>Points of Interest and Urban Flows in Fort-de-France City Centre from Open Data</title><link>https://borisdupoux.com/research/urban-flows-fort-de-france/</link><pubDate>Thu, 01 Oct 2026 00:00:00 +0000</pubDate><guid>https://borisdupoux.com/research/urban-flows-fort-de-france/</guid><description>&lt;h2 id="context"&gt;&lt;a href="#context" class="header-anchor"&gt;&lt;/a&gt;Context
&lt;/h2&gt;&lt;p&gt;Fort-de-France (Martinique) has almost no local open data. The city&amp;rsquo;s own
datasets were last updated in 2019, and there are no public pedestrian
counters, open transit ridership figures or open road counts at
city-centre scale. The project asks how far national open data and
continuously archived feeds can go in describing the city centre as a system
of &lt;strong&gt;points of interest&lt;/strong&gt; (shops, administrations, schools, health, culture,
entry gates) and &lt;strong&gt;flows&lt;/strong&gt; (residents, commuters, pupils, visitors, cruise
passengers, vehicles, buses) over a ten-year horizon (2016–2026).&lt;/p&gt;
&lt;p&gt;No single source answers that question. The business register knows where
establishments are but not who visits them. The census knows where people
sleep but not where they spend the day. Transit feeds show the buses but not
their passengers, and cruise schedules show the ships but not the people
ashore. The approach is therefore to &lt;strong&gt;cross many heterogeneous sources&lt;/strong&gt; on
a common spatial and temporal frame, so that each one fills a blind spot of
the others, and to check the result against the few measured signals that do
exist.&lt;/p&gt;
&lt;h2 id="approach"&gt;&lt;a href="#approach" class="header-anchor"&gt;&lt;/a&gt;Approach
&lt;/h2&gt;&lt;p&gt;The method comes from an earlier knowledge-base project of mine. Raw data is
immutable and addressed by content. Every database is disposable and rebuilt
from it. Every value keeps its provenance, and a set of invariants is checked
on each rebuild.&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;flowchart TB
 A[Source catalogue&lt;br/&gt;sources.yaml] --&gt; B[Immutable raw store&lt;br/&gt;sha256 + JSONL manifest]
 C[Scheduled collectors&lt;br/&gt;Meta every 14 days · GTFS-RT 60 s] --&gt; B
 B --&gt; D[Versioned extractors&lt;br/&gt;normalised Parquet]
 D --&gt; E[DuckDB spatial warehouse&lt;br/&gt;rebuilt, never edited]
 E --&gt; F[QA invariants I1–I6]
 E --&gt; G[Statistics&lt;br/&gt;requetes/*.sql]
 E --&gt; H[Desktop application&lt;br/&gt;map + time selector]
 G --&gt; I[Calibration against&lt;br/&gt;measured signals]&lt;/pre&gt;&lt;h2 id="methods"&gt;&lt;a href="#methods" class="header-anchor"&gt;&lt;/a&gt;Methods
&lt;/h2&gt;&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Acquisition.&lt;/strong&gt; Each download is hashed (SHA-256) and logged in a JSONL
manifest, and acquisition is idempotent. National files larger than 1 GB
(Sirene business register) are read as remote HTTP-range extracts filtered
to the municipality. The filtering query is recorded as part of the
provenance.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Ephemeral sources.&lt;/strong&gt; Meta Movement Distribution keeps only 90 days online,
so a scheduled task archives it every 14 days. A bus collector polls GTFS-RT
vehicle positions every 60 s and predictions every 10 min, and zips each day
with a SHA-256 checksum.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Spatial frame.&lt;/strong&gt; Data is stored in WGS84 and computed in EPSG:5490 on the
INSEE 200 m grid. Points of interest are classified by one typology
(NAF codes, OSM tags, stops), with family, weight and an hourly rhythm over
seven time slots.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Models.&lt;/strong&gt; Hourly presence per grid cell combines residents (Filosofi
2021, distributed dasymetrically to buildings), employees, pupils and
tourists. A Huff model estimates catchment. Cruise passengers onshore are
modelled from ship schedules and capacities. An island-wide gravity model
with BPR assignment covers visitors and through-traffic.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Quality.&lt;/strong&gt; Six invariants are checked, from provenance to the absence of
personal data. Unconfirmed parameters go in a decision log.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="data-sources"&gt;&lt;a href="#data-sources" class="header-anchor"&gt;&lt;/a&gt;Data sources
&lt;/h2&gt;&lt;p&gt;Crossing the sources is the core of the project. Business registers (Sirene,
BODACC, URSSAF) give the stock and turnover of activities, but say nothing
about the people around them. The census and tax data (RP, Filosofi) place
residents, their income and housing vacancy, but only at night. Commuting
files and the national travel survey (MOBPRO, MOBSCO, EMP) say who travels,
why and when. Transit, accident and air-quality feeds (GTFS-RT, BAAC,
Madininair) are the only measured traces of movement. Cruise schedules and
ship capacities bring a population that no census counts. The geographic
frames (IGN, OpenStreetMap, the INSEE 200 m grid) are what make all of these
comparable cell by cell.&lt;/p&gt;
&lt;p&gt;The catalogue lists &lt;strong&gt;120 sources&lt;/strong&gt;: 51 integrated, 32 to integrate, 7 to
verify, 13 available on request only, 5 paid, 1 by partnership and 11 not
retained. The tables below list every integrated source and summarise the
others by theme.&lt;/p&gt;
&lt;h3 id="businesses-jobs--amenities"&gt;&lt;a href="#businesses-jobs--amenities" class="header-anchor"&gt;&lt;/a&gt;Businesses, jobs &amp;amp; amenities
&lt;/h3&gt;&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;Source&lt;/th&gt;
					&lt;th&gt;What it contributes&lt;/th&gt;
					&lt;th&gt;Period / frequency&lt;/th&gt;
					&lt;th&gt;Status&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;a class="link" href="https://www.data.gouv.fr/fr/datasets/base-sirene-des-entreprises-et-de-leurs-etablissements-siren-siret/" target="_blank" rel="noopener"
 &gt;Sirene business register&lt;/a&gt; — INSEE&lt;/td&gt;
					&lt;td&gt;Establishments, activity code (NAF), creation and closure dates, headcount bands&lt;/td&gt;
					&lt;td&gt;Full history; stock rebuilt at any date 2016–2026&lt;/td&gt;
					&lt;td&gt;Integrated&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;BODACC — DILA&lt;/td&gt;
					&lt;td&gt;Business turnover and failures (sales of goodwill, insolvency proceedings)&lt;/td&gt;
					&lt;td&gt;2016–2026, daily&lt;/td&gt;
					&lt;td&gt;Integrated&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;a class="link" href="https://www.insee.fr/fr/statistiques/8217525" target="_blank" rel="noopener"
 &gt;Permanent amenities database (BPE)&lt;/a&gt;, geolocated — INSEE&lt;/td&gt;
					&lt;td&gt;Shops, services, health, administrations, education, sport, tourism&lt;/td&gt;
					&lt;td&gt;2024 and 2025 editions, annual&lt;/td&gt;
					&lt;td&gt;Integrated&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;a class="link" href="https://open.urssaf.fr/explore/dataset/etablissements-et-effectifs-salaries-au-niveau-commune-x-ape-last/" target="_blank" rel="noopener"
 &gt;Establishments and salaried headcount by municipality × activity&lt;/a&gt; — URSSAF / Acoss&lt;/td&gt;
					&lt;td&gt;Private salaried employment by activity&lt;/td&gt;
					&lt;td&gt;2006–2025, annual&lt;/td&gt;
					&lt;td&gt;Integrated&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;a class="link" href="https://data.education.gouv.fr/explore/dataset/fr-en-annuaire-education/" target="_blank" rel="noopener"
 &gt;Education directory and pupil numbers&lt;/a&gt; — MENJ / DEPP&lt;/td&gt;
					&lt;td&gt;Schools, location, pupils per school year, staff&lt;/td&gt;
					&lt;td&gt;Pupil numbers 2016–2025&lt;/td&gt;
					&lt;td&gt;Integrated&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;FINESS — DREES / ARS&lt;/td&gt;
					&lt;td&gt;Health and social facilities, pharmacies&lt;/td&gt;
					&lt;td&gt;Extract of May 2026, bimonthly&lt;/td&gt;
					&lt;td&gt;Integrated&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;a class="link" href="https://docs.overturemaps.org/guides/places" target="_blank" rel="noopener"
 &gt;Overture Maps places&lt;/a&gt; — Overture Maps Foundation&lt;/td&gt;
					&lt;td&gt;Points of interest with a confidence score (comparison layer)&lt;/td&gt;
					&lt;td&gt;Frozen extract of release 2026-09-23, monthly&lt;/td&gt;
					&lt;td&gt;Integrated&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;OpenStreetMap (Overpass) — OpenStreetMap contributors&lt;/td&gt;
					&lt;td&gt;Points of interest, shops, streets, pedestrian paths&lt;/td&gt;
					&lt;td&gt;Current state&lt;/td&gt;
					&lt;td&gt;Integrated&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Historic monuments (Mérimée / POP) — Ministry of Culture&lt;/td&gt;
					&lt;td&gt;Heritage attractors (cathedral, Schœlcher library, Fort Saint-Louis)&lt;/td&gt;
					&lt;td&gt;Current&lt;/td&gt;
					&lt;td&gt;Integrated&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;em&gt;23 more&lt;/em&gt; — e.g. Flores localised employment (INSEE), Foursquare Open Source Places, LOCOMVAC vacant commercial premises (DGFiP), Google Places&lt;/td&gt;
					&lt;td&gt;&lt;/td&gt;
					&lt;td&gt;&lt;/td&gt;
					&lt;td&gt;Catalogued&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 id="population-income--housing"&gt;&lt;a href="#population-income--housing" class="header-anchor"&gt;&lt;/a&gt;Population, income &amp;amp; housing
&lt;/h3&gt;&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;Source&lt;/th&gt;
					&lt;th&gt;What it contributes&lt;/th&gt;
					&lt;th&gt;Period / frequency&lt;/th&gt;
					&lt;th&gt;Status&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;a class="link" href="https://www.insee.fr/fr/statistiques/8647014" target="_blank" rel="noopener"
 &gt;Census, IRIS-level files (RP)&lt;/a&gt; — INSEE&lt;/td&gt;
					&lt;td&gt;Residents, age, occupations, dwellings (vacancy, car ownership), workforce&lt;/td&gt;
					&lt;td&gt;RP 2016 → RP 2022, annual&lt;/td&gt;
					&lt;td&gt;Integrated&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;a class="link" href="https://www.insee.fr/fr/statistiques/8229323" target="_blank" rel="noopener"
 &gt;Filosofi by IRIS&lt;/a&gt; — INSEE&lt;/td&gt;
					&lt;td&gt;Disposable and declared income, poverty&lt;/td&gt;
					&lt;td&gt;2016–2021, annual&lt;/td&gt;
					&lt;td&gt;Integrated&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;a class="link" href="https://www.insee.fr/fr/statistiques/fichier/8735162/Filosofi2021_carreaux_200m_csv.zip" target="_blank" rel="noopener"
 &gt;Filosofi 200 m grid files&lt;/a&gt; — INSEE&lt;/td&gt;
					&lt;td&gt;Tax households, individuals, poverty, social housing and age per 200 m cell&lt;/td&gt;
					&lt;td&gt;2015, 2017, 2019, 2021&lt;/td&gt;
					&lt;td&gt;Integrated&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Filosofi 200 m grid, Géoplateforme WFS layer — INSEE&lt;/td&gt;
					&lt;td&gt;Grid cell geometries&lt;/td&gt;
					&lt;td&gt;2015 layer&lt;/td&gt;
					&lt;td&gt;Integrated&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;a class="link" href="https://www.data.gouv.fr/datasets/536998cba3a729239d20505e" target="_blank" rel="noopener"
 &gt;IRCOM income tax by municipality&lt;/a&gt; — DGFiP&lt;/td&gt;
					&lt;td&gt;Tax households, declared income&lt;/td&gt;
					&lt;td&gt;2016–2024, annual&lt;/td&gt;
					&lt;td&gt;Integrated&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;a class="link" href="https://data.caf.fr/explore/dataset/s_ben_com_f/information/" target="_blank" rel="noopener"
 &gt;Cafdata&lt;/a&gt; — CNAF&lt;/td&gt;
					&lt;td&gt;Recipients of minimum income, housing and family benefits&lt;/td&gt;
					&lt;td&gt;2020–2024 (2022 missing), annual&lt;/td&gt;
					&lt;td&gt;Integrated&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;a class="link" href="https://www.data.gouv.fr/datasets/5a561801c751df42d7fca9b6" target="_blank" rel="noopener"
 &gt;Priority neighbourhoods (QPV)&lt;/a&gt; — ANCT / INSEE&lt;/td&gt;
					&lt;td&gt;Priority urban policy areas, which include the city centre&lt;/td&gt;
					&lt;td&gt;2024 boundaries&lt;/td&gt;
					&lt;td&gt;Integrated&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;a class="link" href="https://www.data.gouv.fr/datasets/61816c6e23197bb34835228e" target="_blank" rel="noopener"
 &gt;LOVAC&lt;/a&gt; — Cerema / DGALN&lt;/td&gt;
					&lt;td&gt;Short-term and structural (≥ 2 years) vacancy of private housing&lt;/td&gt;
					&lt;td&gt;2020–2026 editions, annual&lt;/td&gt;
					&lt;td&gt;Integrated&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;RPLS social housing — SDES&lt;/td&gt;
					&lt;td&gt;Social dwellings (address, surface, rent, vacancy)&lt;/td&gt;
					&lt;td&gt;2023 and 2025&lt;/td&gt;
					&lt;td&gt;Integrated&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Sitadel building permits — SDES&lt;/td&gt;
					&lt;td&gt;Dwellings and premises authorised, started, demolished&lt;/td&gt;
					&lt;td&gt;2013–2026, monthly&lt;/td&gt;
					&lt;td&gt;Integrated&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;a class="link" href="https://files.data.gouv.fr/geo-dvf/latest/csv/" target="_blank" rel="noopener"
 &gt;Geolocated DVF&lt;/a&gt; — DGFiP / Etalab&lt;/td&gt;
					&lt;td&gt;Property transactions (price, surface, commercial premises)&lt;/td&gt;
					&lt;td&gt;2021–2025, rolling 5 years&lt;/td&gt;
					&lt;td&gt;Integrated&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;em&gt;8 more&lt;/em&gt; — e.g. census detail files and legal populations (INSEE), DV3F and land files (Cerema / DGFiP), WorldPop / GHSL gridded population&lt;/td&gt;
					&lt;td&gt;&lt;/td&gt;
					&lt;td&gt;&lt;/td&gt;
					&lt;td&gt;Catalogued&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 id="mobility--transport"&gt;&lt;a href="#mobility--transport" class="header-anchor"&gt;&lt;/a&gt;Mobility &amp;amp; transport
&lt;/h3&gt;&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;Source&lt;/th&gt;
					&lt;th&gt;What it contributes&lt;/th&gt;
					&lt;th&gt;Period / frequency&lt;/th&gt;
					&lt;th&gt;Status&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;a class="link" href="https://www.insee.fr/fr/statistiques/8582949" target="_blank" rel="noopener"
 &gt;Commuting flows (MOBPRO)&lt;/a&gt; — INSEE&lt;/td&gt;
					&lt;td&gt;Home → work flows between municipalities&lt;/td&gt;
					&lt;td&gt;2016–2022, annual&lt;/td&gt;
					&lt;td&gt;Integrated&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;a class="link" href="https://www.insee.fr/fr/statistiques/8582969" target="_blank" rel="noopener"
 &gt;School commuting (MOBSCO)&lt;/a&gt; — INSEE&lt;/td&gt;
					&lt;td&gt;Home → study flows between municipalities&lt;/td&gt;
					&lt;td&gt;2016–2022, annual&lt;/td&gt;
					&lt;td&gt;Integrated&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;a class="link" href="https://www.data.gouv.fr/datasets/donnees-detaillees-de-lenquete-mobilite-des-personnes-2018-2019" target="_blank" rel="noopener"
 &gt;National travel survey 2018–2019 (EMP)&lt;/a&gt; — SDES&lt;/td&gt;
					&lt;td&gt;Trip purposes, departure and return times, activity durations, distances&lt;/td&gt;
					&lt;td&gt;2018–2019&lt;/td&gt;
					&lt;td&gt;Integrated&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;a class="link" href="https://transport.data.gouv.fr/datasets/gtfs-urbain-de-la-zone-centre" target="_blank" rel="noopener"
 &gt;GTFS, urban network Centre zone&lt;/a&gt; (Mozaïk, BRT lines) — Martinique Transport&lt;/td&gt;
					&lt;td&gt;Scheduled transit supply&lt;/td&gt;
					&lt;td&gt;Archives since June 2023&lt;/td&gt;
					&lt;td&gt;Integrated&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;GTFS-RT, continuous collection — Martinique Transport&lt;/td&gt;
					&lt;td&gt;Observed bus speeds, buses in service, declared occupancy&lt;/td&gt;
					&lt;td&gt;Since 7 October 2026; 60 s (positions), 10 min (predictions)&lt;/td&gt;
					&lt;td&gt;Integrated&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;a class="link" href="https://transport.data.gouv.fr/datasets/gtfs-du-reseau-maritime-de-martinique" target="_blank" rel="noopener"
 &gt;GTFS, ferry shuttles&lt;/a&gt; — Martinique Transport&lt;/td&gt;
					&lt;td&gt;Shuttles landing on the waterfront&lt;/td&gt;
					&lt;td&gt;Versions since August 2024&lt;/td&gt;
					&lt;td&gt;Integrated&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;a class="link" href="https://www.data.gouv.fr/datasets/53698f4ca3a729239d2036df/" target="_blank" rel="noopener"
 &gt;Road injury accidents (BAAC)&lt;/a&gt; — ONISR&lt;/td&gt;
					&lt;td&gt;Geolocated accidents (pedestrians, vehicles), with time&lt;/td&gt;
					&lt;td&gt;2016–2024 loaded&lt;/td&gt;
					&lt;td&gt;Integrated&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;a class="link" href="https://data.humdata.org/dataset/movement-distribution" target="_blank" rel="noopener"
 &gt;Movement Distribution&lt;/a&gt; — Meta Data for Good / HDX&lt;/td&gt;
					&lt;td&gt;Share of users at home, within 10 km, 10–100 km, beyond 100 km&lt;/td&gt;
					&lt;td&gt;Daily; 90-day window, archived every 14 days&lt;/td&gt;
					&lt;td&gt;Integrated&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;a class="link" href="https://data.humdata.org/dataset/movement-range-maps" target="_blank" rel="noopener"
 &gt;Movement Range Maps&lt;/a&gt; — Meta Data for Good / HDX&lt;/td&gt;
					&lt;td&gt;Change in movement, share of users staying put&lt;/td&gt;
					&lt;td&gt;March 2020 → May 2022, daily (discontinued)&lt;/td&gt;
					&lt;td&gt;Integrated&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;BD TOPO main road network — IGN&lt;/td&gt;
					&lt;td&gt;Island road graph for traffic assignment&lt;/td&gt;
					&lt;td&gt;Current&lt;/td&gt;
					&lt;td&gt;Integrated&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;BD TOPO road sections — IGN&lt;/td&gt;
					&lt;td&gt;Carriageway width, lanes and direction, used to measure kerbside parking&lt;/td&gt;
					&lt;td&gt;Current&lt;/td&gt;
					&lt;td&gt;Integrated&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;OpenStreetMap car parks — OpenStreetMap contributors&lt;/td&gt;
					&lt;td&gt;Car parks, type, declared capacity&lt;/td&gt;
					&lt;td&gt;Current&lt;/td&gt;
					&lt;td&gt;Integrated&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;em&gt;18 more&lt;/em&gt; — e.g. ridership and ticket validations (Martinique Transport, on request), road counts (on request), Orange Flux Vision mobile data (paid), Waze for Cities / Strava Metro (partnership), a pedestrian counting campaign (to be created)&lt;/td&gt;
					&lt;td&gt;&lt;/td&gt;
					&lt;td&gt;&lt;/td&gt;
					&lt;td&gt;Catalogued&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 id="tourism--cruise"&gt;&lt;a href="#tourism--cruise" class="header-anchor"&gt;&lt;/a&gt;Tourism &amp;amp; cruise
&lt;/h3&gt;&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;Source&lt;/th&gt;
					&lt;th&gt;What it contributes&lt;/th&gt;
					&lt;th&gt;Period / frequency&lt;/th&gt;
					&lt;th&gt;Status&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;a class="link" href="https://www.cruisemapper.com/ports/fort-de-france-port-2382" target="_blank" rel="noopener"
 &gt;Cruise call schedule, port of Fort-de-France&lt;/a&gt; — CruiseMapper&lt;/td&gt;
					&lt;td&gt;Ship-by-ship calls with arrival and departure times&lt;/td&gt;
					&lt;td&gt;March 2026 → March 2029; archived monthly, as past months disappear&lt;/td&gt;
					&lt;td&gt;Integrated&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;a class="link" href="https://www.cruisemapper.com/ships" target="_blank" rel="noopener"
 &gt;Ship pages&lt;/a&gt; — CruiseMapper&lt;/td&gt;
					&lt;td&gt;Passenger capacity, crew, tonnage of ships in port&lt;/td&gt;
					&lt;td&gt;Current&lt;/td&gt;
					&lt;td&gt;Integrated&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Cruise season reviews — Martinique Tourism Authority (CMT), via the press&lt;/td&gt;
					&lt;td&gt;Passengers and calls per season, cruise terminals&lt;/td&gt;
					&lt;td&gt;Seasons 2024–25 and 2025–26&lt;/td&gt;
					&lt;td&gt;Integrated&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;a class="link" href="https://data.humdata.org/dataset/martinique-daily-port-activity-data-and-shipment-estimates" target="_blank" rel="noopener"
 &gt;PortWatch&lt;/a&gt; — IMF / HDX&lt;/td&gt;
					&lt;td&gt;Daily port calls from AIS&lt;/td&gt;
					&lt;td&gt;2019–2026, daily&lt;/td&gt;
					&lt;td&gt;Integrated&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;a class="link" href="https://www.data.gouv.fr/datasets/64808a5811da153f1439914c" target="_blank" rel="noopener"
 &gt;Monthly commercial air traffic&lt;/a&gt; — DGAC&lt;/td&gt;
					&lt;td&gt;Arrivals at Martinique Aimé Césaire airport (tourist seasonality)&lt;/td&gt;
					&lt;td&gt;1990–2025, monthly&lt;/td&gt;
					&lt;td&gt;Integrated&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;a class="link" href="https://www.data.gouv.fr/datasets/hebergements-touristiques-classes-en-france/" target="_blank" rel="noopener"
 &gt;Classified tourist accommodation&lt;/a&gt; — Atout France&lt;/td&gt;
					&lt;td&gt;Classified hotels and residences, rooms and capacity&lt;/td&gt;
					&lt;td&gt;Current stock&lt;/td&gt;
					&lt;td&gt;Integrated&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;a class="link" href="https://www.insee.fr/fr/statistiques/2021703" target="_blank" rel="noopener"
 &gt;Tourist accommodation capacity&lt;/a&gt; — INSEE&lt;/td&gt;
					&lt;td&gt;Hotels by rating, campsites, residences, holiday villages&lt;/td&gt;
					&lt;td&gt;2015–2026, annual&lt;/td&gt;
					&lt;td&gt;Integrated&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;em&gt;7 more&lt;/em&gt; — e.g. DATAtourisme (ADN Tourisme), INSEE hotel occupancy survey, Inside Airbnb / AirDNA, port authority call data&lt;/td&gt;
					&lt;td&gt;&lt;/td&gt;
					&lt;td&gt;&lt;/td&gt;
					&lt;td&gt;Catalogued&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 id="context-weather-air-calendar-safety"&gt;&lt;a href="#context-weather-air-calendar-safety" class="header-anchor"&gt;&lt;/a&gt;Context: weather, air, calendar, safety
&lt;/h3&gt;&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;Source&lt;/th&gt;
					&lt;th&gt;What it contributes&lt;/th&gt;
					&lt;th&gt;Period / frequency&lt;/th&gt;
					&lt;th&gt;Status&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;a class="link" href="https://www.data.gouv.fr/datasets/donnees-climatologiques-de-base-horaires" target="_blank" rel="noopener"
 &gt;Climatological data&lt;/a&gt; — Météo-France&lt;/td&gt;
					&lt;td&gt;Rain, temperature, wind (Fort-de-France Desaix and Le Lamentin stations)&lt;/td&gt;
					&lt;td&gt;2010–2026, hourly / daily&lt;/td&gt;
					&lt;td&gt;Integrated&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Air quality — Madininair&lt;/td&gt;
					&lt;td&gt;NO₂ as a traffic proxy, plus PM10, PM2.5, O₃ and H₂S from sargassum&lt;/td&gt;
					&lt;td&gt;Hourly; monthly NO₂ 2016–2026&lt;/td&gt;
					&lt;td&gt;Integrated&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;a class="link" href="https://www.data.gouv.fr/datasets/bases-statistiques-communale-departementale-et-regionale-de-la-delinquance-enregistree-par-la-police-et-la-gendarmerie-nationales" target="_blank" rel="noopener"
 &gt;Recorded crime by municipality&lt;/a&gt; — SSMSI&lt;/td&gt;
					&lt;td&gt;Recorded offences&lt;/td&gt;
					&lt;td&gt;2016–2025, annual&lt;/td&gt;
					&lt;td&gt;Integrated&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Public holidays in Martinique — DINUM&lt;/td&gt;
					&lt;td&gt;Calendar, including 22 May&lt;/td&gt;
					&lt;td&gt;2006–2031&lt;/td&gt;
					&lt;td&gt;Integrated&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;a class="link" href="https://data.education.gouv.fr/explore/dataset/fr-en-calendrier-scolaire/" target="_blank" rel="noopener"
 &gt;School calendar&lt;/a&gt; — MENJ&lt;/td&gt;
					&lt;td&gt;School holidays, including Carnival&lt;/td&gt;
					&lt;td&gt;2017–18 → 2026–27&lt;/td&gt;
					&lt;td&gt;Integrated&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Pageviews — Wikimedia Foundation&lt;/td&gt;
					&lt;td&gt;Online interest in Fort-de-France, Carnival and heritage&lt;/td&gt;
					&lt;td&gt;2016–2026, monthly&lt;/td&gt;
					&lt;td&gt;Integrated&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;em&gt;4 more&lt;/em&gt; — e.g. NASA Black Marble night lights, Google Trends / GDELT, an events agenda (to be compiled)&lt;/td&gt;
					&lt;td&gt;&lt;/td&gt;
					&lt;td&gt;&lt;/td&gt;
					&lt;td&gt;Catalogued&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 id="geography--base-maps"&gt;&lt;a href="#geography--base-maps" class="header-anchor"&gt;&lt;/a&gt;Geography &amp;amp; base maps
&lt;/h3&gt;&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;Source&lt;/th&gt;
					&lt;th&gt;What it contributes&lt;/th&gt;
					&lt;th&gt;Period / frequency&lt;/th&gt;
					&lt;th&gt;Status&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;Study perimeters — project&lt;/td&gt;
					&lt;td&gt;City centre and extended centre (provisional, see D1)&lt;/td&gt;
					&lt;td&gt;Version 0.1&lt;/td&gt;
					&lt;td&gt;Integrated&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Administrative boundaries API (geo.api.gouv.fr) — DINUM / IGN / INSEE&lt;/td&gt;
					&lt;td&gt;Municipal boundary and legal population&lt;/td&gt;
					&lt;td&gt;Current&lt;/td&gt;
					&lt;td&gt;Integrated&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;IRIS boundaries — IGN / INSEE&lt;/td&gt;
					&lt;td&gt;Sub-municipal census units&lt;/td&gt;
					&lt;td&gt;Current layer&lt;/td&gt;
					&lt;td&gt;Integrated&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;a class="link" href="https://cartes.gouv.fr/rechercher-une-donnee/dataset/IGNF_BD-TOPO" target="_blank" rel="noopener"
 &gt;BD TOPO&lt;/a&gt; — IGN&lt;/td&gt;
					&lt;td&gt;Buildings (height, use), roads, facilities&lt;/td&gt;
					&lt;td&gt;Quarterly editions&lt;/td&gt;
					&lt;td&gt;Integrated&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;BD TOPO residential buildings, extended centre — IGN&lt;/td&gt;
					&lt;td&gt;Use, height and footprint used to place households in buildings&lt;/td&gt;
					&lt;td&gt;Current edition&lt;/td&gt;
					&lt;td&gt;Integrated&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;OpenStreetMap vector base map — OpenStreetMap contributors&lt;/td&gt;
					&lt;td&gt;Streets, water, parks, coastline drawn in the application&lt;/td&gt;
					&lt;td&gt;Current&lt;/td&gt;
					&lt;td&gt;Integrated&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;em&gt;9 more&lt;/em&gt; — e.g. Etalab cadastre, National Address Base, BD ORTHO aerial imagery (IGN), local urban plan (Géoportail de l&amp;rsquo;Urbanisme), natural risk maps (Géorisques)&lt;/td&gt;
					&lt;td&gt;&lt;/td&gt;
					&lt;td&gt;&lt;/td&gt;
					&lt;td&gt;Catalogued&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 id="results"&gt;&lt;a href="#results" class="header-anchor"&gt;&lt;/a&gt;Results
&lt;/h2&gt;&lt;p&gt;The project started in October 2026 and is at an &lt;strong&gt;early, uncalibrated
stage&lt;/strong&gt; (application v0.6.0). All city-centre figures depend on a provisional
perimeter (see below). Each result below states which sources it crosses:
none of them could be produced from a single dataset.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Catalogue.&lt;/strong&gt; 120 sources are catalogued, of which 51 are integrated.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Warehouse (first build).&lt;/strong&gt; 117,161 entities, about 25,000 measures and
1,278 indicators, with all 6 invariants passing.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Retail decline vs residents (measured, registers × census × tax data).&lt;/strong&gt;
Active personal-goods establishments (Sirene) fell from 498 (2016) to 363
(2026), a drop of 27 %, and the city centre&amp;rsquo;s share of the municipality in
this sector fell from 75.5 % to 64.7 %. Over the same period, population in
the city-centre IRIS (census) fell from 13,444 (2016) to 12,898 (2022), and
housing vacancy rose from 24.9 % to 27.6 %. 47 % of city-centre households
are below the poverty line, against 30 % for the municipality (Filosofi
2021). Read together, the three sources show a centre losing shops,
residents and purchasing power at the same time.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Day/night rhythm (modelled, six sources crossed).&lt;/strong&gt; About 3,700 people
are present at night and 11,200 in the morning, a ratio of 3.0. Night-time
residents come from the Filosofi 200 m grid placed in BD TOPO buildings;
daytime workers from Sirene headcount bands; pupils from the education
directory; tourists from classified accommodation capacity. The morning
slot is adjusted on the hourly NO₂ profile of traffic stations.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Observed bus speed vs model (GTFS-RT × road graph × travel surveys).&lt;/strong&gt;
Over one full day, buses ran at 12.7 km/h at 05:00, falling to 7–8 km/h
during the day and 10.6 km/h at 19:00. The assignment model, built from BD
TOPO roads, MOBPRO commuting, EMP trip profiles and BPE amenities, predicts
a 20 % slowdown at peak hours, whereas the buses show 45 %. The measured
feed exposes what the model misses.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cruise check (schedules × ship capacities × reported counts).&lt;/strong&gt; Onshore
passengers are modelled from the CruiseMapper call schedule and ship
capacities. On the one documented cruise day (24 April 2026), the estimates
came within about 1–2 % of the passenger counts reported in the press
(3,075 vs 3,112, and 1,995 vs 1,969). The fill rate used in the model was
set on that same day, so this is a consistency check, not an independent
validation.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="limitations--next-steps"&gt;&lt;a href="#limitations--next-steps" class="header-anchor"&gt;&lt;/a&gt;Limitations &amp;amp; next steps
&lt;/h2&gt;&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;D1: the city-centre perimeter is provisional.&lt;/strong&gt; It is a rectangle around
the historic core plus a 1 km extended zone, used until an official
perimeter is provided. Every city-centre figure depends on it.&lt;/li&gt;
&lt;li&gt;No pedestrian, ridership or road-count data is open. Hourly rhythms,
attractiveness weights and parking assumptions are still uncalibrated. The
measured controls so far are hourly NO₂ at traffic stations and observed
bus speeds.&lt;/li&gt;
&lt;li&gt;The 2016–2026 business stock is rebuilt from creation and closure dates
only.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Next steps:&lt;/strong&gt; build up several months of GTFS-RT data for measured
congestion, request survey and counting data, and calibrate presence on
field counts.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="stack"&gt;&lt;a href="#stack" class="header-anchor"&gt;&lt;/a&gt;Stack
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;Python&lt;/code&gt; · &lt;code&gt;DuckDB&lt;/code&gt; (spatial, httpfs) · &lt;code&gt;Parquet&lt;/code&gt; · &lt;code&gt;SQL&lt;/code&gt; · &lt;code&gt;GTFS / GTFS-RT&lt;/code&gt; · &lt;code&gt;pytest&lt;/code&gt; · &lt;code&gt;ruff&lt;/code&gt; · &lt;code&gt;Electron&lt;/code&gt; · &lt;code&gt;React&lt;/code&gt; · &lt;code&gt;TypeScript&lt;/code&gt; · &lt;code&gt;ECharts&lt;/code&gt; · &lt;code&gt;Leaflet&lt;/code&gt; · &lt;code&gt;GitHub Actions&lt;/code&gt;&lt;/p&gt;</description></item><item><title>Codex — High-Density Physical Archiving of Media on Paper</title><link>https://borisdupoux.com/research/codex-paper-archive/</link><pubDate>Tue, 01 Sep 2026 00:00:00 +0000</pubDate><guid>https://borisdupoux.com/research/codex-paper-archive/</guid><description>&lt;h2 id="context"&gt;&lt;a href="#context" class="header-anchor"&gt;&lt;/a&gt;Context
&lt;/h2&gt;&lt;p&gt;Consumer digital media decay on the scale of years. Unpowered SSDs lose charge, optical discs delaminate, formats go out of use and accounts get closed. Paper can last for centuries, needs no electricity and can be checked by eye. The question is how much information an ordinary A4 sheet can hold if it must still be read back reliably with a standard flatbed scanner and no special hardware.&lt;/p&gt;
&lt;p&gt;The problem is hard because the print-and-scan channel is noisy in several ways at once. Toner spreads and clumps, density varies across the page, and scanners add rotation, anisotropic scaling, uneven paper feed, blur, vignetting and gamma. Paper also stretches non-linearly: at the target density, 0.2 % of stretch shifts the grid by about three cells. Common 2D codes such as QR and DataMatrix are designed for robustness rather than density. A long-term archive also has to remain decodable when today&amp;rsquo;s software no longer exists.&lt;/p&gt;
&lt;h2 id="approach"&gt;&lt;a href="#approach" class="header-anchor"&gt;&lt;/a&gt;Approach
&lt;/h2&gt;&lt;pre class="mermaid" style="visibility:hidden"&gt;flowchart TB
 A["Source media: photo, video, voice, text"] --&gt; B["Format-conservative reduction&lt;br/&gt;JPEG baseline · MJPEG/PCM AVI · PCM WAV · UTF-8"]
 B --&gt; C["Archive: tar + zlib + SHA-256"]
 C --&gt; D["Inter-page erasure code&lt;br/&gt;Cauchy MDS over GF(256): any K of N pages"]
 D --&gt; E["Per-block Reed–Solomon + CRC-32&lt;br/&gt;page-wide interleaving, triplicated header"]
 E --&gt; F["Multi-level cell rendering&lt;br/&gt;1-bit A4 PDF at 1200 dpi"]
 F --&gt; G["Print → scan&lt;br/&gt;(simulated channel)"]
 G --&gt; H["Fiducials → homography → local alignment markers"]
 H --&gt; I["Grey-level calibration from the page itself"]
 I --&gt; J["RS/CRC decode → erasure decode → SHA-256 check"]
 J --&gt; K["Byte-exact files, or partial recovery"]
 F -.-&gt; L["Printed appendix: format spec + decoder source in OCR-B with per-line CRC-16"]
 L -.-&gt; J&lt;/pre&gt;&lt;p&gt;&lt;img alt="A full encoded A4 data page from the photo archive, next to a printer-resolution close-up of its top-left corner showing the corner fiducial, the start of the header band and the first grey-level cells with an alignment marker" class="gallery-image" data-flex-basis="436px" data-flex-grow="181" height="880" loading="lazy" sizes="(max-width: 767px) calc(100vw - 30px), (max-width: 1023px) 700px, (max-width: 1279px) 950px, 1232px" src="https://borisdupoux.com/research/codex-paper-archive/codex-page-and-macro.png" srcset="https://borisdupoux.com/research/codex-paper-archive/codex-page-and-macro_hu_7e58d21b3fd6152e.png 800w, https://borisdupoux.com/research/codex-paper-archive/codex-page-and-macro.png 1600w" width="1600"&gt;
&lt;em&gt;Left: page 1 of 10 of the photo archive, as sent to the printer (A4, 1200 dpi, a grid of 1,145 × 1,576 cells), downscaled. Right: the boxed top-left corner at printer resolution (about 10 × 9 mm, each printer dot drawn as 2 × 2 pixels): the solid corner fiducial, the start of the header band, and the first data cells. Each cell is 8 × 8 dots (0.169 mm) and its number of inked dots encodes one of 4 levels. The square at the corner of the grid is an alignment marker.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;img alt="A page of the printed appendix: the start of the decoder file cdx_read.py, set in OCR-B, each line preceded by its number and a four-digit CRC-16" class="gallery-image" data-flex-basis="339px" data-flex-grow="141" height="778" loading="lazy" sizes="(max-width: 767px) calc(100vw - 30px), (max-width: 1023px) 700px, (max-width: 1279px) 950px, 1232px" src="https://borisdupoux.com/research/codex-paper-archive/codex-appendix-decoder-page.png" srcset="https://borisdupoux.com/research/codex-paper-archive/codex-appendix-decoder-page_hu_f0b09cea3a0e024f.png 800w, https://borisdupoux.com/research/codex-paper-archive/codex-appendix-decoder-page.png 1100w" width="1100"&gt;
&lt;em&gt;A page of the printed appendix (page 11 of 30): the start of &lt;code&gt;cdx_read.py&lt;/code&gt; (360 lines), set in OCR-B. Each line carries its number and its CRC-16, so a line misread by OCR can be found and checked by hand.&lt;/em&gt;&lt;/p&gt;
&lt;h2 id="methods"&gt;&lt;a href="#methods" class="header-anchor"&gt;&lt;/a&gt;Methods
&lt;/h2&gt;&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Multi-level cells instead of binary modules.&lt;/strong&gt; Each cell carries 2 bits. Cells are drawn as clustered dots at the printer&amp;rsquo;s native resolution, so the driver does no halftoning and produces no moiré.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Geometry recovery in stages.&lt;/strong&gt; Four corner fiducials, one of them hollow to fix orientation, give a global homography. Alignment markers spread across the page then absorb local, non-linear paper stretch.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Self-calibrating reader.&lt;/strong&gt; The decoder estimates the printed grey levels and spatial shading from calibration structures on each page, so it does not depend on the printer&amp;rsquo;s dot settings.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Two-level error control, a product code.&lt;/strong&gt; Reed–Solomon and CRC-32 inside each page correct random and burst errors such as stains and scratches. A Cauchy MDS erasure code across pages, decoded block by block, lets any K of N pages rebuild an archive.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Graceful failure.&lt;/strong&gt; If the SHA-256 check fails, the decoder still extracts every complete file that comes before the damaged region.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Self-describing archive.&lt;/strong&gt; A printed appendix contains the format specification, a reading procedure and the full decoder source in OCR-B, with a line number and CRC-16 on each line. The decoder needs only NumPy, Pillow and reedsolo. Text uses DEFLATE (zlib), which can be reimplemented in a few hundred lines, rather than the stronger LZMA.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Earlier prototype (&lt;code&gt;codex_proto/&lt;/code&gt;).&lt;/strong&gt; This version explored several physical media: paper, laser-marked stainless steel, intaglio plates and microfilm frames. It used 2- to 16-level cells, fountain-style parity pages, MLP-based grey-level readers, a printed optics calculator for reading microfilm, and lossy media reduction (parametric voice coding, wavelet-residual photo coding).&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="results"&gt;&lt;a href="#results" class="header-anchor"&gt;&lt;/a&gt;Results
&lt;/h2&gt;&lt;p&gt;All figures below come from &lt;strong&gt;simulation only&lt;/strong&gt;. The software models laser defects (spot blur, toner grain, dot gain, banding, non-uniform density, textured paper), scanner defects (±1.2° rotation, ±0.3 % x/y scale, feed wobble, blur, vignetting, noise, gamma 2.2), coffee stains, scratches, and &lt;strong&gt;one deliberately lost page per archive&lt;/strong&gt;. No physical print-and-scan test has been run yet.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Net payload:&lt;/strong&gt; 383,469 bytes per A4 page, about 59 bits/mm².&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;End-to-end:&lt;/strong&gt; 46 of 50 pages were scanned and all &lt;strong&gt;4 of 4 archives were restored byte-identical&lt;/strong&gt; (photo, video, voice, text). No Reed–Solomon codeword failed, and no inter-page block was lost.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Error rates:&lt;/strong&gt; clean pages needed about 0.007–0.008 % of bytes corrected. Pages with stains and/or scratches needed at most 0.90 %.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Archive sizes:&lt;/strong&gt; photo 2.86 MB → 10 pages (8 data + 2 parity); video (800×600, 24 s, MJPEG + 16 kHz PCM) 8.30 MB → 25 pages; voice 0.91 MB → 5 pages; text 5.58 MB → 10 pages.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Text:&lt;/strong&gt; the public-domain &lt;em&gt;Mémoires d&amp;rsquo;outre-tombe&lt;/em&gt; (5.43 M characters) would take 1,357 typeset A4 pages. Encoded, it takes 6 data pages, a ratio of about 226. zlib compresses this prose by 2.62 and LZMA by 3.58.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Partial recovery:&lt;/strong&gt; an archive truncated to a quarter of its length still returned 1.42 MB of the 5.58 MB of text.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Decoder robustness:&lt;/strong&gt; the decoder also worked on scans rotated 90° and 180°, on 600 dpi scans and on JPEG (q92) scans.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Decoder size:&lt;/strong&gt; 731 lines of Python for the printed decoder modules.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img alt="The same small region of a data page, left as encoded and right after the simulated print-and-scan channel, crossing the edge of a simulated coffee stain" class="gallery-image" data-flex-basis="616px" data-flex-grow="256" height="600" loading="lazy" sizes="(max-width: 767px) calc(100vw - 30px), (max-width: 1023px) 700px, (max-width: 1279px) 950px, 1232px" src="https://borisdupoux.com/research/codex-paper-archive/codex-encoded-vs-scan.png" srcset="https://borisdupoux.com/research/codex-paper-archive/codex-encoded-vs-scan_hu_25056d9200d46d29.png 800w, https://borisdupoux.com/research/codex-paper-archive/codex-encoded-vs-scan.png 1540w" width="1540"&gt;
&lt;em&gt;The same region of the photo page (380 × 300 dots, about 8 × 6.4 mm), enlarged. Left: as encoded. Right: after the &lt;strong&gt;simulated&lt;/strong&gt; print-and-scan channel, resampled onto the page grid through the fiducial homography. Toner spread, blur and noise soften the cells, and the edge of a simulated coffee stain darkens the left part. The reader calibrates the grey levels locally, so it still separates the four levels. In the simulation run reported above, stained or scratched pages needed at most 0.90 % of bytes corrected.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;img alt="Three frames of an underwater video, original on top and restored after encoding, simulated print-and-scan and decoding on the bottom" class="gallery-image" data-flex-basis="480px" data-flex-grow="200" height="792" loading="lazy" sizes="(max-width: 767px) calc(100vw - 30px), (max-width: 1023px) 700px, (max-width: 1279px) 950px, 1232px" src="https://borisdupoux.com/research/codex-paper-archive/codex-video-frames.jpg" srcset="https://borisdupoux.com/research/codex-paper-archive/codex-video-frames_hu_87a931ee2368d927.jpg 800w, https://borisdupoux.com/research/codex-paper-archive/codex-video-frames.jpg 1584w" width="1584"&gt;
&lt;em&gt;The video archive: an underwater clip (school of fish over a reef) used as test footage. Top: the original 3840 × 2880 HEVC frames at 3.2 s, 12.8 s and 16.0 s, downscaled. Bottom: the same instants decoded from the &lt;strong&gt;simulated&lt;/strong&gt; scans of the 25 pages (one deliberately lost). The paper channel itself loses nothing: the restored AVI is byte-identical to the archived one. What differs from the original is the reduction made before printing (800 × 600 MJPEG, 12.5 frames/s, 16 kHz mono sound) so that 24 s of video fit in 8.30 MB.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;img alt="Close-up of the same reef area, original video on the left and restored video on the right, showing JPEG blocking and loss of fine detail in the restored frame" class="gallery-image" data-flex-basis="648px" data-flex-grow="270" height="585" loading="lazy" sizes="(max-width: 767px) calc(100vw - 30px), (max-width: 1023px) 700px, (max-width: 1279px) 950px, 1232px" src="https://borisdupoux.com/research/codex-paper-archive/codex-video-detail.jpg" srcset="https://borisdupoux.com/research/codex-paper-archive/codex-video-detail_hu_ef2152090c0bf4cc.jpg 800w, https://borisdupoux.com/research/codex-paper-archive/codex-video-detail.jpg 1580w" width="1580"&gt;
&lt;em&gt;Close-up of the 12.8 s frame. Left: a 1152 × 864-pixel area of the original, downscaled. Right: the matching 240 × 180-pixel area of the restored frame, enlarged with no smoothing. The fish and the reef stay recognisable. Fine texture and colour detail are lost, and MJPEG block artefacts appear. This is the price of fitting the clip into 25 pages.&lt;/em&gt;&lt;/p&gt;
&lt;h2 id="limitations--next-steps"&gt;&lt;a href="#limitations--next-steps" class="header-anchor"&gt;&lt;/a&gt;Limitations &amp;amp; next steps
&lt;/h2&gt;&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;No physical test yet.&lt;/strong&gt; The cell dot patterns were tuned in simulation. The next step is a real 1200 dpi print with a 1200 dpi scan to set them and measure the actual error margin.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Small test set.&lt;/strong&gt; The simulation covers one run over four archives. A sweep across defect severity, scan resolution and paper stock is still needed.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Unmeasured physical properties.&lt;/strong&gt; Long-term ageing of paper and toner, and how practical it is to OCR the decoder back from paper, have not been measured.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Font dependency.&lt;/strong&gt; The appendix body text relies on a system font.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="stack"&gt;&lt;a href="#stack" class="header-anchor"&gt;&lt;/a&gt;Stack
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;Python&lt;/code&gt; · &lt;code&gt;NumPy&lt;/code&gt; · &lt;code&gt;SciPy&lt;/code&gt; · &lt;code&gt;Pillow&lt;/code&gt; · &lt;code&gt;reedsolo&lt;/code&gt; · &lt;code&gt;ReportLab&lt;/code&gt; · &lt;code&gt;soundfile&lt;/code&gt; · &lt;code&gt;ffmpeg&lt;/code&gt; · &lt;code&gt;Tesseract (OCR-B)&lt;/code&gt;&lt;/p&gt;</description></item><item><title>Explainable Processing Atoms for Documents &amp; Data</title><link>https://borisdupoux.com/research/processing-atoms/</link><pubDate>Tue, 01 Sep 2026 00:00:00 +0000</pubDate><guid>https://borisdupoux.com/research/processing-atoms/</guid><description>&lt;h2 id="context"&gt;&lt;a href="#context" class="header-anchor"&gt;&lt;/a&gt;Context
&lt;/h2&gt;&lt;p&gt;Production systems for document reconciliation and OCR, built for clients such as a
wholesale distributor and an accounting firm, left several codebases that each solved
the same sub-problems (page routing, OCR tuning, LLM extraction, field comparison)
with their own bugs. The atoms library consolidates them into one package. Its central
question is the one behind the original systems: &lt;strong&gt;can a document check be automated so
that a human can audit and contest every verdict?&lt;/strong&gt; Extracting a field is not enough. The
system must state whether two documents agree, why, and from what evidence.&lt;/p&gt;
&lt;h2 id="approach"&gt;&lt;a href="#approach" class="header-anchor"&gt;&lt;/a&gt;Approach
&lt;/h2&gt;&lt;p&gt;Everything is built from &lt;strong&gt;atoms&lt;/strong&gt;. An atom has one responsibility, typed inputs and
outputs, no global state, and injected dependencies (LLM client, OCR engine,
storage). A pipeline is a composition of atoms, either written in code or declared
as a YAML recipe that is validated when it loads, before anything runs.&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;flowchart TB
 A["Input documents (PDF, scans, CSV, mail)"] --&gt; B{"Page routing: native text or OCR?"}
 B -- "native" --&gt; C["Positioned native text"]
 B -- "scan" --&gt; D["PaddleOCR (production settings)"]
 C --&gt; E["Segmentation &amp; classification"]
 D --&gt; E
 E --&gt; F["LLM JSON extraction + verbatim grounding check"]
 F --&gt; G["Cross-document reconciliation (invoice / delivery note / order)"]
 G --&gt; H["Tri-state verdict + named reason + trace"]
 H --&gt; I["Report / human review"]&lt;/pre&gt;&lt;h2 id="the-catalogue"&gt;&lt;a href="#the-catalogue" class="header-anchor"&gt;&lt;/a&gt;The catalogue
&lt;/h2&gt;&lt;p&gt;The 459 atoms are grouped into 32 families, one Python package each, ordered along the
processing chain: &lt;strong&gt;capture&lt;/strong&gt; the world, &lt;strong&gt;understand&lt;/strong&gt; it, &lt;strong&gt;reconcile&lt;/strong&gt; and decide,
&lt;strong&gt;deliver&lt;/strong&gt;, then &lt;strong&gt;operate&lt;/strong&gt; in production. Two further packages assemble them: &lt;code&gt;steps&lt;/code&gt;
(77 named, parameterised steps) and &lt;code&gt;recipes&lt;/code&gt; (the YAML loader and validator).&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;Stage&lt;/th&gt;
					&lt;th&gt;Family&lt;/th&gt;
					&lt;th style="text-align: right"&gt;Atoms&lt;/th&gt;
					&lt;th&gt;Role&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;Base&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;core&lt;/code&gt;&lt;/td&gt;
					&lt;td style="text-align: right"&gt;25&lt;/td&gt;
					&lt;td&gt;pivot types, pipeline, configuration, resilience (retry, rate limits), files, concurrency&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Capture&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;connect&lt;/code&gt;&lt;/td&gt;
					&lt;td style="text-align: right"&gt;40&lt;/td&gt;
					&lt;td&gt;resilient HTTP, mailboxes (Graph, IMAP), SFTP, OneDrive, read-only ERP source, open data, audit trail&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;google&lt;/code&gt;&lt;/td&gt;
					&lt;td style="text-align: right"&gt;24&lt;/td&gt;
					&lt;td&gt;Gmail, Sheets, Drive, Apps Script, with resilient execution&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;web&lt;/code&gt;&lt;/td&gt;
					&lt;td style="text-align: right"&gt;17&lt;/td&gt;
					&lt;td&gt;driver-agnostic web robots (Selenium, Playwright): waits, resumable skeleton, forms&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;pdf&lt;/code&gt;&lt;/td&gt;
					&lt;td style="text-align: right"&gt;9&lt;/td&gt;
					&lt;td&gt;positioned native text, OCR-or-not decision, rendering, page handling, duplicates&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;ocr&lt;/code&gt;&lt;/td&gt;
					&lt;td style="text-align: right"&gt;9&lt;/td&gt;
					&lt;td&gt;PaddleOCR with production settings, EasyOCR&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;layout&lt;/code&gt;&lt;/td&gt;
					&lt;td style="text-align: right"&gt;3&lt;/td&gt;
					&lt;td&gt;page geometry: lines, columns, deskew, monospace rendering&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;audio&lt;/code&gt;&lt;/td&gt;
					&lt;td style="text-align: right"&gt;16&lt;/td&gt;
					&lt;td&gt;chunked transcription, segment post-processing, microphone ↔ screen alignment&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;vision&lt;/code&gt;&lt;/td&gt;
					&lt;td style="text-align: right"&gt;32&lt;/td&gt;
					&lt;td&gt;video / RTSP streams, detection, tracking, depth, segmentation, calibration&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;vlm&lt;/code&gt;&lt;/td&gt;
					&lt;td style="text-align: right"&gt;10&lt;/td&gt;
					&lt;td&gt;screen reading with vision-language models: change detection, label location&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;geo&lt;/code&gt;&lt;/td&gt;
					&lt;td style="text-align: right"&gt;11&lt;/td&gt;
					&lt;td&gt;geodesic measures, combined GPS fixes, line-distance filtering, spatial SQL&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;calendar&lt;/code&gt;&lt;/td&gt;
					&lt;td style="text-align: right"&gt;4&lt;/td&gt;
					&lt;td&gt;public holidays, working days and opening hours&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Understand&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;text&lt;/code&gt;&lt;/td&gt;
					&lt;td style="text-align: right"&gt;32&lt;/td&gt;
					&lt;td&gt;numbers and amounts, dates, normalisation, pattern-based fields, identifiers&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;segment&lt;/code&gt;&lt;/td&gt;
					&lt;td style="text-align: right"&gt;7&lt;/td&gt;
					&lt;td&gt;pages → logical documents, typed document numbers, page nature&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;classify&lt;/code&gt;&lt;/td&gt;
					&lt;td style="text-align: right"&gt;12&lt;/td&gt;
					&lt;td&gt;file name, sender, weighted patterns, TF-IDF, visual and LLM classifiers, cascades&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;llm&lt;/code&gt;&lt;/td&gt;
					&lt;td style="text-align: right"&gt;24&lt;/td&gt;
					&lt;td&gt;provider-agnostic contract, providers, pool, prompts, structured extraction&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;anchor&lt;/code&gt;&lt;/td&gt;
					&lt;td style="text-align: right"&gt;5&lt;/td&gt;
					&lt;td&gt;fields and tables located relative to printed labels, native text or OCR&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;kb&lt;/code&gt;&lt;/td&gt;
					&lt;td style="text-align: right"&gt;8&lt;/td&gt;
					&lt;td&gt;traced knowledge base: validated event log folded into a SQLite index&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Reconcile&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;match&lt;/code&gt;&lt;/td&gt;
					&lt;td style="text-align: right"&gt;25&lt;/td&gt;
					&lt;td&gt;EAN/GTIN, indexes, multi-criteria scores, optimal assignment, entity resolution&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;compare&lt;/code&gt;&lt;/td&gt;
					&lt;td style="text-align: right"&gt;9&lt;/td&gt;
					&lt;td&gt;tri-state, explainable comparators for fields, records and sets&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;check&lt;/code&gt;&lt;/td&gt;
					&lt;td style="text-align: right"&gt;9&lt;/td&gt;
					&lt;td&gt;generic tri-state checks: totals, VAT, balance, completeness, plausibility&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;reconcile&lt;/code&gt;&lt;/td&gt;
					&lt;td style="text-align: right"&gt;12&lt;/td&gt;
					&lt;td&gt;set pairing, order → delivery → invoice chains, N-way, ledger and bank matching&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;validate&lt;/code&gt;&lt;/td&gt;
					&lt;td style="text-align: right"&gt;14&lt;/td&gt;
					&lt;td&gt;rules engine, identifiers, amount consistency, corrected LLM verdicts, final decision&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;rules&lt;/code&gt;&lt;/td&gt;
					&lt;td style="text-align: right"&gt;8&lt;/td&gt;
					&lt;td&gt;safe condition language, explainable first-match tables, escalation, state machines&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;events&lt;/code&gt;&lt;/td&gt;
					&lt;td style="text-align: right"&gt;11&lt;/td&gt;
					&lt;td&gt;timestamped events: windows, expected-event watch, sessions, status transitions&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;analytics&lt;/code&gt;&lt;/td&gt;
					&lt;td style="text-align: right"&gt;18&lt;/td&gt;
					&lt;td&gt;supply-chain analytics without pandas: demand, stock-outs, service level, safety stock&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Deliver&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;export&lt;/code&gt;&lt;/td&gt;
					&lt;td style="text-align: right"&gt;8&lt;/td&gt;
					&lt;td&gt;CSV for French Excel, shared Excel workbooks, HTML / Markdown / PDF reports&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;report&lt;/code&gt;&lt;/td&gt;
					&lt;td style="text-align: right"&gt;6&lt;/td&gt;
					&lt;td&gt;persistable discrepancies, summaries, review queue&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;render&lt;/code&gt;&lt;/td&gt;
					&lt;td style="text-align: right"&gt;12&lt;/td&gt;
					&lt;td&gt;HTML deliverables: pre-rendered Mermaid, ECharts charts, GeoJSON maps&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;diagram&lt;/code&gt;&lt;/td&gt;
					&lt;td style="text-align: right"&gt;6&lt;/td&gt;
					&lt;td&gt;process diagrams: draw.io / BPMN JSON in, Mermaid and BPMN XML out&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Operate&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;ops&lt;/code&gt;&lt;/td&gt;
					&lt;td style="text-align: right"&gt;26&lt;/td&gt;
					&lt;td&gt;high availability (heartbeats, leader election), scheduled daemon, monitoring, pre-flight checks&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;qa&lt;/code&gt;&lt;/td&gt;
					&lt;td style="text-align: right"&gt;7&lt;/td&gt;
					&lt;td&gt;editorial non-regression (golden files), simulation replay, inter-annotator agreement&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 id="named-recipes"&gt;&lt;a href="#named-recipes" class="header-anchor"&gt;&lt;/a&gt;Named recipes
&lt;/h3&gt;&lt;p&gt;Each of the 17 recipes is a YAML list of steps, named &lt;code&gt;&amp;lt;family&amp;gt;.&amp;lt;atom&amp;gt;&lt;/code&gt;. Three examples:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;invoice_multi_doc&lt;/code&gt;&lt;/strong&gt;: a batch of multi-document PDFs becomes checked logical
documents. &lt;code&gt;io.collect&lt;/code&gt; → &lt;code&gt;pdf.read&lt;/code&gt; → &lt;code&gt;segment.documents&lt;/code&gt; → &lt;code&gt;classify.documents&lt;/code&gt; →
&lt;code&gt;llm.extract&lt;/code&gt; → &lt;code&gt;validate.ids&lt;/code&gt; → &lt;code&gt;validate.amounts&lt;/code&gt; → &lt;code&gt;validate.decide&lt;/code&gt; → &lt;code&gt;export.csv&lt;/code&gt;
→ &lt;code&gt;google.sheets.upsert&lt;/code&gt; → &lt;code&gt;connect.notify&lt;/code&gt;. Each document ends as valid, to check, or invalid.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;doc_reconciliation&lt;/code&gt;&lt;/strong&gt;: a file of invoices, credit notes and delivery notes, checked
against orders. Guarded LLM extraction (&lt;code&gt;llm.extract_docs&lt;/code&gt;) is cross-checked with
pattern extraction (&lt;code&gt;extract.regex&lt;/code&gt;, &lt;code&gt;compare.extractions&lt;/code&gt;, &lt;code&gt;reconcile.fields&lt;/code&gt;), then
&lt;code&gt;check.document&lt;/code&gt; and &lt;code&gt;check.completeness&lt;/code&gt;, lines against the order reference
(&lt;code&gt;compare.reference&lt;/code&gt;), the order → delivery → invoice chain (&lt;code&gt;reconcile.nway&lt;/code&gt;), and a
discrepancy file plus review queue (&lt;code&gt;report.discrepancies&lt;/code&gt;, &lt;code&gt;report.review&lt;/code&gt;). 17 steps.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;meeting_transcript&lt;/code&gt;&lt;/strong&gt;: a meeting recording becomes a timestamped transcript with
speakers. &lt;code&gt;audio.decode&lt;/code&gt; → &lt;code&gt;audio.vad&lt;/code&gt; → &lt;code&gt;audio.transcribe&lt;/code&gt; → &lt;code&gt;audio.diarize&lt;/code&gt; →
&lt;code&gt;audio.speakers&lt;/code&gt; → &lt;code&gt;audio.segments&lt;/code&gt; → &lt;code&gt;export.csv&lt;/code&gt; → &lt;code&gt;export.transcript&lt;/code&gt; → &lt;code&gt;connect.notify&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The others include &lt;code&gt;delivery_vs_order&lt;/code&gt;, &lt;code&gt;ledger_reconciliation&lt;/code&gt;, &lt;code&gt;invoice_dedup&lt;/code&gt;,
&lt;code&gt;article_base_cleaning&lt;/code&gt;, &lt;code&gt;stock_policy&lt;/code&gt;, &lt;code&gt;mail_to_sheet&lt;/code&gt;, &lt;code&gt;draft_mails&lt;/code&gt;,
&lt;code&gt;pdf_text_extract&lt;/code&gt;, &lt;code&gt;rubric_eval&lt;/code&gt;, &lt;code&gt;screen_session&lt;/code&gt;, &lt;code&gt;camera_flow&lt;/code&gt; and
&lt;code&gt;process_from_transcript&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id="the-atlas"&gt;&lt;a href="#the-atlas" class="header-anchor"&gt;&lt;/a&gt;The Atlas
&lt;/h2&gt;&lt;p&gt;The Atlas is a single offline HTML page generated from the code itself (syntax trees and
YAML), with no hand-written data. It draws the catalogue as a periodic table: each family
is an element with a symbol, each module an atom, each recipe a molecule. It also flags
atoms that lack documentation, tests or a usage example. The interface is in French.&lt;/p&gt;
&lt;p&gt;&lt;img alt="Atlas, “Capture” series: one element per family (connect, google, web, pdf, ocr, layout, audio, vision, vlm, geo, calendar), with its atoms listed below" class="gallery-image" data-flex-basis="367px" data-flex-grow="153" height="920" loading="lazy" sizes="(max-width: 767px) calc(100vw - 30px), (max-width: 1023px) 700px, (max-width: 1279px) 950px, 1232px" src="https://borisdupoux.com/research/processing-atoms/atlas-capture-series.png" srcset="https://borisdupoux.com/research/processing-atoms/atlas-capture-series_hu_dfcfb778a533826c.png 800w, https://borisdupoux.com/research/processing-atoms/atlas-capture-series.png 1408w" width="1408"&gt;
&lt;em&gt;The &amp;ldquo;Capture&amp;rdquo; series of the periodic table. Each box is a family, with its symbol, number of modules and size in thousands of lines; each tile below it is an atom.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;img alt="Atlas, “Reconcile” series: match, compare, check, reconcile, validate, rules, events, analytics" class="gallery-image" data-flex-basis="536px" data-flex-grow="223" height="630" loading="lazy" sizes="(max-width: 767px) calc(100vw - 30px), (max-width: 1023px) 700px, (max-width: 1279px) 950px, 1232px" src="https://borisdupoux.com/research/processing-atoms/atlas-reconcile-series.png" srcset="https://borisdupoux.com/research/processing-atoms/atlas-reconcile-series_hu_872c082d4171f5de.png 800w, https://borisdupoux.com/research/processing-atoms/atlas-reconcile-series.png 1408w" width="1408"&gt;
&lt;em&gt;The &amp;ldquo;Reconcile&amp;rdquo; series: the families that pair, compare, check and decide.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;img alt="Atlas page for the compare.fields atom: purpose, dominance rules and full function signature" class="gallery-image" data-flex-basis="170px" data-flex-grow="71" height="900" loading="lazy" sizes="(max-width: 767px) calc(100vw - 30px), (max-width: 1023px) 700px, (max-width: 1279px) 950px, 1232px" src="https://borisdupoux.com/research/processing-atoms/atlas-atom-compare-fields.png" width="640"&gt;
&lt;em&gt;An atom&amp;rsquo;s page (&lt;code&gt;compare.fields&lt;/code&gt;): what it does, taken from its docstring, then each public name with its full signature.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;img alt="Atlas view of the invoice_multi_doc recipe as a molecule: 11 steps linked by the context keys they pass on" class="gallery-image" data-flex-basis="590px" data-flex-grow="246" height="572" loading="lazy" sizes="(max-width: 767px) calc(100vw - 30px), (max-width: 1023px) 700px, (max-width: 1279px) 950px, 1232px" src="https://borisdupoux.com/research/processing-atoms/atlas-recipe-invoice-multi-doc.png" srcset="https://borisdupoux.com/research/processing-atoms/atlas-recipe-invoice-multi-doc_hu_9f54a6aa2766961a.png 800w, https://borisdupoux.com/research/processing-atoms/atlas-recipe-invoice-multi-doc.png 1408w" width="1408"&gt;
&lt;em&gt;The &lt;code&gt;invoice_multi_doc&lt;/code&gt; recipe as a molecule: 11 steps, the context keys that link them, and the services they need (dashed when optional).&lt;/em&gt;&lt;/p&gt;
&lt;h2 id="methods"&gt;&lt;a href="#methods" class="header-anchor"&gt;&lt;/a&gt;Methods
&lt;/h2&gt;&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Tri-state results.&lt;/strong&gt; Every comparison returns &lt;code&gt;ok ∈ {True, False, None}&lt;/code&gt;
(conforms / discrepancy / inconclusive) with a named reason code and a trace. A
missing or unreadable value counts as inconclusive, never as an anomaly. Inconclusive
results are still counted, not silently dropped.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Hybrid native-text/OCR routing.&lt;/strong&gt; Each PDF page is classified as native, OCR or
empty, based on how much native text it holds and how much of it is covered by
images. OCR runs only where it is needed.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Grounded LLM extraction.&lt;/strong&gt; Structured JSON extraction is validated against
Pydantic schemas. Each extracted value is then checked verbatim against the source
text, so altered or invented values are flagged.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cross-document reconciliation.&lt;/strong&gt; Invoices, delivery notes and orders are compared
line by line using named tolerances and dominance rules. For example, a matching
line amount can override a price difference.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Provenance.&lt;/strong&gt; Every ported atom carries a &lt;code&gt;Provenance:&lt;/code&gt; header and an entry tracing
it to &lt;code&gt;repo@commit:path:lines&lt;/code&gt; in the original production code. Bugs found while
porting were fixed and each fix is covered by a non-regression test.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Enforced architecture.&lt;/strong&gt; Layering is checked by 30 import contracts: an atom
imports only the &lt;code&gt;core&lt;/code&gt; package. Strict typing, a ≥ 80 % coverage gate and a check that
no client names appear in source code are also enforced.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Device abstraction.&lt;/strong&gt; Vision and audio atoms target &lt;code&gt;cuda&lt;/code&gt;, &lt;code&gt;mps&lt;/code&gt; or &lt;code&gt;cpu&lt;/code&gt; via one setting.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;End-to-end evaluation.&lt;/strong&gt; Tests run with real engines (PaddleOCR, Ollama &lt;code&gt;qwen3:8b&lt;/code&gt;
and &lt;code&gt;qwen3-vl:8b&lt;/code&gt;) on data with known ground truth. Each metric is recorded against a
threshold in a JSON/HTML report. Metrics that miss their threshold are kept and shown
as failing.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="results"&gt;&lt;a href="#results" class="header-anchor"&gt;&lt;/a&gt;Results
&lt;/h2&gt;&lt;p&gt;Codebase (current tree): 459 atom modules in 32 families, 77 parameterised steps,
17 YAML recipes, 30 import contracts. The suite collects 2,959 unit and recipe tests,
plus 109 end-to-end tests that run separately. All 17 recipes have at least one
end-to-end test, and 34 of the 35 Atlas elements are exercised end to end.&lt;/p&gt;
&lt;p&gt;Selected end-to-end scores (synthetic documents with known ground truth, local 8B models):&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;Task&lt;/th&gt;
					&lt;th&gt;Engine&lt;/th&gt;
					&lt;th&gt;Measured&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;Invoice + delivery note vs. order reconciliation&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;qwen3:8b&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;document types and fields 14/14; expected discrepancies 5/5; 0 false discrepancies on the compliant case&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;LLM JSON extraction (4 native invoices)&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;qwen3:8b&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;valid JSON 4/4; exact fields 28/28; all deliberately altered values caught by grounding&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;OCR on a photographed label&lt;/td&gt;
					&lt;td&gt;PaddleOCR&lt;/td&gt;
					&lt;td&gt;word recall 0.94 on the raw photo; a contrast-enhancement defect (0.65–0.71) was found and fixed (0.88–0.94)&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Table reconstruction from OCR&lt;/td&gt;
					&lt;td&gt;PaddleOCR&lt;/td&gt;
					&lt;td&gt;100 % exact cells (proportional font), 95 % (monospace)&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 id="limitations--next-steps"&gt;&lt;a href="#limitations--next-steps" class="header-anchor"&gt;&lt;/a&gt;Limitations &amp;amp; next steps
&lt;/h2&gt;&lt;ul&gt;
&lt;li&gt;Verbatim grounding cannot catch a value that exists in the document but was put in
the wrong field: an absent field was wrongly filled in 1 of 8 cases.&lt;/li&gt;
&lt;li&gt;The 8B model judged a +50 % quantity gap a &amp;ldquo;minor&amp;rdquo; discrepancy, against the
instruction. LLM verdicts are therefore normalised and kept downstream of
deterministic rules.&lt;/li&gt;
&lt;li&gt;Scores come mostly from synthetic data. A real-document test set is being built
outside the repository, and only cases that have been reviewed are scored.&lt;/li&gt;
&lt;li&gt;Next steps: measure the automatic-reconciliation rate and the false-positive rate
on production data.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="stack"&gt;&lt;a href="#stack" class="header-anchor"&gt;&lt;/a&gt;Stack
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;Python&lt;/code&gt; · &lt;code&gt;Pydantic&lt;/code&gt; · &lt;code&gt;PyYAML&lt;/code&gt; · &lt;code&gt;PaddleOCR&lt;/code&gt; · &lt;code&gt;Ollama&lt;/code&gt; (&lt;code&gt;qwen3:8b&lt;/code&gt;, &lt;code&gt;qwen3-vl:8b&lt;/code&gt;) · &lt;code&gt;Mistral / Anthropic / OpenAI APIs&lt;/code&gt; · &lt;code&gt;pytest&lt;/code&gt; · &lt;code&gt;mypy&lt;/code&gt; · &lt;code&gt;import-linter&lt;/code&gt; · &lt;code&gt;ruff&lt;/code&gt;&lt;/p&gt;</description></item><item><title>Field Identification of Recall-Affected Vehicles</title><link>https://borisdupoux.com/research/fleet-management/</link><pubDate>Mon, 01 Jun 2026 00:00:00 +0000</pubDate><guid>https://borisdupoux.com/research/fleet-management/</guid><description>&lt;h2 id="context"&gt;&lt;a href="#context" class="header-anchor"&gt;&lt;/a&gt;Context
&lt;/h2&gt;&lt;p&gt;Safety recall campaigns depend on reaching every affected vehicle, but
letters and phone calls leave a residual population of owners who never
respond. In island and overseas territories the problem is sharper: network
coverage is uneven, field logistics span several territories, and the
registry of who drives what is incomplete. The practical question becomes a
fleet-management one: &lt;em&gt;which vehicles of the circulating fleet are still
affected, where are they seen, and how far has each one progressed towards
repair?&lt;/em&gt;&lt;/p&gt;
&lt;h2 id="approach"&gt;&lt;a href="#approach" class="header-anchor"&gt;&lt;/a&gt;Approach
&lt;/h2&gt;&lt;pre class="mermaid" style="visibility:hidden"&gt;flowchart TB
 A["Recall listing&lt;br/&gt;(scheduled file pull or manual CSV)"] --&gt; B["Central server&lt;br/&gt;import · versioning · model-name decoding"]
 B --&gt;|"incremental diff"| C["Field units (laptops in agent vehicles)&lt;br/&gt;local recall copy"]
 D["Camera stream"] --&gt; E["Plate detection + OCR&lt;br/&gt;(on-device, offline)"]
 E --&gt; F["Format-aware correction&lt;br/&gt;+ double-read confirmation"]
 F --&gt; G{"Match against&lt;br/&gt;local recall list?"}
 G --&gt;|"no"| H["Discarded — nothing stored"]
 G --&gt;|"yes"| I["Alert to agent&lt;br/&gt;+ local queue"]
 I --&gt;|"sync when network returns"| B
 B --&gt; J["Dashboard&lt;br/&gt;units · tours · detections · incidents"]
 J --&gt; K["Owner contact workflow&lt;br/&gt;identified → contacted → repaired"]&lt;/pre&gt;&lt;h2 id="methods"&gt;&lt;a href="#methods" class="header-anchor"&gt;&lt;/a&gt;Methods
&lt;/h2&gt;&lt;p&gt;&lt;strong&gt;Data ingestion and versioning.&lt;/strong&gt; The recall listing is pulled
automatically on a schedule or imported by hand. Each import is diffed
against the previous one with stable identifiers, versioned by hash, and
only the delta is sent to the field units. Manufacturer type codes are
translated into readable model names on import.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Matching under OCR noise.&lt;/strong&gt; Plate detection and character recognition run
on the field laptop with ONNX models, with no network dependency. Two
safeguards reduce false matches: (i) a &lt;em&gt;positional correction&lt;/em&gt; step uses the
known structure of national plate formats (current and legacy) to resolve
classic OCR confusions (O/0, I/1, S/5, B/8, Z/2, G/6), rejecting any
ambiguous reading; (ii) a &lt;em&gt;double-read confirmation&lt;/em&gt; only counts a plate when
it is read twice within a short time window. A smartphone web page offers a
manual fallback (photo or typed plate, plus VIN check-digit validation).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Offline-first synchronisation.&lt;/strong&gt; Matches are queued locally and pushed
to the central server on the next connection, so detections survive
multi-day outages. Units report a heartbeat (software version, recall-list
version, reading counters).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Field operations.&lt;/strong&gt; Supervisors plan tours as ordered, geolocated
checkpoints on a map, assign them to teams and units, reuse templates, and
follow progress. Agents report incidents with a severity level. A contact
workflow records the outcome of each owner approach, and the dashboard
derives a conversion funnel (identified → approached → engaged → repaired)
with weekly trends, the median delay to repair and a breakdown by territory.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Privacy by design.&lt;/strong&gt; Plates that do not match the recall list are never
stored or transmitted; retained records have fixed retention periods with
automatic purge; actions are written to an audit log; dashboard access is
role-based, including read-only accounts; data at rest is encrypted. A
processing register was written alongside the code.&lt;/p&gt;
&lt;h2 id="deployment--operations-devops"&gt;&lt;a href="#deployment--operations-devops" class="header-anchor"&gt;&lt;/a&gt;Deployment &amp;amp; operations (DevOps)
&lt;/h2&gt;&lt;p&gt;The system runs on two very different kinds of machine: Windows laptops in
agents&amp;rsquo; vehicles, which may stay offline for days, and one server per
instance. After the first weeks in the field, most of the engineering work
went into making both sides easy to deploy, observe and recover, by a single
person, without breaking field work.&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;flowchart LR
 subgraph ADM["Admin workstation"]
 GIT["Git repository&lt;br/&gt;committed tree only"]
 PROV["Provisioning script&lt;br/&gt;one config file per unit"]
 BLD["Field build&lt;br/&gt;PyInstaller bundle + ONNX models"]
 end
 subgraph SRV["Server (Ubuntu LTS VPS)"]
 CAD["Caddy&lt;br/&gt;TLS · HSTS · no access log"]
 APP["Docker container&lt;br/&gt;FastAPI API + dashboard&lt;br/&gt;loopback port only"]
 VOL[("LUKS2 volume&lt;br/&gt;SQLite · photos · archives")]
 TIM["systemd timers&lt;br/&gt;monitoring · backup · cleanup"]
 end
 FLD["Field laptops&lt;br/&gt;local SQLite · sync loop"]
 OBJ[("Off-site object storage&lt;br/&gt;age-encrypted backups")]
 CHAT["Chat alerts&lt;br/&gt;state changes + daily digests"]
 GIT --&gt;|"git archive over SSH → deploy script"| APP
 PROV --&gt;|"admin API"| APP
 PROV --&gt;|"config file + README"| FLD
 BLD --&gt;|"archive + one-click installer"| FLD
 FLD &lt;--&gt;|"HTTPS: heartbeat · pushes · recall diff"| CAD
 CAD --&gt; APP
 APP --- VOL
 TIM --&gt; VOL
 TIM --&gt;|"encrypted copy"| OBJ
 TIM --&gt; CHAT&lt;/pre&gt;&lt;h3 id="field-laptops-packaging-installation-and-updates"&gt;&lt;a href="#field-laptops-packaging-installation-and-updates" class="header-anchor"&gt;&lt;/a&gt;Field laptops: packaging, installation and updates
&lt;/h3&gt;&lt;p&gt;&lt;strong&gt;Packaging.&lt;/strong&gt; The field application (Python backend, React interface) is
frozen with PyInstaller into a one-folder Windows executable that embeds the
ONNX models and the OCR configuration, so a unit needs no Python install and
no network to read plates. Built-in &lt;code&gt;--diag&lt;/code&gt; and &lt;code&gt;--selftest&lt;/code&gt; modes check the
bundled runtime and read a test image without starting the interface, which
made start-up failures diagnosable on the field machine itself.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;One-click installer.&lt;/strong&gt; The build is delivered as an archive with a
double-click &lt;code&gt;INSTALLER.bat&lt;/code&gt;. It elevates itself and runs a PowerShell script
that removes the &amp;ldquo;downloaded from the Internet&amp;rdquo; mark from the files (otherwise
the bundled runtime fails to load), disables USB power saving so the GPS
dongle is not suspended, and installs the unit&amp;rsquo;s configuration file where the
executable reads it. The script refuses to guess: if several &lt;em&gt;different&lt;/em&gt;
configuration files are present, it lists them and asks which one is the new
one. An optional reset clears the local database when a unit moves to another
server; it shows the target server before asking for confirmation and warns
that unsynchronised detections would be lost. The unit&amp;rsquo;s access token is
never displayed. An update follows the same path: new build, re-run the
installer.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Provisioning.&lt;/strong&gt; A Python script that uses only the standard library, so it
runs on a bare Windows machine, creates the units from a CSV file through the
server&amp;rsquo;s admin API and generates, for each unit, its configuration file
(server address, unit code, display name, token) and a README to hand over
with the laptop. The admin key is read from an environment variable or a
masked prompt, never from a command-line argument.&lt;/p&gt;
&lt;h3 id="versioning-software-and-recall-list"&gt;&lt;a href="#versioning-software-and-recall-list" class="header-anchor"&gt;&lt;/a&gt;Versioning: software and recall list
&lt;/h3&gt;&lt;p&gt;&lt;strong&gt;Software version.&lt;/strong&gt; Each unit reports its application version in its
heartbeat. The dashboard compares it with the expected release and flags the
units that still need updating.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Recall-list version.&lt;/strong&gt; The recall list is versioned by hash. On each cycle,
a unit compares its local hash with the server&amp;rsquo;s: identical, nothing to do; no
local copy, full download; otherwise, the server returns only the rows added
and removed since that hash (and the unit falls back to a full download if the
server no longer knows its version). The change is applied in a single
transaction, and the local hash only moves forward if everything was applied.&lt;/p&gt;
&lt;p&gt;That last rule came from a field incident. A database constraint made the
diff fail on one unit, the error was swallowed by the sync loop, and the unit
kept sending heartbeats while its recall list stayed frozen for more than two
weeks, so it alerted on vehicles that had already been repaired. Release 1.4.1
fixed the cause, added an explicit rollback, and shipped a schema migration
that keeps existing data. It was tested with a 62-test run that included
reproducing the bug and migrating a database with the old schema. Monitoring
now raises a &amp;ldquo;recall sync stuck&amp;rdquo; alert when a unit has not received a
published list after a grace period.&lt;/p&gt;
&lt;h3 id="incremental-resilient-synchronisation"&gt;&lt;a href="#incremental-resilient-synchronisation" class="header-anchor"&gt;&lt;/a&gt;Incremental, resilient synchronisation
&lt;/h3&gt;&lt;p&gt;Every five minutes, or on demand, a unit runs a sync cycle made of
independent steps: heartbeat, push of detections (in batches), GPS track,
incidents, checkpoint updates, then pull of the recall list and the tours.
Pushes come before pulls, so field data leaves first. A failing step no
longer blocks the others: in an earlier version the cycle was monolithic,
and a failed heartbeat once delayed detections by 22 minutes. Requests use
short timeouts, downloads longer ones. The heartbeat carries the software
version, the recall-list version, the number of pending detections and the
reading counters, which is what the dashboard and the monitoring rely on.&lt;/p&gt;
&lt;h3 id="server-deployment"&gt;&lt;a href="#server-deployment" class="header-anchor"&gt;&lt;/a&gt;Server deployment
&lt;/h3&gt;&lt;p&gt;&lt;strong&gt;Instance kit.&lt;/strong&gt; Each deployment is an independent instance described by a
short identifier, on an Ubuntu LTS virtual server. A bootstrap script, which is
interactive and can be safely re-run, installs the system packages, Docker
and Caddy from their official repositories. It enables unattended security
updates without automatic reboot, restricts SSH to key authentication,
enables a default-deny firewall and brute-force protection, and checks that
the DNS record points to the server before applying the TLS configuration.
Secrets live in root-only configuration files on the server, never in the
repository.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Container and reverse proxy.&lt;/strong&gt; A multi-stage Dockerfile builds the React
dashboard with Node, then runs the FastAPI server on a slim Python image as an
unprivileged user. Its health check allows a three-minute start period
because the plate and OCR models are loaded before the server listens. The
container is published on the loopback interface only. Caddy is the only
public entry point: automatic Let&amp;rsquo;s Encrypt certificate, explicit HSTS, and
no access log, because scan URLs contain plates. The application&amp;rsquo;s own
access log is disabled for the same reason.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Deploy and rollback.&lt;/strong&gt; Code leaves the admin workstation only as a
&lt;code&gt;git archive&lt;/code&gt; of a committed tree: uncommitted changes are listed and not
sent, and line endings are forced to LF. On the server, the new tree is
unpacked next to the current one and swapped in, the previous one is kept,
and the deployed commit is recorded. The deploy script then detaches from the
SSH session, so a dropped connection interrupts neither the deployment nor a
rollback. A lock prevents concurrent deployments. The script builds the image
(labelled with its commit), keeps the previous image as &lt;code&gt;rollback&lt;/code&gt;, recreates
the container and waits until it reports healthy. It gives up after five
minutes, or as soon as the container has restarted twice. On failure it rolls
back automatically to the previous image and source tree, sets the failed
code aside and sends an alert. Going back to any earlier version means
re-deploying that commit. An image rollback does not undo a schema
migration, so the documented procedure is to take a backup before any
deployment that changes the schema.&lt;/p&gt;
&lt;h3 id="encrypted-volume-and-safe-restarts"&gt;&lt;a href="#encrypted-volume-and-safe-restarts" class="header-anchor"&gt;&lt;/a&gt;Encrypted volume and safe restarts
&lt;/h3&gt;&lt;p&gt;Server data (database, contact photos, recall archives) lives on a LUKS2
file container mounted with restrictive options. Its passphrase is never
stored on the machine, so after a reboot a person unlocks the volume. The
container restarts on its own, but the server refuses to start unless a
sentinel file &lt;em&gt;on the encrypted volume&lt;/em&gt; is present. Without it, a reboot
would have left an empty directory in place of the volume, and the
application would have created a fresh, unencrypted database there and
accepted field data into it. The guard was checked both ways on a throwaway
container. A separate script grows the volume while it is mounted.&lt;/p&gt;
&lt;h3 id="backups-and-recovery"&gt;&lt;a href="#backups-and-recovery" class="header-anchor"&gt;&lt;/a&gt;Backups and recovery
&lt;/h3&gt;&lt;p&gt;A daily systemd timer takes a consistent SQLite snapshot through the
database&amp;rsquo;s backup API, archives it with the photos and recall archives, and
encrypts the result with &lt;code&gt;age&lt;/code&gt; to a public key. The private key is shown
once, at creation, and kept off the server. Backups are kept locally and,
optionally, copied to S3-compatible object storage with &lt;code&gt;rclone&lt;/code&gt;, configured
through environment variables only. A restore &lt;em&gt;test&lt;/em&gt; mode decrypts a backup
into a temporary directory on the encrypted volume, runs an integrity check,
counts rows per table, then deletes everything. The &lt;em&gt;apply&lt;/em&gt; mode moves the
current state aside instead of deleting it. The documentation includes a
step-by-step rebuild after total loss of the server. The continuity plan
targets a recovery time of 4 hours and a maximum data loss of 24 hours.&lt;/p&gt;
&lt;h3 id="monitoring"&gt;&lt;a href="#monitoring" class="header-anchor"&gt;&lt;/a&gt;Monitoring
&lt;/h3&gt;&lt;p&gt;A watchdog runs every 15 minutes and only posts to a chat webhook when a
state changes (problem, then &amp;ldquo;resolved&amp;rdquo;). A fuller digest is sent twice a day,
timed to the field shifts. It checks that the encrypted volume is mounted, the
API health endpoint (with retries, because the daily import can block it for
about a minute), the container health status, that the database is readable,
disk usage on both the system disk and the encrypted volume, the size of the
SQLite write-ahead log, TLS certificate expiry, backup age and size, units
silent for more than 48 hours (not 24, since units are switched off at
weekends) and units whose recall list is stuck. A separate watcher alerts
when no recall import has happened for more than 30 hours. Several of these
checks were added after real incidents: the encrypted volume once reached
90 % without any alert because only the system disk was watched. A weekly
timer cleans up Docker build caches and keeps only the current and rollback
images. A dedicated script truncates the SQLite log with a short lock wait,
so field writes are never blocked for long.&lt;/p&gt;
&lt;h3 id="offline-resilience"&gt;&lt;a href="#offline-resilience" class="header-anchor"&gt;&lt;/a&gt;Offline resilience
&lt;/h3&gt;&lt;p&gt;The field side is designed to keep working through a server outage: plate
reading, matching against the local recall copy, alerts, tours already
downloaded and the local statistics tab all work without a network, and the
queue empties at the next connection. Only the dashboard (and the
server-hosted smartphone page) is unavailable while the server is down.&lt;/p&gt;
&lt;h3 id="testing"&gt;&lt;a href="#testing" class="header-anchor"&gt;&lt;/a&gt;Testing
&lt;/h3&gt;&lt;p&gt;There is no CI pipeline in the repository. Releases are tested before they
ship, and the commits record those runs: 12/12 and 6/6 for the plate
correction and confirmation stages, 62 tests for release 1.4.1. Behaviours
that cannot be unit-tested, such as the volume guard or a live deployment,
were checked on throwaway containers and against the production health
endpoint.&lt;/p&gt;
&lt;h2 id="results"&gt;&lt;a href="#results" class="header-anchor"&gt;&lt;/a&gt;Results
&lt;/h2&gt;&lt;ul&gt;
&lt;li&gt;In early field operation, agents flagged &lt;strong&gt;32 %&lt;/strong&gt; of detections as
misreadings over roughly one week. This measurement motivated the
positional-correction and double-read confirmation stages, and the
dashboard statistics now exclude detections marked as false positives.&lt;/li&gt;
&lt;li&gt;The new stages shipped with unit tests: &lt;strong&gt;12/12&lt;/strong&gt; for plate correction
and &lt;strong&gt;6/6&lt;/strong&gt; for confirmation, settings and reading log.&lt;/li&gt;
&lt;li&gt;After the model-name mapping was extended, only &lt;strong&gt;one&lt;/strong&gt; row of the
production recall listing remained without a decoded model name.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;No end-to-end recognition rate has been measured yet.&lt;/p&gt;
&lt;h2 id="limitations--next-steps"&gt;&lt;a href="#limitations--next-steps" class="header-anchor"&gt;&lt;/a&gt;Limitations &amp;amp; next steps
&lt;/h2&gt;&lt;ul&gt;
&lt;li&gt;Recognition accuracy has not been measured against a ground-truth
benchmark. The specification targets (read rate, latency) are design goals,
not results.&lt;/li&gt;
&lt;li&gt;No legacy-format plate reading was observed at first. A dedicated counter
now tracks these readings end to end, but the fix still needs a field check.&lt;/li&gt;
&lt;li&gt;A false match against a large listing still costs agent time. Calibrating
the confidence threshold and confirmation window on labelled field video
is the next step.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="stack"&gt;&lt;a href="#stack" class="header-anchor"&gt;&lt;/a&gt;Stack
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;Python&lt;/code&gt; · &lt;code&gt;FastAPI&lt;/code&gt; · &lt;code&gt;SQLite&lt;/code&gt; · &lt;code&gt;ONNX Runtime&lt;/code&gt; · &lt;code&gt;fast-alpr&lt;/code&gt; (YOLOv9 plate detector + OCR) · &lt;code&gt;OpenCV&lt;/code&gt; · &lt;code&gt;PaddleOCR&lt;/code&gt; · &lt;code&gt;React&lt;/code&gt; · &lt;code&gt;TypeScript&lt;/code&gt; · &lt;code&gt;Vite&lt;/code&gt; · &lt;code&gt;Leaflet&lt;/code&gt; · &lt;code&gt;Docker&lt;/code&gt; · &lt;code&gt;Caddy&lt;/code&gt; · &lt;code&gt;LUKS&lt;/code&gt; · &lt;code&gt;systemd&lt;/code&gt; · &lt;code&gt;age&lt;/code&gt; · &lt;code&gt;rclone&lt;/code&gt; · &lt;code&gt;PyInstaller&lt;/code&gt; · &lt;code&gt;PowerShell&lt;/code&gt;&lt;/p&gt;</description></item></channel></rss>