<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Home on Boris Dupoux</title><link>https://borisdupoux.com/</link><description>Recent content in Home 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/index.xml" rel="self" type="application/rss+xml"/><item><title>A Fully Local AI Agent with a Meaning Graph and Multimodal Emotion Measurement</title><link>https://borisdupoux.com/research/local-ai-agent/</link><pubDate>Thu, 01 Oct 2026 00:00:00 +0000</pubDate><guid>https://borisdupoux.com/research/local-ai-agent/</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;Personal digital traces (messaging, mail, photos, music listening, notes, calendar,
health exports, code repositories) are among the most sensitive data a person owns, and
also among the richest for studying behaviour — and feeling — over time. Sending them to
a cloud LLM is not acceptable, so everything here runs on a single Apple-silicon desktop
(M4, 32 GB unified memory), and no data leaves the machine.&lt;/p&gt;
&lt;p&gt;The aim is an agent that answers questions about one&amp;rsquo;s own life while keeping a strict
line between what is &lt;em&gt;observed&lt;/em&gt;, what is &lt;em&gt;computed&lt;/em&gt;, what is &lt;em&gt;hypothesised by a model&lt;/em&gt;,
and what is &lt;em&gt;accepted as true by its owner&lt;/em&gt;.&lt;/p&gt;
&lt;h2 id="what-is-original"&gt;&lt;a href="#what-is-original" class="header-anchor"&gt;&lt;/a&gt;What is original
&lt;/h2&gt;&lt;p&gt;Local agents over personal files are not new. Two aspects of this project are.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. A meaning graph on top of a measurement layer.&lt;/strong&gt; Knowledge is held at two levels.
An &lt;em&gt;observatory&lt;/em&gt; stores facts and signals in long format (item, dimension, value,
confidence, model, version). Above it, a &lt;strong&gt;meaning graph&lt;/strong&gt; stores what those signals
&lt;em&gt;mean&lt;/em&gt;: &lt;strong&gt;relations&lt;/strong&gt; (reified, so that a relationship can evolve and be measured),
&lt;strong&gt;affective episodes&lt;/strong&gt; (who felt what, when, how intensely, through which modality),
&lt;strong&gt;themes&lt;/strong&gt;, &lt;strong&gt;chapters&lt;/strong&gt; of a life, and &lt;strong&gt;truths&lt;/strong&gt; — statements with supporting and
contradicting evidence that only the owner can validate. Every node and edge carries its
provenance down to the source element, and an origin ranked by trust
(human &amp;gt; declared &amp;gt; structural &amp;gt; rule/computed &amp;gt; vendor &amp;gt; model &amp;gt; inferred).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. Emotion measured across modalities, along unusual dimensions.&lt;/strong&gt; The same fixed
vocabulary of &lt;strong&gt;53 emotions&lt;/strong&gt; (Plutchik-based, each with valence and arousal coordinates)
is applied to &lt;strong&gt;messages&lt;/strong&gt; (local LLM), &lt;strong&gt;voice&lt;/strong&gt; (five acoustic and multimodal layers
fused with the transcript, with an explicit text/voice &lt;em&gt;mismatch&lt;/em&gt; flag) and &lt;strong&gt;photos&lt;/strong&gt;
(deterministic colour and light measures, SigLIP embeddings, face signals treated as weak
evidence). These sit in a catalogue of &lt;strong&gt;929 dimensions in 88 groups&lt;/strong&gt; that goes well
beyond the usual metadata. A few examples of its less common entries:&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;modality&lt;/th&gt;
					&lt;th&gt;dimension&lt;/th&gt;
					&lt;th&gt;how it is defined&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;photo&lt;/td&gt;
					&lt;td&gt;colour temperature&lt;/td&gt;
					&lt;td&gt;mean red–blue balance of the image (warm vs cool)&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;photo&lt;/td&gt;
					&lt;td&gt;colourfulness&lt;/td&gt;
					&lt;td&gt;Hasler &amp;amp; Süsstrunk (2003) opponent-colour metric&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;photo&lt;/td&gt;
					&lt;td&gt;golden hour / blue hour&lt;/td&gt;
					&lt;td&gt;sun elevation computed offline from date and position&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;photo&lt;/td&gt;
					&lt;td&gt;face size&lt;/td&gt;
					&lt;td&gt;share of the frame taken by a face (a proxy for proximity)&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;voice&lt;/td&gt;
					&lt;td&gt;jitter, shimmer, harmonics-to-noise ratio&lt;/td&gt;
					&lt;td&gt;openSMILE eGeMAPS prosody&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;voice&lt;/td&gt;
					&lt;td&gt;laughter, crying, sighs&lt;/td&gt;
					&lt;td&gt;sound-event detection (AudioSet classes)&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;voice&lt;/td&gt;
					&lt;td&gt;text/voice mismatch&lt;/td&gt;
					&lt;td&gt;voice valence and transcript valence differ by ≥ 0.6&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;message&lt;/td&gt;
					&lt;td&gt;valence, arousal, tone&lt;/td&gt;
					&lt;td&gt;per participant, constrained to the fixed grid&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;message&lt;/td&gt;
					&lt;td&gt;expressive punctuation, emoji palette&lt;/td&gt;
					&lt;td&gt;deterministic text measures&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;relation&lt;/td&gt;
					&lt;td&gt;reply-latency asymmetry&lt;/td&gt;
					&lt;td&gt;log ratio of the two parties&amp;rsquo; median reply delays&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;relation&lt;/td&gt;
					&lt;td&gt;initiatives&lt;/td&gt;
					&lt;td&gt;conversations opened after 3 h of silence, per side&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;relation&lt;/td&gt;
					&lt;td&gt;trend&lt;/td&gt;
					&lt;td&gt;slope of relationship intensity over 90 days (cooling &amp;lt; 0)&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;period&lt;/td&gt;
					&lt;td&gt;musical nostalgia&lt;/td&gt;
					&lt;td&gt;mean age of the tracks listened to&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;period&lt;/td&gt;
					&lt;td&gt;creation / consumption&lt;/td&gt;
					&lt;td&gt;time in creative tools ÷ time consuming&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 id="from-signals-to-meaning"&gt;&lt;a href="#from-signals-to-meaning" class="header-anchor"&gt;&lt;/a&gt;From signals to meaning
&lt;/h2&gt;&lt;pre class="mermaid" style="visibility:hidden"&gt;flowchart LR
 PH["Photo"] --&gt; PM["Measures&lt;br/&gt;light · contrast · colour temperature&lt;br/&gt;colourfulness · palette · sharpness"]
 PH --&gt; PS["SigLIP embedding&lt;br/&gt;(image ↔ text similarity)"]
 PH --&gt; PF["Vendor face signals&lt;br/&gt;smile · expression · gaze · face size"]
 VN["Voice note"] --&gt; VT["Whisper transcript"]
 VN --&gt; VL["Acoustic layers&lt;br/&gt;valence/arousal/dominance · categories&lt;br/&gt;sound events · prosody · audio-LLM"]
 VT --&gt; VX["Text emotion of transcript"]
 VL --&gt; VF["Weighted fusion&lt;br/&gt;confidence = agreement&lt;br/&gt;text/voice mismatch flag"]
 VX --&gt; VF
 MS["Messages&lt;br/&gt;(pseudonymised: Me, P1, P2)"] --&gt; ML["Local LLM, fixed grid&lt;br/&gt;emotion · intensity · valence&lt;br/&gt;arousal · tone · topics"]
 PM --&gt; OB[("Observatory&lt;br/&gt;929 dimensions")]
 PS --&gt; OB
 PF --&gt; OB
 VF --&gt; OB
 ML --&gt; OB
 OB -- "emotion with enough confidence" --&gt; AFF["Affective episode (AFF)&lt;br/&gt;who · emotion · when · intensity · modality"]
 PF -. "weak cue, confidence ≤ 0.4" .-&gt; AFF
 ML -- "topics" --&gt; THM["Themes"]
 OB -- "aggregates" --&gt; AG["Day / period indicators&lt;br/&gt;valence · light · palette"]
 AFF --&gt; G["Meaning graph&lt;br/&gt;relations · episodes · themes · chapters · truths&lt;br/&gt;(all proposed)"]
 THM --&gt; G
 AG --&gt; G
 G --&gt; HV(("Owner&lt;br/&gt;validate · reject · correct"))&lt;/pre&gt;&lt;p&gt;Each affective episode is a node linked to the person who felt it, to the emotion in the
fixed vocabulary, and — when it exists — to the conversation or moment it occurred in.
Inference rules then carry affect upwards (for instance, the affect of a chapter or of a
routine), and strong aggregate correlations can become &lt;em&gt;proposed&lt;/em&gt; truths with their
measures as evidence.&lt;/p&gt;
&lt;h2 id="emotion-measurement-in-detail"&gt;&lt;a href="#emotion-measurement-in-detail" class="header-anchor"&gt;&lt;/a&gt;Emotion measurement in detail
&lt;/h2&gt;&lt;p&gt;&lt;strong&gt;Vocabulary.&lt;/strong&gt; The grid is fixed and versioned: Plutchik&amp;rsquo;s 8 primary emotions at 3
intensities, his 8 primary dyads, plus social and reflective emotions useful for a life
(gratitude, tenderness, pride, shame, loneliness, nostalgia, wonder, relief, exhaustion…),
&lt;strong&gt;53 in total&lt;/strong&gt;. Each carries starting coordinates in Russell&amp;rsquo;s circumplex — valence in
[−1, 1], arousal in [0, 1] — meant to be calibrated later on the owner&amp;rsquo;s own validations.
Plutchik oppositions are encoded, and health-app mood labels are mapped onto the grid.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Messages.&lt;/strong&gt; A local LLM (Qwen3-30B-A3B) reads each conversation with participants
replaced by &amp;ldquo;Me&amp;rdquo;, &amp;ldquo;P1&amp;rdquo;, &amp;ldquo;P2&amp;rdquo;, messages framed as data it must not obey, and media
reduced to placeholders such as &amp;ldquo;[voice note 23 s]&amp;rdquo;. Output is constrained by a JSON
schema: for each participant, the dominant emotion from the grid (or none), intensity,
valence, arousal, a tone from a closed list of 19, and a confidence; plus 1–3 topics
that become themes. Long conversations are reduced to their beginning and end; short
ones are batched ten per call. An affective episode is proposed only when confidence is
at least 0.3.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Voice.&lt;/strong&gt; Each recording is converted to 16 kHz mono and passes through:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Whisper large-v3-turbo (MLX) for the transcript;&lt;/li&gt;
&lt;li&gt;a wav2vec2 dimensional model giving valence, arousal and dominance on 15 s windows,
weighted by window length, plus arousal variability;&lt;/li&gt;
&lt;li&gt;emotion2vec+ large for categorical emotion, mapped onto the grid (kept at ≥ 0.4);&lt;/li&gt;
&lt;li&gt;an AST AudioSet classifier for laughter, crying, sighs, shouting, whispering, music
and ambience;&lt;/li&gt;
&lt;li&gt;openSMILE eGeMAPS prosody: pitch (semitones) and its variability, loudness, jitter,
shimmer, harmonics-to-noise ratio, voiced segments per second, mean pause length;&lt;/li&gt;
&lt;li&gt;Qwen2.5-Omni-7B (llama.cpp, Metal), which listens to the audio and returns an emotion
from the grid, a valence and short acoustic cues;&lt;/li&gt;
&lt;li&gt;the text-emotion model above, applied to the transcript.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The categorical opinions (voice categories, audio-LLM, transcript) are combined by a
weighted vote (weights 1.0, 1.0 and 0.8); valence is a weighted mean of the dimensional
model, the audio-LLM and the transcript. &lt;strong&gt;Confidence is agreement between layers&lt;/strong&gt;: one
agreeing source gives 0.45, two give 0.65, three give 0.85, minus 0.1 for each source
that disagrees. When the voice and the words disagree by 0.6 or more in valence (an
&amp;ldquo;I&amp;rsquo;m fine&amp;rdquo; said sadly), a &lt;strong&gt;text/voice mismatch&lt;/strong&gt; is flagged rather than averaged away.
Laughter and crying scores are kept alongside.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Photos.&lt;/strong&gt; On 256 px thumbnails, without any model: luminance, contrast, saturation,
colour temperature, colourfulness, sharpness (variance of the Laplacian) and a 5-colour
palette with named dominant hue. SigLIP so400m embeddings make the library searchable by
text. Vendor face attributes (smile, smile type, expression, gaze, eye state, face size)
are read as signals, but the schema states that &lt;em&gt;a photographed smile is not a felt joy&lt;/em&gt;:
the face modality is a weak cue whose confidence is capped at 0.4. Music mood is handled
the same way: the mood of a track is never, on its own, evidence of the listener&amp;rsquo;s mood.
At period level, valence is fused with a fixed precedence: declared &amp;gt; voice &amp;gt; text &amp;gt; face.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Human validation.&lt;/strong&gt; Every episode, theme, chapter or truth is emitted as &lt;code&gt;proposed&lt;/code&gt;.
The PWA shows a validation queue — affective episodes ordered by confidence, with the
evidence (the conversation or recording it came from) — and the owner validates, rejects
or corrects. Only that human decision event turns a proposal into &lt;code&gt;validated&lt;/code&gt;, an
equivalence checked automatically. The planned next step is a small personal classifier
on emotion2vec features, trained on these validations.&lt;/p&gt;
&lt;h2 id="architecture"&gt;&lt;a href="#architecture" class="header-anchor"&gt;&lt;/a&gt;Architecture
&lt;/h2&gt;&lt;pre class="mermaid" style="visibility:hidden"&gt;flowchart TB
 S["Sources (read-only snapshots)&lt;br/&gt;messaging · mail · photos · music · notes · calendar · exports"]
 C["Connectors&lt;br/&gt;dedicated ingestion process, sole holder of disk access"]
 V["Vault (owner-only, 0700)&lt;br/&gt;content-addressed raw store"]
 O["Observatory&lt;br/&gt;facts + signals (SQLite)"]
 K["Meaning graph&lt;br/&gt;append-only JSONL log → SQLite fold + FTS"]
 N["Nightly / hourly pipelines&lt;br/&gt;thumbnails · measures · SigLIP · emotions&lt;br/&gt;(GPU lock, one model at a time)"]
 M["MCP server (stdio, no port)&lt;br/&gt;20 read-only / propose-only tools"]
 H["Hermes Agent&lt;br/&gt;local LLM via Ollama"]
 P["PWA (HTTPS + TOTP)&lt;br/&gt;chat · validation queue"]
 U(("Owner"))
 S --&gt; C --&gt; V --&gt; O --&gt; K
 N --&gt; O
 O --&gt; M
 K --&gt; M
 M --&gt; H --&gt; P
 P -- "validate / reject / correct" --&gt; K
 U -- "NetBird VPN" --&gt; P&lt;/pre&gt;&lt;p&gt;Raw data is snapshotted (SQLite opened in &lt;code&gt;mode=ro&amp;amp;immutable=1&lt;/code&gt;) and never written to;
everything derived lives in a separate owner-only vault, and the code repository holds no
data. The meaning graph is built on the atoms library&amp;rsquo;s knowledge-base module: an
append-only, schema-validated JSONL event log folded into a disposable SQLite index.&lt;/p&gt;
&lt;p&gt;The schema itself is data, verified mechanically: &lt;strong&gt;27 node types and 50 predicates&lt;/strong&gt;,
&lt;strong&gt;9 provenance origins&lt;/strong&gt;, &lt;strong&gt;31 locator kinds&lt;/strong&gt; tying every claim to its source element,
the 53-emotion vocabulary, &lt;strong&gt;17 inference rules&lt;/strong&gt;, and &lt;strong&gt;42 sources&lt;/strong&gt; behind the 929
dimensions (610 native, 126 vendor-computed, 86 aggregates, 74 deterministic measures,
29 model interpretations).&lt;/p&gt;
&lt;h2 id="security-model"&gt;&lt;a href="#security-model" class="header-anchor"&gt;&lt;/a&gt;Security model
&lt;/h2&gt;&lt;p&gt;Source content is assumed adversarial (prompt injection in a message or mail), and the
model may hallucinate or misuse tools. Defences are structural:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;No ambient capabilities.&lt;/strong&gt; The agent framework (Hermes Agent) has 20 built-in
toolsets disabled (terminal, files, code execution, web, browser, delegation, memory,
cloud speech…); no cloud provider, update check or external login; the only model
endpoint is a loopback Ollama server.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;One narrow interface.&lt;/strong&gt; Data is reachable only through a local MCP server over stdio,
with closed schemas, validated arguments, explicit truncation and a content-free access
log. Tools can &lt;em&gt;propose&lt;/em&gt;, never &lt;em&gt;decide&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Privilege separation and pseudonymisation.&lt;/strong&gt; Only ingestion holds full-disk access;
analysis has no network. Third parties are opaque codes, and a real name in an
identifier is rejected by the schema.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Sensitivity levels&lt;/strong&gt; (0–3) per element with a lower ceiling for remote sessions;
remote access only through a NetBird VPN, TOTP and strict session cookies.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Ethical exclusions&lt;/strong&gt;: vendor-inferred ethnicity, skin tone and gender are never read.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="models"&gt;&lt;a href="#models" class="header-anchor"&gt;&lt;/a&gt;Models
&lt;/h2&gt;&lt;p&gt;All inference is local; one large model is loaded at a time under a shared GPU lock.&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;task&lt;/th&gt;
					&lt;th&gt;model&lt;/th&gt;
					&lt;th&gt;runtime&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;agent (tool use, ≥ 64k context)&lt;/td&gt;
					&lt;td&gt;gpt-oss:20b, qwen3:8b fallback&lt;/td&gt;
					&lt;td&gt;Ollama&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;text emotions, voice-transcript emotions&lt;/td&gt;
					&lt;td&gt;Qwen3-30B-A3B-Instruct-2507 (Q4)&lt;/td&gt;
					&lt;td&gt;Ollama&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;text / image embeddings&lt;/td&gt;
					&lt;td&gt;bge-m3 · SigLIP so400m&lt;/td&gt;
					&lt;td&gt;Ollama · PyTorch MPS&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;image reading&lt;/td&gt;
					&lt;td&gt;qwen3-vl:8b&lt;/td&gt;
					&lt;td&gt;Ollama&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;speech-to-text&lt;/td&gt;
					&lt;td&gt;Whisper large-v3-turbo&lt;/td&gt;
					&lt;td&gt;MLX&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;voice emotion (5 layers)&lt;/td&gt;
					&lt;td&gt;wav2vec2 dimensional · emotion2vec+ · AST · openSMILE eGeMAPS · Qwen2.5-Omni-7B&lt;/td&gt;
					&lt;td&gt;PyTorch MPS · CPU · llama.cpp (Metal)&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Each model is smoke-tested on synthetic data only (a fictitious French sentence
synthesised by a local TTS, then passed through the whole audio chain).&lt;/p&gt;
&lt;h2 id="evaluation--results"&gt;&lt;a href="#evaluation--results" class="header-anchor"&gt;&lt;/a&gt;Evaluation &amp;amp; results
&lt;/h2&gt;&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Agent answer quality.&lt;/strong&gt; A 16-question benchmark (2 trap questions), with ground truth
computed by calling the tools directly, scores tool choice, &lt;em&gt;numerical fidelity&lt;/em&gt; (every
number in the answer must appear in that turn&amp;rsquo;s tool outputs), correctness, trap
handling and partial-period flagging: &lt;strong&gt;73 % → 100 %&lt;/strong&gt; after tool-description,
argument-validation and configuration fixes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Server-side error correction.&lt;/strong&gt; With a reduced 8-tool interface, simple questions
went from 76 % to 90 % and cross-source questions from 73 % to 90 %; 20 deliberately
unusual questions scored 89 % and exposed silent-zero failures, since fixed.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Context and latency.&lt;/strong&gt; Tool descriptions cut from 23.5 k to 9.5 k characters; a heavy
aggregate query from 172 s to 0.1 s; all heavy benchmark queries ≤ 0.3 s.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cross-exploration benchmark.&lt;/strong&gt; 60 placeholder-only scenarios with oracle code; oracles
pass 61/61, with no leak at remote sensitivity ceilings 1 and 2.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Verification.&lt;/strong&gt; 56 named schema checks green; 631 native/vendor columns confirmed
against real database structures; 1,235 Python unit tests, 15 JavaScript test files and
110 quality tests on real data that report counts only.&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;p&gt;Evaluation sets are small and authored by the system&amp;rsquo;s designer; they measure fidelity
and regressions rather than general capability. Emotion outputs are hypotheses: the
valence/arousal coordinates of the vocabulary are starting values, and personal
calibration of voice emotion from validated examples is still pending, as is the larger
target agent model. Agent answers take tens of seconds on a 20B model.&lt;/p&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;SQLite / FTS5&lt;/code&gt; · &lt;code&gt;Ollama&lt;/code&gt; · &lt;code&gt;llama.cpp (Metal)&lt;/code&gt; · &lt;code&gt;MLX&lt;/code&gt; · &lt;code&gt;PyTorch MPS&lt;/code&gt; · &lt;code&gt;Hermes Agent&lt;/code&gt; · &lt;code&gt;MCP&lt;/code&gt; · &lt;code&gt;SigLIP&lt;/code&gt; · &lt;code&gt;Whisper&lt;/code&gt; · &lt;code&gt;emotion2vec&lt;/code&gt; · &lt;code&gt;openSMILE&lt;/code&gt; · &lt;code&gt;NetBird&lt;/code&gt; · &lt;code&gt;PWA&lt;/code&gt; · &lt;code&gt;launchd&lt;/code&gt;&lt;/p&gt;</description></item><item><title>Handheld Video to Photogrammetry and Gaussian Splatting</title><link>https://borisdupoux.com/research/photogrammetry-splatting/</link><pubDate>Thu, 01 Oct 2026 00:00:00 +0000</pubDate><guid>https://borisdupoux.com/research/photogrammetry-splatting/</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;Indoor sites are often documented with a handheld gimbal camera rather than a
survey-grade scanner. The goal was to turn such video into usable 3D deliverables
(dense cloud, textured mesh, plan and elevation orthoviews, a Gaussian Splatting
scene and lightweight web exports) with no manual steps between capture and output.
The pipeline runs natively on Windows on an RTX 5090 (Blackwell, sm_120) that is
&lt;strong&gt;shared with other long-running GPU workloads&lt;/strong&gt;, so it must never pre-empt them.&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["inbox/project/videos + GO file"] --&gt; B["Keyframe extraction&lt;br/&gt;optical-flow overlap + sharpness"]
 B --&gt; C["COLMAP 4.2.1 SfM&lt;br/&gt;SIFT, global mapper"]
 C --&gt; D{"QC: registration, reprojection,&lt;br/&gt;trajectory continuity"}
 D -- fail --&gt; C2["Fallback: incremental mapper"] --&gt; D
 D -- pass --&gt; E["OpenMVS 2.4 (sm_120 build)&lt;br/&gt;densify, mesh, refine, texture"]
 E --&gt; F["Ortho alignment&lt;br/&gt;gravity, floor RANSAC, Manhattan yaw"]
 F --&gt; G["Blender renders&lt;br/&gt;orthos, axonometries, orbit"]
 E --&gt; H["gsplat training&lt;br/&gt;MCMC or default strategy"]
 G --&gt; I["Web exports&lt;br/&gt;Potree, GLB, SOG"]
 H --&gt; I
 I --&gt; J["outbox: deliverables, report.json, STATUS"]&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;Overlap-driven keyframe selection.&lt;/strong&gt; KLT tracking with a RANSAC similarity
transform estimates overlap with the last keyframe. A frame is emitted when overlap
drops below 0.80, tracking is lost or a time limit passes. The sharpest candidate
(variance of the Laplacian) is the one kept.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;SfM with explicit QC.&lt;/strong&gt; COLMAP&amp;rsquo;s global mapper is fast but non-deterministic.
In testing it produced reconstructions that met registration and reprojection
criteria but were geometrically incoherent. A trajectory-continuity check (jumps
above 25× the median camera step) triggers an automatic fallback to the
incremental mapper. Below 20 points per image the step fails.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;MVS hardening.&lt;/strong&gt; The official OpenMVS Windows binary has only sm_89 kernels, so
it was rebuilt for sm_120. The object ROI is off for rooms because it cropped the
cloud. Refinement is dropped when it diverges, and seam leveling (which blackened
faces) is off.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Metric-ready orthoviews.&lt;/strong&gt; The up axis comes from camera orientations. A RANSAC
floor plane sets z = 0 and a histogram of wall-normal azimuths (mod 90°) sets
Manhattan yaw. An optional two-point reference fixes scale. The 4×4 transform is
stored so it can be applied again to the mesh and the splat.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Gaussian Splatting.&lt;/strong&gt; gsplat 1.6.0 was compiled for sm_120 with CUDA 13 under
MSVC, which required local patches. Training uses the MCMC strategy with a
Gaussian budget and falls back to the default 3DGS strategy when MCMC diverges.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Operations.&lt;/strong&gt; Projects start in watch-folder mode when a GO file appears.
Stages can be resumed or redone. A GPU guard waits until enough VRAM has been
free for a stable period, serialises GPU stages through a lock file and records
peak VRAM per stage in &lt;code&gt;report.json&lt;/code&gt;. Degraded QC produces a &lt;code&gt;DONE_WITH_ALERTS&lt;/code&gt;
status instead of a silent success.&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;First real deployment: five locations inside a commercial showroom, one 4K
handheld video per location, processed unattended in about 30 minutes per location.&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;Location&lt;/th&gt;
					&lt;th&gt;Images&lt;/th&gt;
					&lt;th&gt;SfM registered / points / reproj.&lt;/th&gt;
					&lt;th&gt;Dense cloud&lt;/th&gt;
					&lt;th&gt;Splat PSNR / SSIM&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;1&lt;/td&gt;
					&lt;td&gt;116&lt;/td&gt;
					&lt;td&gt;116/116 · 21,458 · 1.49 px&lt;/td&gt;
					&lt;td&gt;1.40 M&lt;/td&gt;
					&lt;td&gt;23.2 / 0.86&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;2&lt;/td&gt;
					&lt;td&gt;103&lt;/td&gt;
					&lt;td&gt;103/103 · 18,972 · 1.75 px&lt;/td&gt;
					&lt;td&gt;1.23 M&lt;/td&gt;
					&lt;td&gt;20.6 / 0.81&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;3&lt;/td&gt;
					&lt;td&gt;47&lt;/td&gt;
					&lt;td&gt;47/47 · 13,167 · 1.17 px&lt;/td&gt;
					&lt;td&gt;1.26 M&lt;/td&gt;
					&lt;td&gt;23.1 / 0.86&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;4&lt;/td&gt;
					&lt;td&gt;121&lt;/td&gt;
					&lt;td&gt;121/121 · 11,970 · 1.34 px&lt;/td&gt;
					&lt;td&gt;1.99 M&lt;/td&gt;
					&lt;td&gt;21.9 / 0.85 (default strategy)&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;5&lt;/td&gt;
					&lt;td&gt;64&lt;/td&gt;
					&lt;td&gt;64/64 · 12,106 · 1.48 px&lt;/td&gt;
					&lt;td&gt;0.91 M&lt;/td&gt;
					&lt;td&gt;21.8 / 0.83&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;ul&gt;
&lt;li&gt;All frames were registered in every location. Reprojection error was 1.17–1.75 px.&lt;/li&gt;
&lt;li&gt;Measured VRAM peaks: SIFT +1.3 GB, matching +1.6 GB, texturing +0.5 GB,
Blender +5.4 GB, and splatting with 3 M Gaussians at 1920 px +25.2 GB.&lt;/li&gt;
&lt;li&gt;The sm_120 OpenMVS build densified the synthetic smoke test in 17 s. The CPU
fallback took about 40 min.&lt;/li&gt;
&lt;li&gt;Metadata analysis showed shutter speeds of 1/50–1/100 s (about 14 px of motion
blur at 4K) and automatic ISO and white balance. Exposure compensation in gsplat
(&lt;code&gt;ppisp&lt;/code&gt;) gave no gain (PSNR 20.5 vs 20.6). The quality limit is in the capture,
not the solver.&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;PSNR in the low 20s reflects motion blur and auto-exposure. The capture protocol
now requires a 1/250–1/500 s shutter, locked exposure and white balance, and a
dedicated floor pass.&lt;/li&gt;
&lt;li&gt;Merging videos from different locations caused false matches and an incoherent
model, so each location is processed separately.&lt;/li&gt;
&lt;li&gt;Dark glossy tiled floors reconstruct poorly. Manhattan alignment is not yet
reliable with furniture and glazing.&lt;/li&gt;
&lt;li&gt;CPU stages are the bottleneck on a 4-vCPU virtual machine.&lt;/li&gt;
&lt;li&gt;Planned: comparison with a commercial photogrammetry suite on the same frames, and
retesting mesh refinement with the sm_120 build.&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 3.11&lt;/code&gt; · &lt;code&gt;OpenCV&lt;/code&gt; · &lt;code&gt;Open3D&lt;/code&gt; · &lt;code&gt;NumPy&lt;/code&gt; · &lt;code&gt;COLMAP 4.2.1&lt;/code&gt; · &lt;code&gt;OpenMVS 2.4&lt;/code&gt; · &lt;code&gt;gsplat 1.6.0&lt;/code&gt; · &lt;code&gt;PyTorch 2.14 (CUDA 13)&lt;/code&gt; · &lt;code&gt;Blender 5.2&lt;/code&gt; · &lt;code&gt;FFmpeg&lt;/code&gt; · &lt;code&gt;PotreeConverter&lt;/code&gt; · &lt;code&gt;glTF-Transform&lt;/code&gt; · &lt;code&gt;splat-transform&lt;/code&gt;&lt;/p&gt;</description></item><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>Mission Knowledge Graphs: From Consulting Evidence to an Ontology Queried Through MCP</title><link>https://borisdupoux.com/research/mission-knowledge-graph/</link><pubDate>Tue, 01 Sep 2026 00:00:00 +0000</pubDate><guid>https://borisdupoux.com/research/mission-knowledge-graph/</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;Process-observation consulting produces flowcharts, interview notes, screen recordings and
recommendations. Once a mission is delivered, that material is effectively dead: the next
mission starts from a blank page, even when the answer was already found elsewhere.
The research question is how to turn deliverables from &lt;strong&gt;documents into typed, sourced,
queryable data&lt;/strong&gt;, so that an assistant can answer &amp;ldquo;where have we seen this before, and what
did we conclude?&amp;rdquo; while citing verifiable evidence.&lt;/p&gt;
&lt;p&gt;Two requirements structure the work: &lt;strong&gt;traceability&lt;/strong&gt; (every assertion points to its proof)
and a strict &lt;strong&gt;separation between evidence and knowledge&lt;/strong&gt; (what was observed is not what was
generalised). Retrieval-augmented generation is fast to build but cites passages, not
checkable assertions; a formal ontology is traceable but expensive. This project explores a
practical path between the two.&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["`Raw mission sources
immutable, hashed`"] --&gt; B["`Append-only event log
source of truth`"]
 B --&gt; C["`Rebuildable SQLite base
disposable`"]
 C --&gt; D[Deterministic extraction]
 C --&gt; E["`LLM extraction
proposer only`"]
 D --&gt; F["`Proposed nodes and edges
with provenance`"]
 E --&gt; F
 F --&gt; G{"`Human review
outside MCP`"}
 G --&gt;|validated| H[Knowledge graph]
 H --&gt; I[MCP: evidence]
 H --&gt; J[MCP: knowledge]
 H --&gt; K[MCP: ontology]
 I --&gt; L[AI assistant]
 J --&gt; L
 K --&gt; L&lt;/pre&gt;&lt;p&gt;The core design choice is to split the interface into &lt;strong&gt;three MCP servers by epistemic
status&lt;/strong&gt;: &lt;em&gt;evidence&lt;/em&gt; (attested, sourced observations), &lt;em&gt;knowledge&lt;/em&gt; (generalisations), and
&lt;em&gt;ontology&lt;/em&gt; (the meta-model itself). The split prevents an assistant from presenting a
generalisation as an observation.&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;flowchart TB
 subgraph SCHEMA["SCHEMA · naming"]
 ON["`ontology server
How do we name it?`"]
 end
 subgraph PROVEN["PROVEN · observed"]
 EV["`evidence server
What did we capture, and where?`"]
 end
 subgraph GENERAL["GENERALISED · concluded"]
 KN["`knowledge server
What do we conclude?
proposes, never validates`"]
 end
 ON -. "types" .-&gt; EV
 EV == "grounds every conclusion" ==&gt; KN
 EV --&gt; AI["`AI assistant
answers labelled by status`"]
 KN --&gt; AI
 ON --&gt; AI&lt;/pre&gt;&lt;p&gt;The graph is single; only the façades are split. The boundary that matters runs between
evidence and knowledge: the &lt;em&gt;evidence&lt;/em&gt; server only returns what was observed and where, the
&lt;em&gt;knowledge&lt;/em&gt; server returns conclusions that must chain back to that evidence, and no MCP
tool can mark anything as validated.&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;Meta-model.&lt;/strong&gt; A fixed core plus mission profiles and a versioned ontology (v0). Each
node carries an origin (structural, deterministic, LLM-extracted) and a status
(proposed → validated).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Invariants (I1–I6).&lt;/strong&gt; Provenance is mandatory on every derived node and edge; a
recommendation must chain back to a sourced finding; LLM-extracted quotes must be literal
substrings of their fragment; &amp;ldquo;validated&amp;rdquo; requires a recorded human decision. Raw sources
are immutable; the database is disposable and rebuilt from the log.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Competency questions before implementation.&lt;/strong&gt; A v0 set of 33 questions, typed as
factual, aggregate, traceability, contradiction, temporal, cross-mission and &lt;strong&gt;negative&lt;/strong&gt;
(requests the system must refuse, e.g. cross-client leakage), in an executable format with
expected answers and references.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Evaluation protocol.&lt;/strong&gt; At least 30 natural-language tasks, scored automatically or by a
judge. Gate thresholds were fixed up front: accuracy ≥ 0.8, valid citations ≥ 0.98,
grounding ≥ 0.95, leakage = 0, median ≤ 6 tool calls per task; each question solvable in
≤ 3 calls; deterministic extraction precision ≥ 0.95 per type; inter-annotator κ ≥ 0.7.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Phases P0–P6 with human gates.&lt;/strong&gt; Implementation is delegated to an agent session that
must stop at each gate and log open decisions.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Black-box software mapping.&lt;/strong&gt; The same method was applied to a commercial
business-management software, without reading its source code. The target is a triplet of
user, software and database truths linked by proven edges. Eleven heuristics (H1–H11) cover value matching,
inverse and direct perturbation, calculated fields, conditional visibility, scenario
diffs, invariant mining, input fuzzing, semantic priors (never treated as proof),
documentation-to-test and symptom-to-rule. Inputs: the menu tree via AT-SPI, a screen
survey, column profiling, and an event typology from the application audit log. A
hypothesis is accepted only with independent counter-evidence; vendor-produced maps are
held in quarantine as a scoring oracle.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;An illustrative excerpt of the typed graph (labels are fictitious): the recommendation is
only admissible because the chain down to a literal fragment of a source is complete.&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;flowchart TB
 R["`Recommendation
single entry point`"] -- responds to --&gt; C["`Finding
double data entry at goods receipt`"]
 O["`Observation
“stock is re-keyed by hand”`"] -- attests --&gt; C
 O -- extracted from --&gt; F["`Fragment
interview notes, lines 40–52`"]
 F -- cites --&gt; P["`Source file
hashed, immutable`"]
 C -- concerns --&gt; A["`Activity
goods receipt`"]
 A -- uses --&gt; S["`Screen
receipt form`"]
 S -- module of --&gt; Y["`System
business software`"]&lt;/pre&gt;&lt;h2 id="status--results"&gt;&lt;a href="#status--results" class="header-anchor"&gt;&lt;/a&gt;Status &amp;amp; results
&lt;/h2&gt;&lt;p&gt;This is &lt;strong&gt;mainly a design with partial implementation&lt;/strong&gt;, not a system in service.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Pilot (observation corpus, deterministic pass).&lt;/strong&gt; 6,195 files (6.92 GB) acquired
idempotently; 13,272 nodes and 6,022 edges extracted in 2.1 s at zero LLM cost; all six
invariants green over 57,969 checks.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Gold-set measurement.&lt;/strong&gt; 145 annotation units plus 100 entity-resolution pairs. Human
annotation was partial and the second annotator was an LLM. Human × LLM agreement:
κ = 0.72 (software), 0.41 (screen), 0.31 (category), 0.52 (segment boundaries).
Extractor precision against human reference: 0.74, 0.43, 0.04, 0.63. &lt;strong&gt;Gate G2 (≥ 0.95)
not passed&lt;/strong&gt;; the category result reflects a definitional mismatch, not a computation error.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Software mapping.&lt;/strong&gt; Schema of 427 tables with 0 declared foreign keys, triggers or views
(all logic is applicative); 1,048 menu entries (899 leaves) mapped; 154 of 899 screens
surveyed (17 %); 2,845 rule hypotheses reduced to 1,519 by a local LLM filter, with 1 decision error in a
50-item sample reviewed by the supervising agent. A read-only MCP prototype exists; &lt;strong&gt;no
rule has yet been proven&lt;/strong&gt; in the software. Work paused after the host VM was overloaded.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Gold-set measurement against the gates fixed in advance: bars are the measured values per
field, the orange line is the gate. Only one bar clears its gate.&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;---
config:
 xyChart:
 width: 540
 height: 320
 themeVariables:
 xyChart:
 backgroundColor: "transparent"
 plotColorPalette: "#3F86B8, #C46A2B"
---
xychart-beta
 title "Human × LLM agreement κ (gate 0.7)"
 x-axis ["software", "screen", "category", "segments"]
 y-axis "κ" 0 --&gt; 1
 bar [0.72, 0.41, 0.31, 0.52]
 line [0.7, 0.7, 0.7, 0.7]&lt;/pre&gt;&lt;pre class="mermaid" style="visibility:hidden"&gt;---
config:
 xyChart:
 width: 540
 height: 320
 themeVariables:
 xyChart:
 backgroundColor: "transparent"
 plotColorPalette: "#3F86B8, #C46A2B"
---
xychart-beta
 title "Extractor precision vs human reference (gate 0.95)"
 x-axis ["software", "screen", "category", "segments"]
 y-axis "Precision" 0 --&gt; 1
 bar [0.74, 0.43, 0.04, 0.63]
 line [0.95, 0.95, 0.95, 0.95]&lt;/pre&gt;&lt;p&gt;The software mapping, as a funnel: the menu tree is fully mapped, but screen coverage and
proof lag far behind, and the hypothesis pipeline has not yet produced a single proven rule.&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;---
config:
 xyChart:
 width: 540
 height: 360
 themeVariables:
 xyChart:
 backgroundColor: "transparent"
 plotColorPalette: "#3F86B8"
---
xychart-beta
 title "Black-box mapping: from inventory to proof"
 x-axis ["Menu entries", "Leaves", "Screens", "Hypotheses", "Filtered", "Proven"]
 y-axis "Count" 0 --&gt; 3000
 bar [1048, 899, 154, 2845, 1519, 0]&lt;/pre&gt;&lt;p&gt;The MCP servers and end-to-end evaluation (P4–P5) are &lt;strong&gt;not implemented&lt;/strong&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;p&gt;Pilot results are below threshold and rest on partial human annotation. Next steps:
redefine the category label, complete a fully human reference, re-measure G1/G2, then build
the three servers and run the pre-registered evaluation. For the software mapping, the
write-perturbation heuristics await written authorisation and a dedicated VM.&lt;/p&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 3.11&lt;/code&gt; · &lt;code&gt;JSONL event log&lt;/code&gt; · &lt;code&gt;SQLite&lt;/code&gt; · &lt;code&gt;YAML ontology&lt;/code&gt; · &lt;code&gt;MCP&lt;/code&gt; · &lt;code&gt;AT-SPI&lt;/code&gt; · &lt;code&gt;Ollama&lt;/code&gt; · &lt;code&gt;pytest&lt;/code&gt;&lt;/p&gt;</description></item><item><title>Deterministic Process Automation Under Strong Security Constraints</title><link>https://borisdupoux.com/research/deterministic-rpa/</link><pubDate>Mon, 01 Jun 2026 00:00:00 +0000</pubDate><guid>https://borisdupoux.com/research/deterministic-rpa/</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;A client operating under strong security constraints manages several hundred
sites and runs four recurring back-office processes every day: maintaining field
equipment, handling recurring spurious equipment events, writing intervention
reports for its customers, and checking that each site&amp;rsquo;s periodic health check
has arrived. The inputs are scattered across three systems with very different
access paths: PDF reports delivered by e-mail, a business web portal with no
public API, and Excel workbooks maintained by hand over many years.&lt;/p&gt;
&lt;p&gt;The goal: automate these processes on an office workstation while keeping a
human operator as the final authority on every customer message.&lt;/p&gt;
&lt;h2 id="design-constraints"&gt;&lt;a href="#design-constraints" class="header-anchor"&gt;&lt;/a&gt;Design constraints
&lt;/h2&gt;&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;No language model at run time.&lt;/strong&gt; Every decision comes from explicit rules
and every sentence from a template. This makes the robot&amp;rsquo;s output
reproducible: the same inputs always produce the same drafts, so a new version
can be compared word for word against production, and any change traces back
to a rule or a data source. No customer data goes to an external model
service. A vision-language model was used once, offline, during design, to map
recorded operator screen sessions; it is not part of the deployed system.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Drafts, not sends.&lt;/strong&gt; The robot only creates Outlook drafts; the operator
reviews and sends them. A global kill switch forces draft mode regardless of
per-process settings.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Local execution, no new infrastructure.&lt;/strong&gt; Python on a Windows workstation,
scheduled tasks, a shared NAS folder for state and configuration.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No plaintext secrets.&lt;/strong&gt; Credentials and tokens live in the Windows
Credential Manager (DPAPI); configuration is YAML the operator can edit
without a rebuild.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="architecture"&gt;&lt;a href="#architecture" class="header-anchor"&gt;&lt;/a&gt;Architecture
&lt;/h2&gt;&lt;pre class="mermaid" style="visibility:hidden"&gt;flowchart TB
 SCHED["Scheduled passes (morning / afternoon)"] --&gt; ING
 MAIL["Mailbox (Microsoft Graph, read)"] --&gt; ING["Ingestion PDF reports grouped by day"]
 ING --&gt; PARSE["PDF parser (pypdf + regular expressions)"]
 PORTAL["Business web portal Playwright login → session token → JSON endpoints replayed"] --&gt; RULES
 XLS["Reference &amp; history workbooks (Excel)"] --&gt; RULES
 PARSE --&gt; RULES["Rule engine 4 processes, ordered rules &amp; exclusions"]
 RULES --&gt; TPL["Template composer French agreement, names, dates"]
 TPL --&gt; DRAFTS["Outlook drafts (Graph, never sent)"]
 RULES --&gt; TRACK["Tracking workbooks (atomic append)"]
 DRAFTS --&gt; OP(["Operator reviews &amp; sends"])
 OP -. "sent items: corrected recipients" .-&gt; RULES
 NAS[("NAS: locks, heartbeats, journal, idempotence ledger")] --- RULES
 RULES --&gt; HEALTH["Health alerts webhook + dead-man check"]&lt;/pre&gt;&lt;h2 id="the-four-processes"&gt;&lt;a href="#the-four-processes" class="header-anchor"&gt;&lt;/a&gt;The four processes
&lt;/h2&gt;&lt;p&gt;&lt;strong&gt;Equipment maintenance.&lt;/strong&gt; Fault lines parsed from the daily PDF reports are
matched against each device&amp;rsquo;s battery and repair history. An ordered decision
tree (first match wins) chooses between ignoring, waiting, battery replacement,
device replacement or radio-range testing, and drafts a technician work order;
each device on a multi-device order keeps its own decision.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Recurring spurious events.&lt;/strong&gt; Event counts per site and per device over 24
hours are compared with thresholds to detect recurring spurious events. Portal
data (an incident confirmed as real, the system being switched off minutes
after a burst) overrides the counts; the outcome is an advisory e-mail to the
customer, a work order, or nothing.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Intervention reports.&lt;/strong&gt; For each on-site intervention, the robot rebuilds a
single timeline from the portal&amp;rsquo;s event log, call log and field-staff records,
handles events that straddle midnight, and drafts a customer report with the
media files attached.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Periodic health checks.&lt;/strong&gt; Sites whose periodic health check is missing are
identified, the status of each connectivity channel is checked on the portal,
and a customer e-mail, a technician work order or an internal note is drafted.&lt;/p&gt;
&lt;h2 id="composing-an-e-mail-from-parameters-to-a-draft"&gt;&lt;a href="#composing-an-e-mail-from-parameters-to-a-draft" class="header-anchor"&gt;&lt;/a&gt;Composing an e-mail: from parameters to a draft
&lt;/h2&gt;&lt;p&gt;Each draft is the end of a long chain of explicit decisions. Counted in the
source code (October 2026), the four rule engines have &lt;strong&gt;53 terminal outcomes&lt;/strong&gt;
(each place where a rule returns a decision), mapped onto &lt;strong&gt;37 decision types&lt;/strong&gt;
and &lt;strong&gt;13 draft builders&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;The customer advisory e-mail of the spurious-events process shows the scale. A
site reaches it only after up to &lt;strong&gt;14 ordered tests&lt;/strong&gt; in the rule engine (16
terminal outcomes for this process alone). The composer then reads &lt;strong&gt;21
inputs&lt;/strong&gt; — 17 fields of the site&amp;rsquo;s decision record and customer record, the
recipient list, the attachments, and two learned resources (zone names from the
portal and a spelling dictionary learned from the operator&amp;rsquo;s own history
workbook). Between the decision and the final text there are &lt;strong&gt;108 conditional
branch points&lt;/strong&gt; (&lt;code&gt;if&lt;/code&gt; statements, conditional expressions and boolean operands,
counted on the syntax tree of the composer and the ten helpers it calls, before
the HTML pass).&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;flowchart TB
 IN(["1 · Rule engine — 14 ordered tests, first match wins. Input: daily event reports · portal journal · customer record · learned site profile"]) --&gt; T0

 T0["Blocking flag on the site record?"] -- yes --&gt; X0["No e-mail"]
 T0 -- no --&gt; T1["R6 · incident confirmed as real today?"]
 T1 -- yes --&gt; X1["No e-mail"]
 T1 -- no --&gt; T2["R17 · media events &amp;lt; 30 s apart across zones?"]
 T2 -- yes --&gt; X2["Manual review"]
 T2 -- no --&gt; T3["R12 · external cause confirmed on media?"]
 T3 -- no --&gt; T4["R8 · ≥ 8 integrity events?"]
 T4 -- yes --&gt; T4b["persisting ≥ 3 days?"]
 T4b -- yes --&gt; X3["Technician work order"]
 T4b -- no --&gt; X4["Keep watching"]
 T4 -- no --&gt; T5["≥ 2 integrity events, ≥ half of the burst?"]
 T5 -- yes --&gt; X5["No e-mail"]
 T5 -- no --&gt; T6["R16 · ≥ 140 events, advisory ignored ≥ 4 weeks? (off by default)"]
 T6 -- yes --&gt; X6["Billing notice"]
 T6 -- no --&gt; T7["R15 · shared-building flag?"]
 T7 -- yes --&gt; X7["Coordination e-mail"]
 T7 -- no --&gt; T8["R5 · weekend + chronic-device flag?"]
 T8 -- yes --&gt; X8["Temporary masking request"]
 T8 -- no --&gt; T9["R1 · same device ≥ 7 events?"]
 T9 -- no --&gt; T10["R2 · ≥ 5 devices and ≥ 8 events?"]
 T10 -- no --&gt; T11["R4 · ≥ 8 events, ≥ 50 % 05:00–07:00?"]
 T11 -- no --&gt; T12["R3 · ≥ 8 events, ≥ 60 % 12:00–18:00, ≥ 70 % in 4 h?"]
 T12 -- no --&gt; T13["R1bis · site total ≥ 7 events?"]
 T13 -- no --&gt; X9["No e-mail"]

 T3 &amp; T9 &amp; T10 &amp; T11 &amp; T12 &amp; T13 -- yes --&gt; ADV

 ADV(["2 · Composer — advisory e-mail, rule id carried along. Customer name: legal form removed, re-cased, acronyms kept"])
 ADV --&gt; SUBJ["≥ 30 events?"]
 SUBJ -- yes --&gt; S1["Subject with urgent prefix"]
 SUBJ -- no --&gt; S2["Plain subject"]
 S1 &amp; S2 --&gt; GR["Contact cell"]
 GR -- "several people, or a role / organisation word" --&gt; G1["Generic greeting"]
 GR -- "title + surname" --&gt; G2["Greeting + normalised title (7 abbreviations) + SURNAME"]
 GR -- "surname only" --&gt; G3["Greeting + SURNAME"]
 G1 &amp; G2 &amp; G3 --&gt; DEV["Devices involved"]
 DEV -- one --&gt; D1["Portal zone name if known, else cleaned label + jargon map + learned accents"]
 DEV -- several --&gt; D2["Generic phrase + list of ≤ 6 by count, each with its device code if known"]
 D1 &amp; D2 --&gt; HND["System switched on/off within minutes of the burst? (R1–R4 only)"]
 HND -- "off just after" --&gt; C1["Handling-error wording A"]
 HND -- "on just before" --&gt; C2["Handling-error wording B"]
 HND -- no --&gt; ACT["Site in activity? — all of: R1–R4 or R1bis · ≥ 2 devices · ≥ 10 events · no intervention in the window · professional site · ≥ 90 % in learned hours · media sensors only: ≥ 90 % while on"]
 ACT -- yes --&gt; C3["Site-in-activity wording"]
 ACT -- no --&gt; RUL["Rule that fired"]
 RUL -- "R1, R2" --&gt; C4["Generic environmental cause"]
 RUL -- "R3, R4" --&gt; C5["Staff-passage cause"]
 RUL -- R12 --&gt; C6["External cause confirmed on media"]
 RUL -- R1bis --&gt; C7["Cause to investigate on site"]
 C4 &amp; C5 --&gt; ATT["Media actually attached?"]
 ATT -- yes --&gt; A1["+ reference to the attachments"]
 ATT -- no --&gt; FLG
 A1 &amp; C1 &amp; C2 &amp; C3 &amp; C6 &amp; C7 --&gt; FLG["Site flag: frequent contractor works?"]
 FLG -- yes --&gt; F1["Request: switch off before works"]
 FLG -- no --&gt; F2["Request: treat the cause"]
 F1 &amp; F2 --&gt; REC["Addresses in the customer record?"]
 REC -- yes --&gt; RC1["All addresses found"]
 REC -- no --&gt; RC2["Internal fallback mailbox"]
 RC1 &amp; RC2 --&gt; DRAFT(["Plain text → Outlook HTML (unfolding, lists, fixed typography, signature) → drafts folder, never sent"])
 classDef q stroke-dasharray:4 3,stroke-width:2px;
 class T0,T1,T2,T3,T4,T4b,T5,T6,T7,T8,T9,T10,T11,T12,T13,SUBJ,GR,DEV,HND,ACT,RUL,ATT,FLG,REC q;&lt;/pre&gt;&lt;p&gt;The wording space of this single e-mail: 2 subject forms, 3 greeting forms, 2
opening-sentence forms, 7 reachable cause wordings (two of them with or without
a reference to attached media), 2 requested actions, and an optional device list
of 2 to 6 lines. Every threshold above lives in the YAML configuration, not in
the code.&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;Parameter&lt;/th&gt;
					&lt;th&gt;Source&lt;/th&gt;
					&lt;th&gt;Effect on the e-mail&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;Event count over 24 h&lt;/td&gt;
					&lt;td&gt;daily reports&lt;/td&gt;
					&lt;td&gt;figure in the opening sentence; ≥ 30 adds an urgent prefix to the subject&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Rule that fired&lt;/td&gt;
					&lt;td&gt;rule engine&lt;/td&gt;
					&lt;td&gt;selects the cause wording; gates the handling-error and site-in-activity checks&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Devices involved (cleaned, de-duplicated)&lt;/td&gt;
					&lt;td&gt;daily reports&lt;/td&gt;
					&lt;td&gt;one named device, or a generic phrase plus a list of up to 6 lines ordered by count&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Device code per report line&lt;/td&gt;
					&lt;td&gt;daily reports&lt;/td&gt;
					&lt;td&gt;appended in parentheses to each listed device, only when known&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Zone name of the main device&lt;/td&gt;
					&lt;td&gt;portal&lt;/td&gt;
					&lt;td&gt;replaces the raw report label when available&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Learned spellings&lt;/td&gt;
					&lt;td&gt;operator&amp;rsquo;s history workbook&lt;/td&gt;
					&lt;td&gt;restores accents and casing of device names&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Title and contact-name cell&lt;/td&gt;
					&lt;td&gt;customer record&lt;/td&gt;
					&lt;td&gt;one of 3 greeting forms; several people, a role or an organisation name fall back to the generic form&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Company name&lt;/td&gt;
					&lt;td&gt;customer record&lt;/td&gt;
					&lt;td&gt;legal form removed, re-cased with acronyms kept, used in subject and body&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Site type, learned opening hours, interventions in the window&lt;/td&gt;
					&lt;td&gt;tracking workbook, portal journal&lt;/td&gt;
					&lt;td&gt;enable or suppress the site-in-activity wording&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;System switched on/off around the burst&lt;/td&gt;
					&lt;td&gt;portal journal&lt;/td&gt;
					&lt;td&gt;handling-error wording (2 variants), overriding every other cause&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Site flags&lt;/td&gt;
					&lt;td&gt;customer record&lt;/td&gt;
					&lt;td&gt;choice between the 2 requested actions&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Attachments present&lt;/td&gt;
					&lt;td&gt;portal media&lt;/td&gt;
					&lt;td&gt;reference to the attachments, or nothing&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Recipient addresses&lt;/td&gt;
					&lt;td&gt;customer record&lt;/td&gt;
					&lt;td&gt;every address found; otherwise an internal fallback mailbox&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The other drafts draw on the same shared French-language helpers: the
preposition before a site name agrees with its first noun (6 forms, from &lt;em&gt;chez&lt;/em&gt;
to &lt;em&gt;à l&amp;rsquo;&lt;/em&gt; and &lt;em&gt;aux&lt;/em&gt;); report titles agree in gender and number; dates are
written in full French form; a &amp;ldquo;Contact&amp;rdquo; heading becomes plural when several
contacts are listed; and an 8-entry map turns technical equipment jargon into
customer vocabulary.&lt;/p&gt;
&lt;h2 id="engineering-for-reliability"&gt;&lt;a href="#engineering-for-reliability" class="header-anchor"&gt;&lt;/a&gt;Engineering for reliability
&lt;/h2&gt;&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Idempotence.&lt;/strong&gt; A daily ledger keyed on site, message type and subject hash
prevents duplicate drafts; a cross-day register prevents re-issuing a work
order before the previous intervention has happened. Missed days are caught up
automatically.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Concurrency on a shared NAS.&lt;/strong&gt; Exclusive lock files (&lt;code&gt;O_CREAT|O_EXCL&lt;/code&gt;) with
a time-to-live for orphans; two workstations publish heartbeats, and the
secondary takes over only when the primary has been silent for two minutes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Safe spreadsheet writes.&lt;/strong&gt; Writes go through a temporary file and an atomic
replace, with retries when the workbook is open by a user; inserted rows shift
footer formulas so totals stay correct.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Network robustness.&lt;/strong&gt; Graph calls handle token refresh, HTTP 429
&lt;code&gt;Retry-After&lt;/code&gt; and 5xx back-off.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Explicit failure.&lt;/strong&gt; A crash wrapper with no dependency on the main package
sends a redacted alert and a dead-man ping even when imports fail; a sentinel
raises an alert when the portal returns no data at all instead of reporting
success.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Shadow benchmarking.&lt;/strong&gt; A sandbox build replays a day in read-only mode and
diffs its drafts against production. The acceptance metric is the share of
drafts the operator sends without changing a single word.&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;ul&gt;
&lt;li&gt;&lt;strong&gt;1,766&lt;/strong&gt; automated tests in the repository (October 2026), built on mock
mail and portal adapters, with live-system tests marked separately.&lt;/li&gt;
&lt;li&gt;An audit of the corpus of received reports showed that &lt;strong&gt;9.5 %&lt;/strong&gt; of event
lines were being silently discarded, because event codes and labels are
concatenated in the extracted PDF text; the parser was rewritten to recognise
the code forms actually emitted.&lt;/li&gt;
&lt;li&gt;Caching the fault-history workbook (1.49 MB, 15,533 rows) cut the equipment
maintenance process on a replay of real reports from &lt;strong&gt;161.1 s to 23.3 s&lt;/strong&gt;;
workbook opens fell from 26 to 3.&lt;/li&gt;
&lt;li&gt;First clean shadow comparison against production: &lt;strong&gt;14 drafts on each side,
14 in common, 0 missed, 0 added&lt;/strong&gt;.&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 3.11&lt;/code&gt; · &lt;code&gt;Microsoft Graph&lt;/code&gt; · &lt;code&gt;MSAL&lt;/code&gt; · &lt;code&gt;httpx&lt;/code&gt; · &lt;code&gt;Playwright&lt;/code&gt; · &lt;code&gt;pypdf&lt;/code&gt; ·
&lt;code&gt;openpyxl&lt;/code&gt; · &lt;code&gt;pydantic&lt;/code&gt; · &lt;code&gt;keyring&lt;/code&gt; · &lt;code&gt;pytest&lt;/code&gt; · &lt;code&gt;PyInstaller&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><item><title>From Multitrack Recordings to an Editable Timeline: Transcription, Diarisation and AAF Export</title><link>https://borisdupoux.com/research/speech-to-edit/</link><pubDate>Mon, 01 Jun 2026 00:00:00 +0000</pubDate><guid>https://borisdupoux.com/research/speech-to-edit/</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;A conference recorded on several microphones (speakers, room, music) produces
hours of raw multitrack audio. Most editing time goes into locating material —
who speaks when, on which track, where the silences are — before any cutting.&lt;/p&gt;
&lt;p&gt;Open-source speech tooling (Whisper and its derivatives, pyannote, Silero VAD)
solves transcription and &amp;ldquo;who spoke when&amp;rdquo; well, but stops at &lt;strong&gt;text&lt;/strong&gt;. The
question addressed here is whether that analysis can be delivered as an
&lt;strong&gt;editing object&lt;/strong&gt;: a multitrack timeline in AAF, the interchange format that
audio workstations open natively, with tracks qualified and dead time removed.&lt;/p&gt;
&lt;h2 id="before--after"&gt;&lt;a href="#before--after" class="header-anchor"&gt;&lt;/a&gt;Before → after
&lt;/h2&gt;&lt;p&gt;One real job processed end to end: a conference recorded on &lt;strong&gt;16 tracks&lt;/strong&gt;.
Everything on the right-hand side was produced automatically, with no manual
cut; figures are read from the job&amp;rsquo;s analysis database and from the delivered
AAF.&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;&lt;/th&gt;
					&lt;th&gt;Input (raw recording)&lt;/th&gt;
					&lt;th&gt;Generated output (AAF timeline)&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;Tracks&lt;/td&gt;
					&lt;td&gt;16 × 1 h 32 min 54 s (≈ 24 h 46 min of audio to review)&lt;/td&gt;
					&lt;td&gt;16 tracks kept in the AAF, 6 carrying clips&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Track roles&lt;/td&gt;
					&lt;td&gt;4 speech · 2 music (one auto-flagged as ambience) · 10 silent&lt;/td&gt;
					&lt;td&gt;10 silent tracks left empty, no manual triage&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Material&lt;/td&gt;
					&lt;td&gt;204 speech regions and 5 music passages detected&lt;/td&gt;
					&lt;td&gt;&lt;strong&gt;41 clips&lt;/strong&gt; (37 speech, 4 music)&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Cuts&lt;/td&gt;
					&lt;td&gt;—&lt;/td&gt;
					&lt;td&gt;&lt;strong&gt;40 hard cuts&lt;/strong&gt; between sources, of which 33 microphone switches&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Transitions&lt;/td&gt;
					&lt;td&gt;—&lt;/td&gt;
					&lt;td&gt;0 crossfades: straight cuts, each speech clip extended by a 0.3 s tail&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Dead time&lt;/td&gt;
					&lt;td&gt;—&lt;/td&gt;
					&lt;td&gt;3 gaps ≥ 4.5 s rippled out (36.9 s); timeline 1 h 32 min 17 s&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;img alt="Multitrack before/after: 16 input tracks coloured by role over time, and the generated edit showing which track is kept per segment, with source switches marked" class="gallery-image" data-flex-basis="333px" data-flex-grow="138" height="1152" loading="lazy" sizes="(max-width: 767px) calc(100vw - 30px), (max-width: 1023px) 700px, (max-width: 1279px) 950px, 1232px" src="https://borisdupoux.com/research/speech-to-edit/multitrack-before-after.png" srcset="https://borisdupoux.com/research/speech-to-edit/multitrack-before-after_hu_1f55e6879ef891c6.png 800w, https://borisdupoux.com/research/speech-to-edit/multitrack-before-after.png 1600w" width="1600"&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Top: the 16 input tracks over the full recording, coloured by the detected
role and activity; the four speech microphones are active almost all the time
because each one also picks up its neighbours. Bottom: the generated edit, one
lane per track as in the AAF — a single source is kept at any moment, ticks mark
the 40 source switches, hatched areas in the top panel are the removed dead
time.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Speech clips are few and long (median 79 s, longest 10 min 11 s): the montage
follows the active speaker rather than chopping on every breath, and
micro-switches shorter than 0.8 s are absorbed by the anti-flicker smoothing.
Ten clips are still shorter than 6 s — short interjections, the first place to
look when checking the edit.&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["Multitrack WAV recording"] --&gt; B["Decode to 16 kHz mono + RMS energy per 100 ms"]
 B --&gt; C["Voice activity detection (Silero)"]
 C --&gt; D["Transcription (Whisper large-v3, chunked)"]
 D --&gt; E["Forced word alignment (WhisperX / wav2vec2)"]
 C --&gt; F["Speaker diarisation (pyannote)"]
 E --&gt; G["Speaker assignment + segment post-processing"]
 F --&gt; G
 B --&gt; H["Track role: speech / music / silent"]
 G --&gt; H
 H --&gt; I["Montage: active microphone per 100 ms frame"]
 I --&gt; J["AAF export (linked to original WAV names)"]
 I --&gt; K["Mono mixdown WAV"]&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;Transcription.&lt;/strong&gt; Whisper &lt;code&gt;large-v3&lt;/code&gt; via faster-whisper (CTranslate2), run on
fixed-length chunks inside an isolated, persistent worker process: the model is
loaded once, and a native crash only loses one chunk, which is reported rather
than silently dropped. Decoding uses beam search with Silero VAD filtering and
&lt;code&gt;condition_on_previous_text&lt;/code&gt; disabled to limit repetition loops.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Word timing and speakers.&lt;/strong&gt; Word-level timestamps come from WhisperX forced
alignment (wav2vec2). Speaker turns come from pyannote diarisation and are
assigned to words by temporal overlap.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Post-processing.&lt;/strong&gt; Pure filters remove known Whisper hallucinations
(stock subtitle credits, &amp;ldquo;thanks for watching&amp;rdquo;-type phrases, music-only
segments), non-Latin output on French audio and duplicates, then merge
consecutive same-speaker segments.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Track qualification and montage.&lt;/strong&gt; Each track is classified as speech, music
or silent by combining frame energy, VAD coverage and transcript statistics.
The montage selects, for every 100 ms frame, the microphone with the highest
signal-to-noise ratio above its own noise floor (reducing cross-talk
&amp;ldquo;bleed&amp;rdquo;), applies anti-flicker smoothing and ripples out global dead time.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Synchronisation.&lt;/strong&gt; Separate recorders are aligned by matching distinctive
words across transcripts (robust median of offsets), after a coarse
clock-based placement corrected for clock drift.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Export.&lt;/strong&gt; The edit list is written as a multitrack AAF with &lt;code&gt;pyaaf2&lt;/code&gt;, either
embedding the audio or linking to it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Deployment.&lt;/strong&gt; A web portal accepts jobs and a separate compute worker polls
it over HTTPS. Clients upload only compressed analysis copies together with a
manifest of their original files; the delivered AAF references the
&lt;strong&gt;original WAV names&lt;/strong&gt;, so the editor relinks full-quality sources locally and
the originals never leave the client&amp;rsquo;s machine.&lt;/p&gt;
&lt;h2 id="results"&gt;&lt;a href="#results" class="header-anchor"&gt;&lt;/a&gt;Results
&lt;/h2&gt;&lt;p&gt;Benchmark (&lt;code&gt;bench_stt.py&lt;/code&gt;) on real conference audio: one-minute speech windows
selected by VAD on four speech tracks, Whisper &lt;code&gt;large-v3&lt;/code&gt; throughout, Apple
Silicon laptop, warm timings excluding model loading. No ground-truth
transcript was available, so quality figures are &lt;strong&gt;divergence from a
pseudo-reference&lt;/strong&gt; (WhisperX output), not absolute error.&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;Pipeline (CPU)&lt;/th&gt;
					&lt;th style="text-align: right"&gt;Real-time factor&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;openai-whisper&lt;/td&gt;
					&lt;td style="text-align: right"&gt;0.43&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;WhisperX&lt;/td&gt;
					&lt;td style="text-align: right"&gt;0.51&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;WhisperX + forced alignment&lt;/td&gt;
					&lt;td style="text-align: right"&gt;0.54&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;openai-whisper + word timestamps&lt;/td&gt;
					&lt;td style="text-align: right"&gt;0.57&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;faster-whisper (beam 5 + VAD)&lt;/td&gt;
					&lt;td style="text-align: right"&gt;0.66&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;faster-whisper + word timestamps&lt;/td&gt;
					&lt;td style="text-align: right"&gt;0.70&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Word timing:&lt;/strong&gt; native word timestamps deviate from forced alignment by a
median of ~205 ms (faster-whisper) and ~210 ms (openai-whisper); forced
alignment adds about 1.2 s per minute of audio.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Transcript divergence:&lt;/strong&gt; 7–11 % word-level divergence between pipelines,
mostly punctuation, segmentation and normalisation on manual inspection.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Repetitions:&lt;/strong&gt; 1–2 % repeated content; no hallucinated passages observed on
these clips.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Track detection:&lt;/strong&gt; the three music/silence tracks of the seven-track
recording were identified by VAD and skipped automatically.&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;Quality is measured against a pseudo-reference; a manually transcribed test
set is needed to report true word error rate and diarisation error rate.&lt;/li&gt;
&lt;li&gt;Benchmark clips are short (one minute per track) and all single-speaker
windows, so diarisation accuracy on multi-speaker overlap is not yet
quantified.&lt;/li&gt;
&lt;li&gt;CTranslate2 has no Metal backend: on Apple Silicon, faster-whisper and
WhisperX run on CPU, while CUDA is used where available. Device choice per
stage was benchmarked rather than assumed; a CUDA comparison remains to be
measured.&lt;/li&gt;
&lt;li&gt;AAF export targets Logic Pro import; other editors have not been
systematically tested.&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;faster-whisper&lt;/code&gt; · &lt;code&gt;CTranslate2&lt;/code&gt; · &lt;code&gt;WhisperX&lt;/code&gt; · &lt;code&gt;pyannote.audio&lt;/code&gt; · &lt;code&gt;Silero VAD&lt;/code&gt; · &lt;code&gt;PyTorch (MPS / CUDA)&lt;/code&gt; · &lt;code&gt;ffmpeg&lt;/code&gt; · &lt;code&gt;pyaaf2&lt;/code&gt; · &lt;code&gt;SQLite&lt;/code&gt; · &lt;code&gt;PHP&lt;/code&gt; · &lt;code&gt;MySQL&lt;/code&gt;&lt;/p&gt;</description></item><item><title>Geolocated Video Analysis and Inter-Object Distance Measurement</title><link>https://borisdupoux.com/research/video-geolocation/</link><pubDate>Thu, 01 Jan 2026 00:00:00 +0000</pubDate><guid>https://borisdupoux.com/research/video-geolocation/</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;Industrial sites are often covered by fixed cameras whose footage is only reviewed after an incident and yields no usable data. The aim was to turn existing cameras, and occasional drone footage, into a measuring instrument: for every detected object, a latitude/longitude position and metric distances rather than a pixel box.&lt;/p&gt;
&lt;p&gt;This is hard for three reasons. A single camera gives no depth, so scene scale must be inferred. The usual georeferencing method, a ground-plane homography, assumes a flat floor and fails silently for raised objects or uneven terrain. And high-resolution video is expensive, while the common shortcut of downscaling to 640 px discards the geometric detail ground positions depend on.&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["Video source&lt;br/&gt;file · RTSP · drone clip (up to 3840×2160)"] --&gt; B["Object detector at native resolution&lt;br/&gt;detect · segment · pose · OBB"]
 A --&gt; C["Metric monocular depth model&lt;br/&gt;depth map"]
 B --&gt; D["Multi-object tracker&lt;br/&gt;motion model + association"]
 B --&gt; E["Per-object depth&lt;br/&gt;mask pixels, outlier rejection"]
 C --&gt; E
 E --&gt; F["Ray casting&lt;br/&gt;pixel ray + metric depth + camera pose"]
 F --&gt; G["WGS84 position&lt;br/&gt;optional DEM for ground elevation"]
 E --&gt; H["3D distances&lt;br/&gt;between masks · along trajectories"]
 D --&gt; H
 G --&gt; I["SQLite store&lt;br/&gt;detections · tracks · GPS trajectories"]
 H --&gt; I
 I --&gt; J["Analytics&lt;br/&gt;zones · flows · orthophoto overlay"]&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;Detection.&lt;/strong&gt; A YOLO-family detector, at several model sizes, in four modes (detect, segment, pose, oriented boxes). Inference runs at native resolution with no forced &lt;code&gt;imgsz&lt;/code&gt;. Segmentation is the default because masks give cleaner depth samples than boxes. PyTorch, ONNX, TensorRT and CoreML weights are accepted (CUDA and Apple MPS).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Monocular depth.&lt;/strong&gt; A metric monocular depth model predicts a depth map, which is resampled to the frame size. Each object&amp;rsquo;s depth is a robust estimate over its mask pixels, after outlier rejection. A validation step compares predicted depth with reference zones whose distance is known, to detect calibration drift.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Geolocation by ray casting.&lt;/strong&gt; A ray is cast from the camera centre through the object&amp;rsquo;s pixel and stopped at the object&amp;rsquo;s metric depth. The resulting point is converted from the camera frame to a local North-East-Down frame, then to WGS84. Unlike a homography, this does not assume a flat ground. An optional DEM supplies ground elevation. Positions can be overlaid on a georeferenced orthophoto for visual checks.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tracking.&lt;/strong&gt; A custom multi-object tracker with a motion model, confidence-aware association and a track-fusion step that links fragments using gap, heading and speed limits. When depth is available, each track stores a 3D trajectory, from which the distance travelled and the speed in m/s are derived.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Inter-object distances.&lt;/strong&gt; Later consolidated into a reusable vision library. Two segmented objects are back-projected into 3D, and the minimum point-to-point distance is reported. Overlapping masks are treated as contact. A Monte Carlo simulation perturbs depth, focal length and pixel positions to give a standard deviation and a 95 % interval. Zone membership uses the share of the mask inside the zone, with hysteresis, rather than a centre-point test.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Engineering.&lt;/strong&gt; The configuration supports a two-GPU split (detection on one device, depth estimation on the other), RTSP discovery and recording, and processing of several cameras in parallel.&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;Measured on a single NVIDIA RTX 5090. The detection benchmark covers detection and tracking only, with depth disabled.&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;Setting&lt;/th&gt;
					&lt;th&gt;Video&lt;/th&gt;
					&lt;th&gt;Throughput&lt;/th&gt;
					&lt;th&gt;Output&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;Largest detector, batch 1&lt;/td&gt;
					&lt;td&gt;3840×2160, 50 fps, 3,301 frames&lt;/td&gt;
					&lt;td&gt;5.1 fps&lt;/td&gt;
					&lt;td&gt;320,217 detections, 1,941 tracks&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Smallest detector, batch 1&lt;/td&gt;
					&lt;td&gt;3840×2160, 50 fps&lt;/td&gt;
					&lt;td&gt;13.0 fps&lt;/td&gt;
					&lt;td&gt;158,989 detections, 908 tracks&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Largest detector, batch 1&lt;/td&gt;
					&lt;td&gt;2304×1296, 15 fps, 5,000 frames&lt;/td&gt;
					&lt;td&gt;17.9 fps&lt;/td&gt;
					&lt;td&gt;73,502 detections, 2,706 tracks&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Smallest detector, batch 1&lt;/td&gt;
					&lt;td&gt;2304×1296, 15 fps&lt;/td&gt;
					&lt;td&gt;39.0 fps&lt;/td&gt;
					&lt;td&gt;51,890 detections, 1,499 tracks&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Full chain (detection + depth + tracking + storage)&lt;/td&gt;
					&lt;td&gt;2304×1296, 300 frames&lt;/td&gt;
					&lt;td&gt;2.02 fps&lt;/td&gt;
					&lt;td&gt;12.3 detections per frame&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;In the full-chain run, depth estimation took 117.6 s of the 148.3 s total (about 80 %), and detection took 20.9 s. Depth estimation, not the detector, is the bottleneck. On 4K footage the largest detector runs about ten times slower than real time. That suits the intended use, which is analysis of recorded archives, not live alerting.&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;No geolocation accuracy has been measured against surveyed ground truth yet. The ray-casting stage was added after the timing runs above, so its cost is not included in them.&lt;/li&gt;
&lt;li&gt;The metric scale depends on the depth model and on the camera pose and intrinsics. Errors in tilt or focal length propagate directly into ground positions.&lt;/li&gt;
&lt;li&gt;The depth model predicts at its own internal resolution, so fine depth detail is limited even when detection runs in 4K.&lt;/li&gt;
&lt;li&gt;Next steps: an accuracy study with GNSS-surveyed targets, profiling the two-GPU split, and measuring how accurate Monte Carlo distance intervals are on objects at known separations.&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;PyTorch&lt;/code&gt; · &lt;code&gt;OpenCV&lt;/code&gt; · &lt;code&gt;NumPy&lt;/code&gt; · &lt;code&gt;SciPy&lt;/code&gt; · &lt;code&gt;pyproj&lt;/code&gt; · &lt;code&gt;rasterio&lt;/code&gt; · &lt;code&gt;SQLite&lt;/code&gt; · &lt;code&gt;CUDA&lt;/code&gt; · &lt;code&gt;Apple MPS&lt;/code&gt;&lt;/p&gt;</description></item><item><title>Pollutant Detection by Drone (Day &amp; Night)</title><link>https://borisdupoux.com/research/pollutant-detection/</link><pubDate>Mon, 01 Jul 2024 00:00:00 +0000</pubDate><guid>https://borisdupoux.com/research/pollutant-detection/</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;Detect &lt;strong&gt;gas and hydrocarbon pollutants&lt;/strong&gt; from a drone, &lt;strong&gt;day and night&lt;/strong&gt;, to monitor
mobile pollution sources (vessels, machinery) in maritime and industrial zones.&lt;/p&gt;
&lt;h2 id="pipeline"&gt;&lt;a href="#pipeline" class="header-anchor"&gt;&lt;/a&gt;Pipeline
&lt;/h2&gt;&lt;pre class="mermaid" style="visibility:hidden"&gt;flowchart TB
 A["Drone — thermal/IR + visible"] --&gt; B["Day / night acquisition"]
 B --&gt; C["Pollutant signature detection"]
 C --&gt; D["Localisation on moving targets"]
 D --&gt; E["Maritime / industrial monitoring"]&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;Combined thermal/infrared and visible-spectrum imaging.&lt;/li&gt;
&lt;li&gt;Detection and localisation of pollutant traces on &lt;strong&gt;moving&lt;/strong&gt; targets.&lt;/li&gt;
&lt;li&gt;Operational in both daytime and nighttime conditions.&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;ul&gt;
&lt;li&gt;Detection of fuel leaks and hydrocarbon discharge in port areas and coastal waters.&lt;/li&gt;
&lt;li&gt;Continuous monitoring capability for maritime and industrial zones.&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;OpenCV&lt;/code&gt; · thermal/IR imaging · drone&lt;/p&gt;</description></item><item><title>Satellite-Based Sargassum Detection &amp; Stranding Prediction</title><link>https://borisdupoux.com/research/sargassum-detection/</link><pubDate>Sat, 01 Jun 2024 00:00:00 +0000</pubDate><guid>https://borisdupoux.com/research/sargassum-detection/</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;Sargassum strandings disrupt Caribbean coastlines. The goal: &lt;strong&gt;detect algae rafts
at sea early&lt;/strong&gt; and &lt;strong&gt;predict where and how much&lt;/strong&gt; will strand, so municipalities
and environmental authorities can plan cleanup — and interventions at sea — in
advance.&lt;/p&gt;
&lt;h2 id="the-problem-raw-reflectance-is-hard-to-read"&gt;&lt;a href="#the-problem-raw-reflectance-is-hard-to-read" class="header-anchor"&gt;&lt;/a&gt;The problem: raw reflectance is hard to read
&lt;/h2&gt;&lt;p&gt;In raw multispectral satellite imagery, Sargassum is a faint signal on a large,
variable ocean background. Rafts are thin, fragmented and often low-contrast:
their edges blur into the surrounding water, and the overall structure of a
band — where it starts, where it thins out, where it breaks up — is difficult
to make out by eye or with generic indices.&lt;/p&gt;
&lt;p&gt;Detecting &lt;em&gt;that&lt;/em&gt; there is Sargassum is not enough. To act, one needs to see
&lt;strong&gt;what it looks like&lt;/strong&gt;.&lt;/p&gt;
&lt;h2 id="core-contribution-an-original-contrast-indicator"&gt;&lt;a href="#core-contribution-an-original-contrast-indicator" class="header-anchor"&gt;&lt;/a&gt;Core contribution: an original contrast indicator
&lt;/h2&gt;&lt;p&gt;I designed a &lt;strong&gt;dedicated contrast indicator&lt;/strong&gt; for Sargassum. Rather than simply
flagging pixels, it is built to give a &lt;strong&gt;very clear reading of the data and of
the shapes&lt;/strong&gt;: rafts and bands stand out sharply from the water, with well-defined
contours and their internal structure preserved. (Formulation details withheld
for now.)&lt;/p&gt;
&lt;p&gt;What it brings:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Clear delineation&lt;/strong&gt; of rafts and of elongated Sargassum bands.&lt;/li&gt;
&lt;li&gt;A readable picture of &lt;strong&gt;how the mass is organised&lt;/strong&gt;, not just where it is.&lt;/li&gt;
&lt;li&gt;A cleaner input for segmentation, quantification and drift modelling downstream.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="why-shape-matters"&gt;&lt;a href="#why-shape-matters" class="header-anchor"&gt;&lt;/a&gt;Why shape matters
&lt;/h2&gt;&lt;p&gt;The geometry of the bands is the key information for operations:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Dispersion&lt;/strong&gt; — is the Sargassum concentrated in a dense raft or scattered
in many small patches?&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Trailing (&amp;ldquo;traîne&amp;rdquo;)&lt;/strong&gt; — how the band stretches and elongates behind its
head, revealing how it is being pulled by currents and wind.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Orientation and drift direction&lt;/strong&gt; — which way the band is heading, and
therefore which stretches of coast are exposed.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Reading these shapes is what makes it possible to &lt;strong&gt;plan interventions on site&lt;/strong&gt;
(where to deploy collection at sea or barriers) and to &lt;strong&gt;anticipate strandings&lt;/strong&gt;
rather than react to them.&lt;/p&gt;
&lt;h2 id="pipeline"&gt;&lt;a href="#pipeline" class="header-anchor"&gt;&lt;/a&gt;Pipeline
&lt;/h2&gt;&lt;pre class="mermaid" style="visibility:hidden"&gt;flowchart TB
 A["Satellite imagery — multispectral"] --&gt; B["Cloud / water masking"]
 B --&gt; C["Original Sargassum contrast indicator"]
 C --&gt; D["Raft &amp; band delineation — shape, dispersion, trailing"]
 D --&gt; E["Quantification at sea"]
 E --&gt; F["Drift / stranding model"]
 F --&gt; G["Alerts &amp; intervention planning"]&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;Multispectral satellite imagery analysis.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Original contrast indicator&lt;/strong&gt; for a sharp reading of Sargassum rafts and bands.&lt;/li&gt;
&lt;li&gt;Shape analysis of the bands: dispersion, elongation/trailing, orientation.&lt;/li&gt;
&lt;li&gt;Raft detection and surface-area quantification at sea.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Predictive drift/stranding modelling&lt;/strong&gt; to anticipate landing zones and volumes.&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;ul&gt;
&lt;li&gt;Clear, readable maps of Sargassum rafts and bands at sea.&lt;/li&gt;
&lt;li&gt;Early-warning capability for coastal municipalities.&lt;/li&gt;
&lt;li&gt;Quantitative stranding forecasts supporting proactive cleanup and intervention planning.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;em&gt;Visuals and further details will be added.&lt;/em&gt;&lt;/p&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; · satellite imagery · spectral analysis · predictive modelling&lt;/p&gt;</description></item><item><title>Sub-Millimetre Crack Detection in Civil Structures</title><link>https://borisdupoux.com/research/crack-detection/</link><pubDate>Fri, 01 Mar 2024 00:00:00 +0000</pubDate><guid>https://borisdupoux.com/research/crack-detection/</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;Non-destructive structural health monitoring for bridges, buildings and
infrastructure. The challenge is not only to &lt;strong&gt;detect&lt;/strong&gt; cracks, but to
&lt;strong&gt;classify them by width&lt;/strong&gt; — without contact or scaffolding.&lt;/p&gt;
&lt;p&gt;Width is what matters for inspection: depending on how open a crack is, the
&lt;strong&gt;repair (filling) method is not the same&lt;/strong&gt;. An inspection system therefore has
to be very precise, because a wrong width class means a wrong repair
specification.&lt;/p&gt;
&lt;h2 id="four-width-classes"&gt;&lt;a href="#four-width-classes" class="header-anchor"&gt;&lt;/a&gt;Four width classes
&lt;/h2&gt;&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;Class&lt;/th&gt;
					&lt;th&gt;Crack width&lt;/th&gt;
					&lt;th&gt;Consequence&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;1&lt;/td&gt;
					&lt;td&gt;&amp;lt; 0.3 mm&lt;/td&gt;
					&lt;td&gt;Finest cracks — often narrower than a pixel; dedicated repair approach&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;2&lt;/td&gt;
					&lt;td&gt;0.3 – 0.7 mm&lt;/td&gt;
					&lt;td&gt;Different repair approach&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;3&lt;/td&gt;
					&lt;td&gt;0.7 – 1 mm&lt;/td&gt;
					&lt;td&gt;Different repair approach&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;4&lt;/td&gt;
					&lt;td&gt;≥ 1 mm&lt;/td&gt;
					&lt;td&gt;Different repair approach&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Each class calls for its own filling/repair approach, so the classification
directly drives the maintenance decision.&lt;/p&gt;
&lt;h2 id="the-sub-pixel-challenge"&gt;&lt;a href="#the-sub-pixel-challenge" class="header-anchor"&gt;&lt;/a&gt;The sub-pixel challenge
&lt;/h2&gt;&lt;p&gt;The key difficulty is to go &lt;strong&gt;below the pixel size&lt;/strong&gt;. A 0.3 mm crack does not
even cover a full pixel at typical inspection resolutions — and the system must
still detect it, even when the ground sampling distance is somewhat coarser than
0.3 mm.&lt;/p&gt;
&lt;p&gt;Why is this possible at all? A crack thinner than a pixel does not vanish: it
still occupies &lt;strong&gt;part&lt;/strong&gt; of the pixels it crosses, and slightly alters their
intensity compared with the surrounding surface. Taken individually, that
partial-pixel signal is weak; but a crack is a &lt;strong&gt;continuous, elongated&lt;/strong&gt; feature,
so the same faint contrast repeats coherently along many adjacent pixels. Exploiting
that contrast and continuity is what allows detection — and width estimation —
beyond the nominal resolution of the image.&lt;/p&gt;
&lt;h2 id="acquisition-drone-and-smartphone"&gt;&lt;a href="#acquisition-drone-and-smartphone" class="header-anchor"&gt;&lt;/a&gt;Acquisition: drone and smartphone
&lt;/h2&gt;&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Drone photography&lt;/strong&gt; — high-resolution UAV capture of structural surfaces,
for large or hard-to-reach structures.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Smartphone photography&lt;/strong&gt; — the approach also works on images taken with a
mobile phone, which I have tested, making close-range inspection possible
with everyday equipment.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="pipeline"&gt;&lt;a href="#pipeline" class="header-anchor"&gt;&lt;/a&gt;Pipeline
&lt;/h2&gt;&lt;pre class="mermaid" style="visibility:hidden"&gt;flowchart TB
 A["Image capture — drone or smartphone"] --&gt; B["Image tiling &amp; calibration"]
 B --&gt; C["Crack segmentation — CV / DL"]
 C --&gt; D["Sub-pixel width estimation"]
 D --&gt; E["Width classification — 4 classes"]
 E --&gt; F["Defect map &amp; inspection report"]&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;High-resolution capture of structural surfaces by drone or smartphone.&lt;/li&gt;
&lt;li&gt;Computer-vision / deep-learning crack segmentation on concrete and masonry.&lt;/li&gt;
&lt;li&gt;Metric calibration and &lt;strong&gt;sub-pixel&lt;/strong&gt; analysis for crack width estimation.&lt;/li&gt;
&lt;li&gt;Classification into four width classes matched to repair methods.&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;ul&gt;
&lt;li&gt;Non-destructive, repeatable inspection of civil structures.&lt;/li&gt;
&lt;li&gt;Geolocated defect maps supporting maintenance prioritisation.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;em&gt;Models and visuals will be added.&lt;/em&gt;&lt;/p&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;OpenCV&lt;/code&gt; · deep learning · high-res RGB drone · smartphone imagery · photogrammetry&lt;/p&gt;</description></item></channel></rss>