# Vincenzo Imperati: complete site content > Software Engineer in Zurich, Switzerland. Software engineer in Zurich. I build data infrastructure for open protocols and operate what I build, alongside peer-reviewed measurement research at USENIX Security and IEEE Access. This document contains every page of https://vincenzo.imperati.dev in full, in one file. Sections are labelled by type: [Project], [Research], [Post]. Generated at build time from the site's content collections. --- # Projects ## [Project] sitesift URL: https://vincenzo.imperati.dev/projects/sitesift Category: Personal Stack: Python, trafilatura, Anthropic API, SQLite Repository: https://github.com/VincenzoImp/sitesift Published: 2026-07-11 A URL classification pipeline that splits deterministic evidence collection from LLM judgment, producing structured and reproducible records of what a site is, what it is about, and how confident the verdict was. import Callout from '../../components/ui/Callout.astro' import RepoCard from '../../components/ui/RepoCard.astro' import SpecPanel from '../../components/ui/SpecPanel.astro' Given a list of URLs, sitesift produces one structured record per URL: what kind of site it is, what it is about, what language it is in, technical metadata, quality flags and, for each decision, how confident it was and which method produced it. ## The problem Pulling text and metadata out of a page is solved: sitesift uses `trafilatura` for it. The gap is turning that evidence into a *judgment*: this is a news site, it is about football, it is parked, this one is a login wall. Judgments are what downstream work needs, and they are what nothing gives you cheaply, reproducibly, and at a scale where every extra model call is a line item. ## What I built The pipeline is split in two, and the split is the design. **The deterministic layer** – normalise, fetch, extract – never calls a model. It deduplicates by registrable domain, respects robots, guards against SSRF, rate-limits, extracts with `trafilatura`, reads JSON-LD, and detects language with a language identifier rather than by asking a model to guess. Its job is to produce every canonical fact about a page. **The judgment layer** is where the model decides. It reads the whole evidence bundle and returns the site type and topic hierarchy, starting with a cheap model and escalating to a stronger one only when the cheap one is not confident. Every record carries which method produced it: `llm_small`, `llm_large`, or `blocked`. The one decision the deterministic layer is allowed to make is to skip a page that has no content at all: dead, parked, a soft 404, not HTML. Those never reach a model, so no call is spent on a page with nothing to read. ## Hard parts **Deciding where the model is allowed to be.** Letting a model do the extraction too would be simpler to write and would destroy every property that matters here: a run could not be resumed after a crash, tests could not run offline, reclassifying under a new prompt would mean re-fetching the whole corpus, and prompt caching would have nothing stable to cache. Keeping acquisition deterministic is what makes the expensive layer replaceable. **Escalation as a cost control, not a quality knob.** Sending everything to the strong model is the accurate option and the unaffordable one; sending everything to the cheap model is affordable and wrong on the hard cases. Routing on the cheap model's own confidence means the bill scales with corpus difficulty rather than corpus size, and it means the confidence signal has to be trustworthy, which is why it is recorded per record rather than assumed. **Saying what the output is not.** The records are indicative flags. One page is fetched per URL, JavaScript is not rendered, and nothing here is certified brand-safety. Those limits are stated in the repository because a structured, confident-looking record invites being used as if it were authoritative. When a pipeline mixes deterministic work with model calls, the boundary between them is a design decision, not an implementation detail. Everything on the deterministic side can be cached, resumed, tested offline and re-run for free; everything on the other side cannot. Push the boundary as late as the problem allows. --- ## [Project] academic-research-skills URL: https://vincenzo.imperati.dev/projects/academic-research-skills Category: Personal Stack: Agent skills, MCP, LaTeX, Markdown Repository: https://github.com/VincenzoImp/academic-research-skills Published: 2026-05-19 Eight agent skills covering the research pipeline end to end: digest a paper, explore the state of the art, write the survey, build a contribution, package artifacts, manage a submission, and review it adversarially. import Callout from '../../components/ui/Callout.astro' import RepoCard from '../../components/ui/RepoCard.astro' import SpecPanel from '../../components/ui/SpecPanel.astro' Eight procedures an agent can follow, each a portable Markdown file. They are the other half of [create-academic-research](/projects/create-academic-research): the scaffold owns the structure and the formats, and these own what you do inside it. ## The problem An agent asked to help with research will happily produce a citation. The question is whether that citation corresponds to a paper that exists, says what it is claimed to say, and can be found again, and a fluent model is at its most dangerous exactly here, because the failure looks like success. ## What I built The eight skills follow the pipeline: `explore-sota` runs the discovery loop to saturation, `digest-paper` turns one paper into a folder, `write-survey` digests every synthesis into a single document, `develop-contribution` builds a self-contained piece of work, `package-artifacts` assembles it for a venue's badge requirements, `write-paper` and `manage-submission` handle the manuscript and its rounds, and `adversarial-review` reads the result as a hostile reviewer. Two rules run through all of them. **A citation exists only if a scholarly lookup produced it** – never model memory, never a web search, never scraping – and each digested paper records where its metadata came from. And **digestion is atomic**: the paper, the synthesis, the bibliography entry, the citation graph and the index row land together or not at all, so a partial digest becomes a queue entry rather than a half-populated folder. ## Hard parts **A hard gate has to fail open somewhere or nothing works.** The original rule required two specific lookup services to be reachable, and a transient outage at one of them blocked all research. The fix was to change the gate from naming services to naming a capability: one required source, plus any one of three others. The cost is that the corpus can be built from a thinner set of sources than intended; the gain is that a provider having a bad afternoon does not stop the work. **Thirty-two skills became eight.** The previous version had thirty-two partially overlapping skills with per-skill configuration and reference-syncing machinery. The rewrite deleted them rather than renaming them, with no migration path: projects on the old scaffold are told to stay on the old version. **Reviewers report, they never edit.** `adversarial-review` is forbidden from touching the artifact it reviews. That separation is what makes its output usable: a review that silently fixes what it finds leaves nobody able to see what was wrong. The useful rule turned out not to be "check the citations" but "make an unverifiable citation impossible to produce in the first place". Provenance recorded at digestion time is cheap; an audit after the fact is expensive and never complete. --- ## [Project] create-academic-research URL: https://vincenzo.imperati.dev/projects/create-academic-research Category: Personal Stack: TypeScript, npm, LaTeX, MCP Repository: https://github.com/VincenzoImp/create-academic-research Published: 2026-05-19 An npm scaffolder for research repositories built around four entities – digested papers, one survey, self-contained contributions, and per-venue submissions – after a rewrite that deleted most of the first version. import Callout from '../../components/ui/Callout.astro' import RepoCard from '../../components/ui/RepoCard.astro' import SpecPanel from '../../components/ui/SpecPanel.astro' A creator command that turns an empty directory into a research project with a structure a human and an agent can both navigate. It is one half of a pair: this owns the structure and the formats, and [academic-research-skills](/projects/academic-research-skills) owns the procedures that operate on them. ## The problem The first version of this had roughly thirty top-level template directories, about fifteen CSV ledgers, a command-line tool of some eight and a half thousand lines with more than thirty-five subcommands, and thirty-two partly overlapping skills. The design note that opens the rewrite states the failure in one sentence: users and agents get lost, and the structure does not make the research pipeline obvious. That is a specific kind of failure. Nothing was broken: there was simply more scaffolding than research, and the shape of the work had stopped being visible in the shape of the repository. ## What I built The rewrite is organised around exactly four things, and nothing may live outside them. **SOTA** is the set of digested papers: each one a PDF, a synthesis written to a standard form, an authoritative bibliography entry, and its position in a citation graph. **Survey** is a single LaTeX document that digests every one of those syntheses, groups them by theme and method, and carries the gaps-and-directions section. **Contributions** are self-contained folders – an analysis, an experiment, a piece of software – each with a report detailed enough to be folded into a paper later. **Papers** are per-venue submission folders: framing, manuscript on the venue's template, artifacts packaged to that venue's badge requirements, correspondence, and the archived rounds. One bibliography file at the root serves all of them. ## Hard parts **A citation exists only if a lookup produced it.** Scholarly lookup servers are mandatory rather than optional, digestion cross-checks every configured source, and each digested paper records where its metadata came from. In a workflow where an agent writes prose next to references, this is the property that matters most: a plausible-looking citation with no provenance is the failure mode of the entire category, and the scaffold is built so it cannot occur silently. **Deleting the command surface.** The creator now scaffolds and does nothing else: no doctor, no update, no rename, none of the tooling command families. The discipline that those commands used to enforce moves into template READMEs, a single check script, and the skills. The cost is that a project cannot be upgraded in place; the gain is that there is no second implementation of the rules to keep in sync with the first. **Committing the built PDFs.** Every LaTeX source keeps its generated PDF beside it, with only auxiliary files ignored. That is deliberate repository bloat, bought so anyone – a co-author, a reviewer, an agent – can read the current state without a working TeX installation. The rewrite removed a tool that worked. Thirty directories and thirty-five subcommands were not wrong, they were unreadable, and for something an agent has to navigate, legibility is a functional requirement rather than a nicety. --- ## [Project] SkillGuard URL: https://vincenzo.imperati.dev/projects/skillguard Category: Hackathon Stack: TypeScript, Rust, Anchor, React Native Repository: https://github.com/VincenzoImp/skillguard Published: 2026-05-08 An approval step between a Solana AI agent and a wallet: the agent declares what it wants to do, policy decides whether a human must look, and the decision is recorded on-chain as a hash. import RepoCard from '../../components/ui/RepoCard.astro' import SpecPanel from '../../components/ui/SpecPanel.astro' An AI agent with a wallet needs some authority and not all of it. SkillGuard puts a decision point in between: the agent submits a signed description of what it intends to do, a policy decides whether that can be auto-approved, and anything else lands in a phone app where the wallet's owner approves or rejects it. ## The problem The tempting design is a firewall that intercepts every signature. The repository contains a feasibility study, written before the code, that argues against building that, and the reason is worth more than the product. A universal interception layer expands the blast radius, cannot guarantee downstream protocol behaviour from a short summary, and cannot stop a user signing a similar transaction somewhere else. Its conclusion is that the honest boundary belongs in the README, because stating it makes the project more credible. So this is deliberately narrower: it governs the actions that agents route through it, and says so. ## What I built An agent registers a public key and pairs with a wallet by QR, after which the owner signs a challenge. From then on, the agent submits an action manifest – a small typed JSON object – signed with its own key. The signature proves the manifest was not tampered with in transit; it proves nothing about whether the agent is telling the truth, which is precisely why a human stays in the loop. The policy engine evaluates it against per-agent rules: allowed network, allowed protocols, allowed mints, a spending ceiling, an expiry, and one of three modes. Auto-approval is deliberately almost useless: only a zero-spend, low-risk manifest with no raw transaction attached can pass without a human. Everything else goes to the phone. The decision then lands on-chain as a receipt containing the manifest's hash, the policy result's hash, and the outcome. Nothing else. The contents never leave the off-chain path. ## Hard parts **Deciding what the signature is evidence of.** An agent signing its own manifest proves integrity, not honesty: the agent could describe a swap and mean something else. That is unfixable at this layer, and the design responds by keeping the human approval step rather than pretending the manifest is authoritative. **Hashes on-chain, decisions off it.** The program stores only hashes, and the policy it holds is reduced to a spending ceiling and three hashed allowlists. The chain checks that a connection is live and that the decision code is valid; it does not evaluate policy. That keeps the receipt cheap and private, at the cost of the chain being unable to enforce anything by itself. **Re-scoring what is already pending.** Editing a policy or revoking an agent re-evaluates the actions still waiting for approval, so a revocation applies to the queue and not only to what comes next. The boundary is explicit: SkillGuard governs actions routed through it, not every transaction a wallet can sign. --- ## [Project] nSealr URL: https://vincenzo.imperati.dev/projects/nsealr Category: Professional Stack: Rust, TypeScript, Python, ESP32, Raspberry Pi Repository: https://github.com/nSealr/specs Published: 2026-05-06 Nostr signing devices in five form factors, held together by 220 conformance vectors and firmware that refuses to sign until twelve readiness gates pass; ten remain unmet on the ESP32 scaffold. import Callout from '../../components/ui/Callout.astro' import MetricRow from '../../components/ui/MetricRow.astro' import RepoCard from '../../components/ui/RepoCard.astro' import SpecPanel from '../../components/ui/SpecPanel.astro' nSealr builds signing devices for Nostr keys: a Raspberry Pi vault, two ESP32 form factors, a smartcard, and a purpose-built wallet. The thing that makes them one project is not shared code; it is a specification with 220 conformance vectors that four implementations, in four languages, all have to replay identically. ## The problem A hardware signer exists to answer one question honestly: *is what I am approving what will actually be signed?* Every part of the system between the user's eyes and the signature is a place where that can stop being true: the host that composed the request, the transport that carried it, the parser that read it, the screen that rendered it. The host is the easy call: the companion application is explicitly not trusted with key custody, and every implementation must reject an attempt by it to act as the review authority. The hard part is the screen. A device that displays one thing and signs another is indistinguishable, from the outside, from one that works. ## What I built The centre of the project is not a device, it is `specs`: protocol documents, JSON schemas, and 220 vectors, **107 of which describe things that must be rejected**. Malformed frames, padded encodings, oversized payloads, duplicate gate names, a device claiming to be signing-enabled while still listing missing gates. Roughly half of the shared contract is deterministic refusal. The answer to the screen problem is the **approval digest**. It is a SHA-256 over the request's identity, the exact event template, the derived review data, *and the rendered pages*, so an approval is bound not only to what was signed but to what was shown. A device that displayed something different produces a different digest, and the mismatch is detectable rather than a matter of trust. The review itself is four physical pages: event header, complete content that is never truncated, tags grouped rather than shown as raw JSON, and a decision page. Raw values only: the specification forbids inferred labels, so a kind is shown as its number and not as a friendly name a compromised host could choose. Touch is navigation on every board; approval is always a separate physical control. ## Hard parts **Refusing to ship the feature the project is for.** The firmware implements the whole request, review, custody and policy path, and then answers every valid signing request with `signing_disabled`. There is no signing backend in it at all. Twelve readiness gates govern that, covering secure boot, flash encryption, debug lock, key provisioning, physical approval, Unicode review rendering and more; the protocol forbids a device from reporting itself signing-enabled while any gate is outstanding, and vectors pin both directions of that contradiction. Ten of the twelve remain unmet on the ESP32 scaffold. The one implementation that produces real BIP-340 signatures is the Raspberry Pi vault. **Keeping the roadmap honest inside the contract.** Every feature in the compatibility matrix carries two values: what the product is meant to do, and what it does now. The specification says why: so a roadmap can be honest without weakening the compatibility guarantee. It also means a form factor cannot quietly claim a capability by shipping it in the target column. **Deleting a board because the evidence said to.** A full custom wallet PCB – secure element, NFC, battery management, a 97-part bill of materials – was reviewed and declared not fabricable, with a defect register naming six critical faults including a display connector whose tail could never mate and a backlight driver that could not regulate below its input. The conclusion recorded was that it could not be fixed by patching the layout. It was archived and replaced by a minimal design, and the two hard gates that no amount of desk work removes were written down: interactive routing, and first-article RF tuning, because that is physics. The strongest security property here is a refusal. Building the review, custody and approval machinery and then leaving signing switched off behind twelve explicit gates is worth more than a device that signs, because the gates are the reason anyone should believe the signature when it eventually happens. --- ## [Project] Proxmox Desk Display URL: https://vincenzo.imperati.dev/projects/proxmox-desk-display Category: Personal Stack: Go, C++, ESP32, PlatformIO Repository: https://github.com/VincenzoImp/proxmox-desk-display Published: 2026-05-03 A physical desk display for a Proxmox homelab, split into a Go bridge that holds all the credentials and an ESP32 firmware that holds none: a product decision, not a technical limitation. import Callout from '../../components/ui/Callout.astro' import RepoCard from '../../components/ui/RepoCard.astro' import SpecPanel from '../../components/ui/SpecPanel.astro' A small screen on a desk showing what a Proxmox homelab is doing. It is two products sharing one HTTP contract: a Go service that talks to Proxmox and normalises everything it knows, and an ESP32 firmware that renders nineteen screens and never learns what Proxmox is. ## The problem The obvious build is to have the microcontroller call the Proxmox API directly. The repository's architecture note argues against that and names the costs: a Proxmox token would live on the device, TLS and certificate rotation are harder on embedded firmware, an API change would require reflashing every device, and debugging a display is far worse than opening a page in a browser. Its conclusion is the sentence worth keeping: the bridge-first design is a product-quality choice, not a technical limitation. ## What I built The bridge polls one or more Proxmox installs, normalises roughly forty API paths into a versioned display schema, caches it, and serves three tiers: a compact payload for the device, a bounded detail payload with explicit caps, and an unbounded inventory for debugging. It has an admin UI, a mock mode that serves fake data so the firmware can be developed with no Proxmox at all, and four TLS modes including certificate-fingerprint pinning, the right answer to Proxmox's self-signed certificates, rather than disabling verification. The firmware connects through a captive portal on first boot, stores only the bridge URL and a display token, and drives nineteen drill-down screens from two buttons. ## Hard parts **Degrading per endpoint rather than per refresh.** Proxmox installs differ by edition, version and the permissions of the token you were given. When an endpoint is refused, the affected host carries a data warning and everything else still renders, so a missing subsystem costs one panel rather than the whole display. **Bounded payloads, because the client is a microcontroller.** The detail endpoint caps every list explicitly, and the architecture note states the rule: when a screen needs more, add a query shape to the bridge rather than make the ESP32 parse the full inventory. The device is not allowed to become the place where the data is filtered. **Actually running out of memory.** One commit raises the loop task's stack to 32 KB and shrinks the JSON document from 48 KB to 32 KB in the same change. That is the constraint the whole protocol design exists to respect, arriving in person. The repository describes itself as an early MVP scaffold, and deliberately does not promise support for arbitrary ESP32 boards: new hardware is meant to arrive as an explicit board profile defining driver, resolution, pins, input method and layout density. --- ## [Project] Encrypted Local Tally Integrity URL: https://vincenzo.imperati.dev/projects/encrypted-local-tally-integrity Category: Hackathon Stack: Solidity, Circom, Groth16, JavaScript Repository: https://github.com/VincenzoImp/encrypted-local-tally-integrity Award: 1st place, De Cifris × XRPL Commons hackathon 2024, Cryptography track Published: 2026-05-02 A protocol and reproducible artifact for federated elections in which each authority publishes an encrypted local tally and the aggregation stays verifiable on-chain. import RepoCard from '../../components/ui/RepoCard.astro' import SpecPanel from '../../components/ui/SpecPanel.astro' In a federated election the ballots are already secret and already counted locally. What is left unguarded is the step where someone collects every local tally and announces the total. This protocol removes the need to trust that step without publishing a single local result. The manuscript is submitted to Koine and has not been peer reviewed. The artifact establishes the protocol's stated aggregation guarantees; it is not a deployed voting system. ## The problem The hackathon track posed a federated-election scenario, and the trust gap in it is specific: local committees already keep ballots secret and already count their own votes, but a central actor still collects the tallies and announces the result. That actor can omit an authority, replace a tally, or simply be believed without evidence. Publishing the local tallies in cleartext closes the gap and opens two others. Anyone can watch partial results accumulate before the election closes, and a small locality's result stays attributable to that locality forever, which is a retaliation risk rather than a privacy abstraction. Commit-and-reveal only postpones both. So the requirement we took was stronger than a public bulletin board: local tallies must stay confidential *even after the election*, and the global result must still be derivable from the full set of them. ## What I built Four roles, and the contract is only one of them. An *owner* initialises the election and the authority registry. Each *local authority* tallies off-chain and submits one encrypted tally vector. The *smart contract* is the bulletin board and the proof gate: it verifies, records one submission per registered authority, and maintains the homomorphic aggregate. A set of *trustees* performs threshold decryption of that aggregate, once, at the end. A submission is three things travelling together: the encrypted tally vector, a Groth16 proof that the hidden tally is well formed and sums to the authority's publicly known electorate size, and a signature binding the submission to the election transcript. Trustee setup and the opening transcript are proof-checked separately, so the final decryption cannot be quietly performed by a different key set than the one registered. The artifact in `artifact/` is the whole thing running: three Circom circuits – tally validity, trustee-set consistency, and partial-decryption validity – their Groth16 verifiers, the `ZKLocalTallyIntegrity` contract, an ElGamal implementation, and a deterministic replay that runs a two-candidate election across three authorities with 2-of-3 threshold opening and diffs the result against a checked-in expected output. ## Hard parts **A hidden number still has to be a plausible number.** Encrypting the tally removes the reviewer's ability to sanity-check it, which is exactly what a malicious authority would want: negative entries, oversized entries, or a vector that does not sum to anything real. The tally circuit exists to restore that check without decryption: it proves the vector is well formed and sums to the electorate size the contract already knows. The cost is that every submission now carries proof generation, and the electorate sizes have to be public. **Deciding what happens when an authority never submits.** A protocol that aggregates whatever arrived is a protocol where omission is a strategy. The finalisation rule cancels an incomplete election rather than publishing a partial total, which trades liveness for integrity: one missing authority stops the result, and that is the deliberate choice. **Keeping the guarantee narrower than the word "verifiable".** What is proved is that the submitted tally is well formed, not that it is the *true* local tally. A fully corrupted local committee is outside the model, and saying so is what makes the rest of the claim usable. **Drawing the boundary around key generation.** The artifact uses a dealer-generated threshold key rather than distributed key generation, and decodes the final tally off-chain through bounded discrete-log recovery. Voter authentication and coercion resistance sit outside the protocol, so its guarantee is confined to the aggregation step. --- ## [Project] Entangle URL: https://vincenzo.imperati.dev/projects/entangle Category: Professional Stack: TypeScript, Nostr, Git, Turborepo Repository: https://github.com/entangle-run/entangle Published: 2026-04-22 A self-hosted federated runtime for organizations of coding agents: a permission graph, Nostr-signed coordination, git-backed artifacts, and deterministic tests that prove the plumbing without spending model API calls. import Callout from '../../components/ui/Callout.astro' import RepoCard from '../../components/ui/RepoCard.astro' import SpecPanel from '../../components/ui/SpecPanel.astro' Entangle runs organizations of coding agents the way you would run a company: humans, agents and services are nodes in a graph, and the edges say who may talk to whom, delegate, review and approve. Runners execute those nodes wherever they happen to live, so the system is federated whether or not you deploy it across machines. ## The problem One agent doing work needs a good prompt. Several agents doing work for each other need something else entirely: a way to know who sent a message, whether they were allowed to send it, what artifact they are referring to, and what actually happened, after the fact, from a record rather than from a log somebody has to trust. That is not a new problem. It is the problem of a distributed system with untrusted participants, and the reason it feels new here is that the participants are non-deterministic. ## What I built A Host holds the authoritative graph state, runner trust, assignment and projection, and exposes it as an API. Everything else is a client of that one boundary: a browser operator console, a participant client served by running user nodes, a CLI for headless operation, and the runners themselves. Four surfaces, one source of truth: adding a surface is wiring rather than a second implementation of the rules. Runners join through a trust flow, publish signed observations, and run either an agent runtime or a human-interface runtime: a person is a node in the graph like anything else, not an exception to it. Adapters cover OpenCode and external engines, and artifacts are handed between nodes through git. ## Hard parts **Identity had to come from somewhere, and borrowing it has consequences.** Every runtime node is identified by a Nostr public key and communicates through signed Nostr events. The recorded reasoning is that this gives a global identity with no registry, verifiable provenance on every message, and one transport that works for local and remote nodes alike. The consequences are written down beside the decision, and the third one is the interesting one: external systems must bind *their own* principals to a node rather than reuse the raw Nostr key, so git authentication and commit signing stay separate credential surfaces. Otherwise a coordination key silently becomes a repository write key. Secret management and package portability become first-class concerns for the same reason: a package that carries key material cannot be shared. **Artifacts are references, not payloads.** Git is the first artifact backend, chosen for version control, explicit diffs and a natural handoff between nodes. The constraint recorded with it is that git must not become the only conceivable backend, which is why the message protocol carries artifact *references* and the runner owns the local git operations. The cost is an indirection that a git-only system would not need. **Testing something non-deterministic.** The non-determinism sits at the model boundary and nowhere else. So the deterministic paths run against a fake OpenCode, fake OpenAI-compatible providers and stand-in external engines, and exercise federation, user-node messaging, artifact handoff, deployment tooling and volume recovery without spending a single model API call. Live provider credentials and real engine behaviour remain manual validation, and the repository says so rather than implying the fixtures cover them. Put the non-determinism at a seam you can replace. If the only uncontrollable thing is the model call, a fake at that boundary turns an "AI system" back into an ordinary distributed system, with an ordinary test suite and an ordinary CI bill. --- ## [Project] nsec leak checker URL: https://vincenzo.imperati.dev/projects/nsec-leak-checker Category: Personal Stack: Python, asyncio, nostr-sdk, Docker Repository: https://github.com/BigBrotr/nsec-leak-checker Published: 2026-03-17 A Nostr data-vending machine that tells you whether your private key has been published in a public event, using your own signature as the proof of ownership and returning the answer encrypted to you alone. import Callout from '../../components/ui/Callout.astro' import MetricRow from '../../components/ui/MetricRow.astro' import RepoCard from '../../components/ui/RepoCard.astro' import SpecPanel from '../../components/ui/SpecPanel.astro' Searching 40 million archived Nostr events turns up more than 16,000 private keys published in public. Having that list creates a problem: it is a list of working keys, so it cannot be published, and telling people individually looks exactly like the phishing message it invites. ## The problem The finding cannot be distributed the way findings normally are. Publishing the list hands working keys to anyone who wants them. Contacting the affected accounts means sending an unsolicited message about their key security over the same protocol an attacker would use, to people who have already demonstrated they can be careless with credentials. So the only safe shape is one where nobody is told anything: they ask, and only about themselves. ## What I built A service that answers job requests over the protocol itself, in about three hundred lines. The elegant part is that it needs no authentication mechanism at all: a request arrives as a signed Nostr event, and the *author of that signature* is the only pubkey it is answered about. The content is never read. You cannot ask about someone else, because asking is signing. The reply is encrypted to the requester with NIP-44, so the relays that carry it – and anyone watching them – learn that a query happened and nothing about its answer. The answer is also deliberately thin. It reports at most fifty events, each reduced to an identifier, a pubkey, a kind and a timestamp. It tells you *where* your key appeared without sending the leaked content back across the network a second time. ## Hard parts **The dataset is the dangerous artifact, not the service.** The index maps a pubkey to its exposed key, which means the files contain plaintext private keys, necessarily, because attributing a leaked string back to an owner requires reversing it. They are excluded from version control by rule rather than by convention, and the container mounts them read-only. **Answering without becoming a targeting oracle.** Every design choice narrows what an observer learns: the query is answerable only by its own author, the response is encrypted, the log records a truncated fingerprint of the requester rather than the pubkey, and the payload is capped. The service only considers requests published after it starts, so requests sent while it is down are not answered later. A dropped answer is a user who asked and heard nothing, which is the failure mode to be aware of when relying on it. --- ## [Project] nostr-linkr URL: https://vincenzo.imperati.dev/projects/nostr-linkr Category: Personal Stack: Solidity, TypeScript, secp256k1, Nostr Repository: https://github.com/VincenzoImp/nostr-linkr Published: 2026-03-10 A trustless bridge between a Nostr identity and an Ethereum address, with full BIP-340 Schnorr verification performed on-chain: no DNS, no platform post, no third party to believe. import RepoCard from '../../components/ui/RepoCard.astro' import SpecPanel from '../../components/ui/SpecPanel.astro' nostr-linkr lets someone prove that a Nostr public key and an Ethereum address belong to the same person, and lets anyone else check that proof without asking a server. It is a smart contract, a TypeScript SDK, and a specification proposed to the Nostr protocol. ## The problem Nostr already has two ways to say "this account is also me somewhere else", and the specification proposal states the objection to both. NIP-05 verification depends on a DNS-controlled server: whoever runs the domain can change or withdraw the claim. NIP-39 depends on a post on GitHub or Twitter, which the platform can delete and the platform's owner can decide about. Both work. Both are mutable, censorable, and require trusting a service that is not a party to the identity being claimed. ## What I built The link is written to a smart contract, and it requires both private keys, which is what makes it a claim neither side can fake. The Nostr key signs an event containing the Ethereum address. The Ethereum key sends the transaction that submits it. The contract then does the part that would ordinarily be taken on trust: it recomputes the NIP-01 event id from the event's fields, checks it matches the id presented, and verifies the Schnorr signature against the Nostr public key. Only then is the link recorded, and it can be looked up in either direction. A `pullLinkr` call removes it, so the link is revocable by the address that made it. Around the contract sit a TypeScript SDK, an example client, and a proposal written up as a NIP and submitted to the specification repository, where it is open. ## Hard parts **The EVM cannot verify a Nostr signature.** Ethereum's precompiled `ecrecover` handles ECDSA; Nostr signs with BIP-340 Schnorr. Same curve, different scheme, and no precompile for it, so the verification is implemented in Solidity: lifting the x-only public key back to a curve point, which works because secp256k1's field prime is congruent to 3 mod 4 and the square root is therefore a single modular exponentiation, then checking that `s·G == R + e·P`. It runs entirely on-chain, which is the whole point; verification that happens off-chain is a claim about a claim. The hand-written verifier has not received a security audit, and the deployed contract is limited to Base Sepolia. It demonstrates the proof path rather than establishing production security. **Recomputing the event id rather than accepting it.** A signature is over the event id, so a contract that trusts a submitted id verifies nothing about the event's contents. The contract serialises the fields per NIP-01 and derives the id itself, and only compares afterwards. --- ## [Project] VendTON URL: https://vincenzo.imperati.dev/projects/vendton Category: Hackathon Stack: TypeScript, TON, x402, ENS Repository: https://github.com/VincenzoImp/vendton Published: 2026-03-09 A marketplace and payment gateway for paid APIs on TON: deploy an endpoint, price it per call, and let an autonomous agent discover and pay for it in USDT without an account. import RepoCard from '../../components/ui/RepoCard.astro' import SpecPanel from '../../components/ui/SpecPanel.astro' VendTON turns an HTTP status code into a market. x402 defines how a server demands payment for a request; VendTON is everything around it: a gateway that enforces the payment, a directory so the endpoint can be found, and an agent that discovers and pays for one on its own. ## The problem x402 revives HTTP 402 Payment Required as a real mechanism: a server answers `402` with the terms, the client pays, and retries with proof of payment in a header. It is a clean protocol, and it is only a protocol: it says nothing about how a caller finds a paid endpoint, how a developer publishes one, or how any of it gets a stable name. The repository's own framing of the gap is the accurate one: everyone had ported x402 to TON, and what was missing was what goes on top. ## What I built Four services around the payment step. **A gateway** proxying calls to a priced endpoint. A request without an `x-payment` header gets a `402` carrying the terms; a request with one has its transaction verified on TON before the call is forwarded upstream, and the payment settles in USDT. **A directory**, so an endpoint can be discovered rather than known in advance: over the gateway API, or on-chain, because each deployed endpoint is assigned an ENS subdomain under a single parent name. The identity lives on Ethereum while the money moves on TON, which is deliberate: a name that resolves anywhere is worth more than one that resolves inside a single ecosystem. **A Telegram bot and mini app**, which is where a TON user already is: deploy an endpoint, set its price, watch it earn, without leaving the client they use for everything else. **An agent** that reads the directory, decides which endpoints it needs, pays from its own wallet, and chains several calls together without a human approving each one. ## Hard parts **Verifying a payment without trusting the caller.** The header is a claim. The gateway has to confirm on TON that the transaction exists, is for the right amount, and has not already been spent on an earlier request, before any upstream work happens. Wrong in the permissive direction gives the API away; wrong in the strict direction charges people for calls they never received. The gateway has not received a security audit and is demonstrated on test infrastructure, so it is evidence of the payment flow rather than a safe place to route real API revenue. **Two chains doing two different jobs.** Payments on TON, names on ENS. That is two wallets, two notions of ownership and two failure modes to reconcile behind what a user experiences as one action. --- ## [Project] asciidoc-to-djot URL: https://vincenzo.imperati.dev/projects/asciidoc-to-djot Category: Personal Stack: TypeScript, Asciidoc, Djot, Vitest Repository: https://github.com/VincenzoImp/asciidoc-to-djot Published: 2026-02-28 An AST-based converter from Asciidoc to Djot, written so that a format migration in the Nostr wiki specification did not require anyone to rewrite their articles by hand. import Callout from '../../components/ui/Callout.astro' import RepoCard from '../../components/ui/RepoCard.astro' import SpecPanel from '../../components/ui/SpecPanel.astro' A converter from Asciidoc to Djot, published to npm as a library and a CLI. It exists because NIP-54, the Nostr wiki specification, changed format, and the articles already published in the old one belong to people who were never going to convert them by hand. ## The problem Converting between two lightweight markup languages looks like a text-substitution job and is not. Headings, emphasis and code spans map across cleanly; lists, tables, source blocks, admonitions and sidebars are structures, and a regular expression that rewrites them handles the common shapes and silently mangles the rest. NIP-54 adds a second difficulty. Its articles carry two constructs that are not Asciidoc at all: `[[wikilinks]]`, in both the plain and the `[[target|display]]` form, and `nostr:` URIs. A parser built for Asciidoc does not know about either, so a naive parse loses them before conversion can begin. ## What I built A five-stage pipeline, structural at every stage. The **pre-process** step replaces wikilinks and `nostr:` URIs with placeholders, precisely because Asciidoctor does not handle them natively: they have to survive the parse rather than be understood by it. `@asciidoctor/core` then **parses** the source into an AST, and a custom `DjotConverter` **walks** that AST, emitting Djot per node type. The **post-process** step restores the placeholders as Djot reference-style and inline links and normalises blank lines. The last stage is the one worth having: the output is **validated** by parsing it back with `@djot/djot`. A conversion that produces invalid Djot fails loudly instead of producing a file that looks fine until something else tries to read it. ## Hard parts **The two wikilink forms map to different Djot constructs.** `[[Target]]` becomes the reference-style `[Target][]`, while `[[target|display]]` becomes `[display][target]`: the components swap order. Getting that backwards produces valid Djot with the link text and the link target exchanged, which is exactly the class of bug that survives review. **Placeholders have to be unmistakable.** The whole pre-process trick depends on substituting something the Asciidoc parser will carry through untouched and that cannot occur in real article text. If a placeholder collides with content, the converter corrupts the document it was meant to preserve. **Round-tripping is the only honest test.** The suite checks individual converter nodes and whole documents, then parses every output as Djot. Each converter bug fixed later arrived with a regression case at the structural level where it occurred. The reason this tool exists, and what happened when it was posted on the pull request, is in [Changing a specification is mostly a migration problem](/posts/changing-a-specification). --- ## [Project] nopayloaddb URL: https://vincenzo.imperati.dev/projects/nopayloaddb Category: Personal Stack: Python, Django REST Framework, PostgreSQL Repository: https://github.com/BNLNPPS/nopayloaddb Published: 2026-02-23 A merged one-line correctness fix to the HEP Software Foundation reference conditions database: an ordering key computed from the wrong field, in a branch rare enough that nothing failed loudly. import Callout from '../../components/ui/Callout.astro' import RepoCard from '../../components/ui/RepoCard.astro' import SpecPanel from '../../components/ui/SpecPanel.astro' `nopayloaddb` is the HEP Software Foundation's reference conditions database, maintained at Brookhaven and used in the sPHENIX and Belle II ecosystems. It answers one question at scale: which calibration payload was valid at this point in a run? ## The problem An interval of validity is a two-level integer pair: a major component, typically the run, and a minor one, typically the sub-run. Asking "which payload applies at (major, minor)?" against two columns is a comparison the database cannot serve from one index. So the schema denormalises it. `comb_iov` collapses the pair into a single `DECIMAL(38,19)`: major plus minor divided by ten to the nineteenth, so the minor component occupies the fractional part, and a covering index on it turns the lookup into one range scan ordered descending, limit one. That is a good trade, and it comes with an obligation: every code path that changes a start interval must recompute the derived key. Miss one, and the index is quietly wrong. ## What I found One was missed. In the branch of the attach endpoint that adjusts a *neighbouring* interval when a newly attached one overlaps it, the recomputation read the major component twice instead of reading major and then minor. The consequence is narrow and nasty. It only triggers when an existing interval is adjusted during an attach, and only when that interval's minor component differs from its major, so most of the time the wrong arithmetic produces the right number. When it does not, nothing raises: the ordering key is simply wrong in its fractional part, and a validity lookup returns a different payload than it should. I opened an issue first, with a table showing the formula was correct at four other call sites and wrong at one, naming the two views whose queries depend on that ordering. Then the patch: one file, one line added, one removed. It merged ten days later, into v5.0.0 and v5.1.0. ## Hard parts **Proving a one-line change is the right one.** A single-character difference in an unfamiliar codebase is indistinguishable from a misunderstanding. What made it reviewable was the audit around it – every place the same expression appears, which of them are correct, and why this branch is the exception – so the maintainer could check the claim without reconstructing it. **A silent bug in a project without unit tests.** Correctness here is verified by standing the API up in Docker and running an external C++ client's test suite against it. That catches behaviour a client exercises; it does not catch an arithmetic slip in a rarely-taken branch that produces a plausible number. Reading the code was the only way this surfaces. One pull request is merged. Seven more are open and awaiting maintainer review: proposals covering query efficiency, error handling and CI, not shipped code. Ten earlier pull requests were closed without merging. --- ## [Project] GSoC 2026 Explorer URL: https://vincenzo.imperati.dev/projects/gsoc-2026-explorer Category: Personal Stack: Python, TypeScript, Next.js, Playwright Repository: https://github.com/VincenzoImp/gsoc-2026-explorer Published: 2026-02-20 Every Google Summer of Code 2026 organization and its project ideas in one searchable place, which meant writing eight different extractors, because every organization publishes its ideas somewhere else. import Callout from '../../components/ui/Callout.astro' import MetricRow from '../../components/ui/MetricRow.astro' import RepoCard from '../../components/ui/RepoCard.astro' import SpecPanel from '../../components/ui/SpecPanel.astro' Choosing where to apply for Google Summer of Code means reading 184 organizations' project-ideas pages. This puts all of them in one place with full-text search, and commits the whole corpus as Markdown so it can be read by something other than a browser. ## The problem The organization list comes from a clean public API. The ideas do not. Each organization points at a URL of its own choosing, and those URLs are GitHub blobs, GitHub wikis, gists, GitLab pages, Google Docs, issue trackers, plain HTML, and pages that render only after JavaScript runs. Eight formats, one desired output. And a scraper pointed at a hundred and eighty arbitrary web pages does not merely fail: it *succeeds* at fetching the wrong thing, and stores it. ## What I built A scraper that classifies each URL and dispatches to the right extractor: raw fetches for GitHub and GitLab sources, an export path for Google Docs, the GitHub CLI for issue trackers, readability extraction for generic HTML, and a headless browser for anything that needs JavaScript. Everything ends up as normalised Markdown, committed to the repository. It also follows links out of an ideas page and fetches those as sub-pages, with slug generation, deduplication and relative-link resolution, so an organization that splits its ideas across a dozen wiki pages is captured whole rather than as a table of contents. The site is a static export with a prebuilt search index and weighted fuzzy matching, so the whole thing is a folder of files with no backend. ## Hard parts **Knowing when you fetched a page and not the page.** The most valuable part of the scraper is a list of patterns that identify content which is not content: Cloudflare interstitials, proof-of-work challenges, "verify you are human", login walls, content-management chrome, and 404 bodies. Each is labelled so a rejection is legible in the log. Without that check, an anti-bot page is just a successful fetch with unusual text, and it enters the corpus silently. **Sanitising at the right end.** Markdown sanitisation initially happened at render time and broke legitimate scraped content, so it moved to scrape time: clean the corpus once, on the way in, rather than every time it is displayed. The dataset is a snapshot, taken in March 2026 and not refreshed since. Organizations move and edit their ideas pages, so some entries link out to the original rather than mirror it. --- ## [Project] Chain Lens URL: https://vincenzo.imperati.dev/projects/chain-lens Category: Hackathon Stack: TypeScript, Bitcoin, Next.js Repository: https://github.com/VincenzoImp/2026-developer-challenge-1-chain-lens Published: 2026-02-16 A Bitcoin transaction and block analyzer that parses raw Core data files, reconstructs the accounting, and classifies every script, with a CLI and a visual walkthrough sharing one engine. import Callout from '../../components/ui/Callout.astro' import RepoCard from '../../components/ui/RepoCard.astro' import SpecPanel from '../../components/ui/SpecPanel.astro' Chain Lens takes raw Bitcoin data and produces a report you can check rather than a summary you have to believe. It parses transactions and whole blocks, rebuilds inputs, outputs and fees, classifies every script, and emits either structured JSON or an interactive walkthrough. ## The problem A block explorer tells you what a transaction did. It does not tell you how it arrived at that answer, and for anything involving fees, weight or script types the answer depends on rules that changed several times: SegWit's weight accounting, Taproot's spend paths, the way a witness discount alters what a transaction actually costs. The challenge was to compute those from the bytes and be checkable about it, which rules out calling an API and reformatting the reply. ## What I built A single analysis engine with two front ends over it. For a transaction it derives `txid` and `wtxid`, size, weight and vbytes under BIP141, input and output totals, the fee and the fee rate. Every output script is classified – P2PKH, P2SH, P2WPKH, P2WSH, P2TR, `OP_RETURN` – and rendered as its mainnet address; every input is classified by how it was spent, down to distinguishing a Taproot key-path spend from a script-path one. It disassembles scripts, decodes `OP_RETURN` payloads and recognises Omni and OpenTimestamps inside them, reports BIP125 replaceability and BIP68 relative timelocks, and compares actual weight against the hypothetical legacy weight to show what SegWit saved. In block mode it reads Bitcoin Core's own `blk*.dat` and `rev*.dat` files – de-obfuscating them with `xor.dat` first – verifies the merkle root against the block header, decodes the BIP34 height from the coinbase, and aggregates fees, weight and script-type distribution across the block. ## Hard parts **One engine, two surfaces, or the numbers diverge.** The CLI and the visualiser both call `lib/analyzer.ts`, and the repository states the reason plainly: so that the two always produce the same numbers. A visualiser with its own fee calculation is a second implementation of consensus-adjacent arithmetic, and the moment the two disagree neither can be trusted. **Reading Core's files rather than its RPC.** Block files are an internal format, obfuscated on disk and not a stable interface. Parsing them directly removes the dependency on a running node and puts the burden of getting the format right entirely on this code, which is why verifying the merkle root matters: it is the check that the parse was correct, computed from the parsed data against the header. **Being gradeable.** The repository carries a grader with expected outputs. Emitting structured JSON rather than prose is what makes the analysis machine-checkable, and it is why the classification vocabulary has to be exhaustive rather than approximate: `unknown` is a real category and has to be returned honestly. Built under time pressure for a challenge, and mainnet-only in its address rendering. It analyses data; it does not validate consensus rules and is not a substitute for a node. --- ## [Project] OmniCredit URL: https://vincenzo.imperati.dev/projects/omnicredit Category: Hackathon Stack: Solidity, LayerZero V2, Pyth, TypeScript Repository: https://github.com/VincenzoImp/omnicredit Published: 2025-11-22 An omnichain lending protocol where borrowing power is earned by paying interest rather than granted by depositing more collateral: a credit score that cannot be farmed for free. import RepoCard from '../../components/ui/RepoCard.astro' import SpecPanel from '../../components/ui/SpecPanel.astro' OmniCredit lets someone deposit collateral on one chain and borrow against it on another, and it gives repeat borrowers better terms than newcomers. The interesting half is the second one: on-chain lending normally has no memory, so every borrower is treated as a stranger forever. ## The problem A lending protocol that has no idea who you are has exactly one instrument for managing risk: demand more collateral than the loan is worth, from everybody, every time. Someone who has repaid twenty loans on time gets the same terms as an address created five minutes ago. The obvious fix – reputation – is also the obvious attack. Any score that can be accumulated cheaply will be accumulated cheaply, by as many addresses as it is worth creating. ## What I built A single liquidity pool with collateral accepted from other chains over LayerZero V2 messaging, priced by Pyth feeds, with liquidations run as Dutch auctions rather than at a fixed discount. The credit layer is a separate contract that keeps a score from 0 to 1000 and derives a loan-to-value ratio from it. What moves the score is the part worth reading: points are earned per fixed amount of **interest actually paid** to the protocol, with a bonus for consecutive loans that caps out, and repayment counted as on-time inside a thirty-day window. A liquidation subtracts a fixed and deliberately large penalty. Half of the interest a borrower has paid also counts as a buffer before they become liquidatable, so a long-standing borrower is not liquidated on the same tick as a new one at the same ratio. The contracts are unaudited and demonstrated on testnets with mock assets; the score parameters show the mechanism and are not calibrated against borrower outcomes. ## Hard parts **Making reputation cost money.** Scoring on time, transaction count or volume gives a free reputation to anyone willing to wait or to loop funds between their own addresses. Denominating the score in interest paid means a Sybil has to buy each identity's standing at full price, which is the property that makes the score usable at all. The cost is that it excludes the borrower who is careful and cheap: repaying early and paying little earns you little. **A cross-chain deposit is a message, and messages fail.** Collateral arriving over LayerZero is credited on the strength of a message from another chain, which means the accounting has to be correct in the window where the deposit has left one side and not yet arrived on the other. --- ## [Project] DotFusion URL: https://vincenzo.imperati.dev/projects/dot-fusion Category: Hackathon Stack: Solidity, TypeScript, Ethereum, Polkadot Repository: https://github.com/VincenzoImp/dot-fusion Award: 1st 1inch · 1st Polkadot · 1st BuidlGuidl · ETHRome 2025 (+ ENS honorable mention) Published: 2025-10-18 Trustless ETH↔DOT atomic swaps between Ethereum and Polkadot using hash time-locked contracts, with Polkadot's XCM precompile propagating the secret across chains automatically. import RepoCard from '../../components/ui/RepoCard.astro' import SpecPanel from '../../components/ui/SpecPanel.astro' DotFusion swaps ETH for DOT directly between Ethereum and Polkadot: two parties lock funds on their respective chains against the same hash, and either both claims succeed or both refunds do. There is no bridge contract holding the assets and no wrapped representation of either side. ## The problem Almost every cross-chain transfer routes through a custodian: a bridge contract, a multisig, or a validator set that holds the real asset and issues a claim against it. That custody is where the value concentrates, and it is where the large losses have historically happened. A hash time-locked contract removes the custodian, but it replaces it with a coordination burden. The party who claims first reveals a secret, and the counterparty has to notice that revelation on a different chain and act on it before their own lock expires. Classically that watching is the user's problem, or a service's. ## What I built Three contracts and a resolver. `EthereumEscrow` and `PolkadotEscrow` hold the two sides of a swap against the same hash; `XCMBridge` is the piece that makes the second half automatic. When the secret is revealed on the Polkadot side, `XCMBridge` calls Polkadot's XCM precompile at `0x…0804` with `send(uint32 paraId, bytes xcmMessage, uint64 weight)`, encoding a Transact instruction that completes the swap on the destination chain. The counterparty does not have to be watching. If the XCM call fails the contract rolls back its own state – the secret is un-marked as processed and the pending message deleted – so a failed propagation leaves the swap claimable rather than half-finished. Around that sits a resolver service offering quotes and swap status over HTTP, a Scaffold-ETH 2 frontend, and ENS resolution on every address field so a counterparty can be named rather than pasted. Both escrows are deployed to testnets – Sepolia and Paseo Asset Hub – and the contracts use `ReentrancyGuard` and checks-effects-interactions. They have not received a security audit, and the demonstration fixes the exchange rate at 1 ETH to 100,000 DOT rather than pricing either asset. The claim here is about the atomic-swap mechanism, not readiness to move real value. ## Hard parts **The two timelocks cannot be equal.** The escrow enforces `MIN_TIMELOCK = 12 hours`, and the comment above that constant states the reason it exists: to ensure the Ethereum timeout exceeds the Polkadot one. If the side that reveals the secret second could expire first, the party who already revealed would be unable to refund while their counterparty could still claim. The asymmetry – twelve hours against six – is the safety property, and it is enforced in the contract rather than left to whoever calls it. **Making the propagation failure safe rather than silent.** Sending an XCM message is an external call that can fail, and it happens after the secret has been observed. Treating a failed send as "the swap is done" would strand funds. The bridge therefore reverts its own bookkeeping on failure, which costs the automatic path but keeps the manual one available. **Bounding the damage from a stuck swap.** Beyond the ordinary cancellation there is a second, longer `rescueDelay` window. A swap that reaches neither claim nor refund is not permanently locked value. --- ## [Project] Bitcoin Full Node in Docker URL: https://vincenzo.imperati.dev/projects/bitcoin-fullnode-docker Category: Personal Stack: Docker, Bitcoin Core, Tor, Python Repository: https://github.com/VincenzoImp/bitcoin-fullnode-docker Published: 2025-10-08 A Bitcoin full node that reaches the network only over Tor and keeps the full transaction index, configured for asking questions about the chain rather than for validating one wallet. import Callout from '../../components/ui/Callout.astro' import RepoCard from '../../components/ui/RepoCard.astro' import SpecPanel from '../../components/ui/SpecPanel.astro' A Compose stack that brings up a Bitcoin full node, a Tor proxy, a Mempool dashboard, a block explorer, and a small Python client for talking to the node from a script. ## The problem Analysis over Bitcoin usually starts by calling somebody's API, and that has two costs that only show up later. The provider sees every query, which for chain analysis means telling a third party exactly which addresses you are interested in. And the answers arrive already shaped: whatever the endpoint chose to expose, at whatever granularity. ## What I built Five containers on one network. The node's configuration is where the decisions are. `onlynet=onion` with the Tor proxy means the node connects to peers *only* through Tor. There is no clearnet fallback: the node's network activity does not associate an IP address with the fact that it is running, and queries do not leave the machine at all. `txindex=1` with `prune=0` keeps a complete index of every transaction, which is what makes the node answer arbitrary lookups rather than only questions about its own wallet. The Python client wraps the RPC interface in about ten methods – blocks, transactions, mempool, peers, fee estimates, address balances – so a notebook can query the chain directly. ## Hard parts **Tor-only costs you the initial sync.** Downloading several hundred gigabytes through onion circuits is materially slower than over clearnet, and it makes the node's availability depend on Tor's. That is the price of not announcing an IP, and it is paid once, at the worst possible moment: the beginning. **The full index is most of the disk.** Six hundred gigabytes buys the ability to ask about any transaction ever made. A pruned node would fit in a fraction of that and answer almost nothing useful for analysis, so the storage is not overhead here: it is the feature. The configuration ships with default RPC credentials and binds the interface to the Compose network. That is fine on a private host and not fine on anything reachable. Change both before running it anywhere else. --- ## [Project] Job Search Tool URL: https://vincenzo.imperati.dev/projects/job-search-tool Category: Personal Stack: Python, FastAPI, React, SQLite, ChromaDB Repository: https://github.com/VincenzoImp/job-search-tool Published: 2025-10-07 A local-first job search system where the dashboard, REST API, and MCP tools share one application layer, so filtering and state changes behave identically on every surface. import Callout from '../../components/ui/Callout.astro' import RepoCard from '../../components/ui/RepoCard.astro' import SpecPanel from '../../components/ui/SpecPanel.astro' A job search that runs on your own machine: a scheduler scrapes boards on an interval, scores each posting against criteria you write, and keeps what clears the bar. You then read it through a dashboard, an HTTP API, or an AI agent, and all three are the same thing underneath. ## The problem Three surfaces over one dataset is where duplicated logic goes to hide. The dashboard filters one way, the API another, and the agent's tools a third, and none of them is wrong until a rule changes and only two of them learn about it. ## What I built One application service, and every surface is a client of it. The REST layer and the MCP layer each construct it through the same helper; both delegate to it, and neither touches the database directly. The dashboard reaches it through the API. Adding a surface is wiring, not a second implementation of the rules. The scoring is deliberately not clever. Each keyword category you define carries a weight, and a posting scores that weight once if any of its keywords match. There is no model in the loop and no embedding in the score: categories, keywords and weights are entirely yours, and two thresholds decide what gets archived and what gets a Telegram message. Semantic search exists, but as its own surface, running a small local embedding model rather than calling anything out. ## Hard parts **Two processes writing one vector index corrupts it.** The scheduler and the web app shared an on-disk Chroma index, which is not safe for concurrent writers: its internal capacity bookkeeping broke and the scheduler died with a native segfault complaining that an index with capacity N and N entries could not add one more. The fix made the scheduler the only writer, moving embedding cleanup out of the web process and onto a periodic job. The cost is freshness – deletions are pruned on a timer instead of immediately – and that is written down next to every method it affects. **Protecting the rows that matter in SQL, not in code.** Every delete query carries `AND bookmarked = 0 AND applied = 0`, and it is not configurable. A retention policy that can be misconfigured into deleting the job you already applied to is a retention policy with a bug in it; putting the guard in the query means no caller can forget. **Refusing a configuration that contradicts itself.** The notify threshold must be at least the save threshold, and the loader rejects anything else rather than warning. Notifying about postings you do not even archive is not a preference, it is a mistake, and the config layer treats it as one. The value of one application layer is not that it saves code: it is that it makes "does the agent see what the dashboard sees?" a question with a structural answer instead of an empirical one. --- ## [Project] Relay Shadow URL: https://vincenzo.imperati.dev/projects/relay-shadow Category: Hackathon Stack: JavaScript, PostgreSQL, Nostr, NIP-90 Repository: https://github.com/BigBrotr/relay-shadow-dvm Award: 3rd prize, Baltic Honeybadger 2025 Published: 2025-08-07 A Nostr data vending machine that recommends relays against a stated threat model, scoring measured privacy, reliability, performance and social-graph data from the BigBrotr archive. import RepoCard from '../../components/ui/RepoCard.astro' import SpecPanel from '../../components/ui/SpecPanel.astro' Relay Shadow answers "which relays should I use?", as a service other software can call, over Nostr itself. It takes a threat level and a use case, scores candidate relays against measured data, and returns a ranked set. ## The problem Every Nostr client ships a default relay list, and almost nobody changes it. That list decides what a user can see, who can see them, and which operators observe their traffic, and it is usually a handful of well-known hostnames chosen once, by a developer, for everyone. There is measured data that could inform the choice. It sits in an archive, in a shape useful to an analyst rather than to a client at runtime. ## What I built A data vending machine: a service that receives jobs as Nostr events and replies with Nostr events, so any client that already speaks the protocol can consume it without a new API. Requests arrive as kind `5600`, carrying their parameters in tags: threat level, use case, the relay set the user currently has, how many results they want. Results are signed and published as kind `5601`, with kind `7000` used for job feedback. Four request types are supported: `recommend`, `analyze` for evaluating a relay set already in use, `discover` for widening coverage, and `health`. Underneath is a PostgreSQL copy of the BigBrotr relay data with the scoring implemented as query functions that combine four weighted factors: social graph, privacy, reliability, and performance. The default weighting places most of the emphasis on the social graph. There is a React demo client and a small Express endpoint exposing service info. ## Hard parts **There is no best relay set, so the request has to say who is asking.** Threat level is a request parameter ranging from `low` to `nation-state`, and it reaches the scoring query rather than being cosmetic. A relay that is excellent for reach can be exactly the wrong answer for someone whose concern is who observes them, and a recommender returning one ranking for everybody is answering a question nobody asked. **Recommending your own contacts back to you.** Weighting the social graph most heavily is what makes a recommendation useful. A relay where nobody you follow publishes is not useful, but this weighting also means the service reads which relays a requester's contacts use in order to answer. That is a tension a privacy-focused recommender sits with rather than resolves. **Answering when the data cannot.** The service falls back to a static list per threat level when the query returns nothing, so a request gets a degraded answer rather than an error. The scoring weights are design inputs rather than measured optima, so the response remains a recommendation, not a privacy guarantee. --- ## [Project] nostr-tools URL: https://vincenzo.imperati.dev/projects/nostr-tools Category: Personal Stack: Python, asyncio, aiohttp, secp256k1, WebSockets Repository: https://github.com/BigBrotr/nostr-tools Published: 2025-08-04 An async Python library for the Nostr protocol that measures whether a relay can connect, read, and write instead of trusting what its NIP-11 document advertises. import Callout from '../../components/ui/Callout.astro' import RepoCard from '../../components/ui/RepoCard.astro' import SpecPanel from '../../components/ui/SpecPanel.astro' The Python library underneath [BigBrotr](/projects/bigbrotr). It implements the protocol – events, filters, subscriptions, Schnorr signing – and adds the thing an observatory needs and a client does not: probing a relay to find out what it will actually let you do. ## The problem A relay's NIP-11 document is a self-description. It lists the protocol extensions the operator believes are supported, and a measurement built on that number is measuring what operators believe, not what relays do. The library captures the document, and then never consults it. Nothing in the probing path reads the advertised list. What a relay supports is established by asking it. ## What I built Three probes, each answering one question, each with its own timing: can a connection be opened, will a subscription return events, and will a write be accepted. The write probe publishes a parameterized-replaceable event keyed by the relay's own URL, so repeatedly measuring the same relay updates one record rather than accumulating a trail in somebody else's database. Above them sits a single call that returns the NIP-11 document and the measurements together with a timestamp, which is the actual unit an observatory stores. Around that is the ordinary protocol surface: events with their identifier and Schnorr signature, filters, subscriptions, an eighteen-type exception hierarchy, and a client that refuses to be constructed for a Tor relay without a proxy configured, so the failure happens at construction rather than at connection. ## Hard parts **A measurement that half-succeeded is worse than one that failed.** A latency figure alongside a verdict of "not readable" is a contradiction, and it shipped: the probe recorded a round-trip on the first message received, before checking what that message was, so a relay that answered by closing the subscription produced a timing for a read that never happened. Downstream validation rejected the record. The fix makes the invariant explicit – a failed check reports no timing at all – and it is a patch, not foresight. **Supporting Python 3.9 costs the toolchain.** The floor is deliberate, for reach, and it holds the test framework several major versions back with the reason annotated next to each pin. The useful distinction in measuring an open network is between what a participant claims and what it demonstrates. Capturing the claim is worth doing because the gap between the two is itself a finding, but nothing downstream should depend on it. --- ## [Project] Nostreum URL: https://vincenzo.imperati.dev/projects/nostreum Category: Hackathon Stack: TypeScript, Next.js, Nostr, Ethereum Repository: https://github.com/VincenzoImp/nostreum Award: Three 1st prizes and one 3rd prize, NapulETH 2025 Published: 2025-07-15 A Nostr social client where a profile can carry a verified Ethereum address: the badge is backed by an on-chain proof the client checks, not by a claim a server repeats. import RepoCard from '../../components/ui/RepoCard.astro' import SpecPanel from '../../components/ui/SpecPanel.astro' Nostreum is a Nostr client with one thing other clients do not have: a verified badge that means something checkable. A profile linked to an Ethereum address shows as verified because the client read the proof from a contract, and a profile can be found by either identity. ## The problem A social identity is worth what its verification is worth. On a platform, a badge means the platform checked something and is telling you about it: you are trusting the platform, not the proof. Nostr removes the platform, and the badge goes with it: a client can show you a name, a picture and a Lightning address, all of them self-asserted. ## What I built A working client – global and following feeds, composing and publishing signed notes, reactions, profile pages – with the identity layer wired through it rather than bolted on. Events arriving from relays have their signatures validated in the client, so a relay cannot insert a note attributed to somebody else. Profiles are checked against the [nostr-linkr](/projects/nostr-linkr) contract, and the ones carrying an on-chain link render a verified badge. Search resolves in both directions: give it a Nostr public key or an Ethereum address and it finds the same person. The follow list is kept in local storage rather than published as a contact-list event, which keeps the client usable without writing anything to a relay. ## Hard parts **The badge is only worth what the client verifies.** The premise is that verification is not a server's word, which means the client performs the contract lookup itself. It also means an unverified profile has to read as plainly unverified rather than merely less decorated: the absence of the badge is the informative case, and it is the easier one to get wrong. The badge also inherits the boundary of nostr-linkr: its contract is unaudited and deployed on Base Sepolia, so "verified" means the proof path succeeds, not that the identity layer is production-secure. **Validating every event on the receiving side.** Relays are untrusted by construction, and a client that renders whatever it is handed will render forgeries. Checking each incoming signature costs work on the hot path of a feed; skipping it would make a verified badge meaningless in the very view that displays it. --- ## [Project] URI Generic Regex URL: https://vincenzo.imperati.dev/projects/uri-generic-regex Category: Personal Stack: Python, RFC 3986 Repository: https://github.com/VincenzoImp/uri-generic-regex Published: 2025-05-10 A single Python regular expression that decomposes a URI into its RFC 3986 components as named capture groups: scheme, userinfo, host, port, path, query, fragment. import Callout from '../../components/ui/Callout.astro' import RepoCard from '../../components/ui/RepoCard.astro' import SpecPanel from '../../components/ui/SpecPanel.astro' One regular expression, one file. It splits a URI into the components RFC 3986 defines and hands each one back as a named group, so extracting the host or the query is an attribute access rather than a second parse. ## What it is The URI regexes that circulate online mostly answer "does this look like a link?", which is a different question from "what are its parts?", and they tend to be assembled by hand from examples rather than from the grammar. This one follows the RFC's own component definitions and names each capture after the component it holds, which is the entire ergonomic point: a match object where `host` and `query` are attributes. The host branch distinguishes a domain name from an IPv4 literal, an IPv6 literal in brackets, and localhost, because those are four different shapes and collapsing them is where the copied regexes usually go wrong. The module also defines a large list of top-level domains, and the regex never uses it. Any claim that this validates a TLD against the IANA registry is wrong: the list is dead code, and the expression accepts any syntactically valid label. ## What I took from it A regular expression derived from a grammar and one derived from examples fail differently. The example-derived kind works on everything you thought of and silently mis-parses the rest; the grammar-derived kind is longer, harder to read, and wrong only where you misread the specification. For something that runs over URLs at scale, that second failure mode is the one worth having, and it is why the acquisition work that uses it starts from the RFC rather than from a snippet. --- ## [Project] BigBrotr URL: https://vincenzo.imperati.dev/projects/bigbrotr Category: Professional Stack: Python, PostgreSQL, FastAPI, Docker, Prometheus Repository: https://github.com/BigBrotr/bigbrotr Published: 2025-04-29 An OpenSats-funded observatory for the Nostr network: eight independent Python services that discover relays across four transport networks, archive what they serve, and expose it through a read-only API and a paid data-vending machine. import Callout from '../../components/ui/Callout.astro' import MetricRow from '../../components/ui/MetricRow.astro' import RepoCard from '../../components/ui/RepoCard.astro' import SpecPanel from '../../components/ui/SpecPanel.astro' BigBrotr is a storage-first observatory for [Nostr](https://nostr.com): it discovers relays, checks whether they are what they claim to be, archives the events they serve, and exposes the result to anyone who wants to ask a question about the network rather than about one relay. ## The problem There is no register of Nostr relays. There is no authority that knows how many exist, which are reachable, which honour the protocol, or what any of them are actually serving, and by design there never will be. A client sees the handful of relays it was configured with; a measurement of the network has to construct its own view from nothing and keep that view correct while the network moves underneath it. Three things make that harder than a crawl. Relays appear and disappear continuously. A meaningful fraction are only reachable over Tor, I2P or Lokinet, each with its own latency and concurrency profile. And "did I get everything this relay has?" is not a question a relay will answer: you have to be able to prove completeness yourself. ## What I built Eight services – a seeder, a finder, a validator, a monitor, a synchronizer, a refresher, the read API, and a data-vending machine – that run on their own schedules and **never call each other**. All coordination goes through a single `service_state` table keyed by service, state type and state key, holding JSONB. There is no message bus, no queue, and no RPC between services, so any one of them can be stopped, restarted or replaced without the others noticing. These are deployment snapshots from the instance I operate, not published benchmarks. They vary with the relay set and the collection window. Discovery is a loop rather than a list: the finder reads relay directories *and* scans already archived events for relay URLs in their tags, so the archive feeds the discovery that fills the archive. The validator promotes a candidate only on protocol-level evidence – an `EOSE`, a NIP-42 auth challenge, or an `auth-required` close – and does so in one transaction that inserts the relay and deletes the candidate together. The monitor runs seven distinct health checks across NIP-11 and NIP-66, with per-network concurrency limits: fifty in flight on clearnet, ten on Tor, five each on I2P and Lokinet. BigBrotr also publishes back. It is itself a NIP-66 monitor, emitting relay-discovery events to the network it measures, and the data-vending machine answers NIP-90 job requests with per-table pricing and a payment-required feedback path. The storage is where most of the design lives. Events are keyed by their own SHA-256 id, so re-ingesting one is a no-op; `event` and `event_relay` are both hash-partitioned sixteen ways on that same key, which the changelog notes is deliberate: same key, so joins stay partition-wise. Tag values are flattened at insert time into a `key:value` form that keeps only single-character tag keys, so a GIN index can tell an `e` tag from a `p` tag with the same value. Thirteen materialized views carry the analytics, refreshed concurrently by a service that owns them under its own database role. The same eight services and the same schema also build as **LilBrotr**, a metadata-only variant from the same parametric Dockerfile, with content, tags and signatures left unpopulated for roughly 60% of the disk. ## Hard parts **Proving completeness against a relay that will not tell you.** The synchronizer walks a relay's history with binary-split windowing: request a window, then re-fetch its boundary to verify nothing was missed, and on failure split at the midpoint and push both halves back onto the stack. Single-second windows are indivisible and yield as they are. The cost is re-reading boundaries you have already read; what it buys is that a gap is detected rather than assumed absent. **Coordination through durable state instead of direct calls.** Every checkpoint, cursor and failure count lives in one table rather than in memory or in a queue. That costs write volume and an extra round trip on every cycle, and it buys independent restarts: a service that dies mid-window resumes from the last committed cursor rather than from the beginning. **A memory leak that was not in my code.** Running this in production surfaced the kind of problem no test finds. The `Event` model delegated attribute access to the underlying `nostr_sdk` object, which is Rust behind a Python binding, so the Python garbage collector could not see what it was holding, and the synchronizer reached 54 GB RSS in eleven hours. Replacing that convenient delegation with explicit fields fixed it, trading ergonomics for memory the runtime can account for. A second, separate leak turned out to be glibc's per-thread arenas never returning pages from the binding's Tokio threads; the fix was jemalloc, which I then removed after clean local benchmarks and had to restore once it became clear the local platform simply does not have that allocator behaviour. Both leaks were invisible on a developer machine and obvious in production, and neither was in code I wrote. When a dependency crosses a language boundary, the memory model crosses it too, and a benchmark on the wrong operating system will confidently tell you the problem is gone. --- ## [Project] HR Management System URL: https://vincenzo.imperati.dev/projects/hr-management-system Category: Professional Stack: TypeScript, Next.js, Firebase Repository: https://github.com/VincenzoImp/human-resources-management-system Published: 2024-10-20 Replaced an Italian industrial contractor's paper personnel files with a web application built around the question the office actually asks: who can weld this, and how well. import Callout from '../../components/ui/Callout.astro' import RepoCard from '../../components/ui/RepoCard.astro' import SpecPanel from '../../components/ui/SpecPanel.astro' An Italian industrial contractor kept its personnel records on paper. This replaced them with a web application: searchable employee profiles, uploaded documents, and a competence rating per trade, so that staffing a job stops being a question you answer from memory. ## The problem The records were physical files in a cabinet. Finding out whether someone was available, what they were qualified for, and where their documents were meant walking to the cabinet, and answering "who on the books can do this job" meant asking whoever had been there longest. Retrieval that used to be a search through physical files is now a single query, which is the whole point of the exercise and the only measure that mattered to the client. ## What I built The employee record is twenty-two fields, nearly all optional, because records migrated from paper arrive incomplete and a schema that refuses them is a schema nobody fills in. Ten fields are required at entry – enough to identify a person and reach them – and the rest accumulate. The part that is actually specific to this business is the qualification model. Nine trades – pipe-fitter, welder, carpenter, fitter, labourer and the rest – each rated from zero to five in half-steps rather than held as a yes-or-no certificate, because competence in a trade is a gradient and the office already thought about it that way. The welder record carries two extra fields, technique and material, since those decide whether a given welder can take a given job. That model then inverts: a second view lists the workforce *by qualification*, ranked by rating, which is the query the business runs most and the reason the whole thing exists. Documents attach to an employee record, and an orphan sweep removes stored files no record references any more, so deleting an employee does not leave their documents behind. ## Hard parts **Modelling competence rather than certification.** A boolean "qualified" field is easier and answers the wrong question. Rating each trade means the data can distinguish someone who can weld from someone you would put on a pressure vessel, and that distinction is the one the office was holding in its head. **A permission model small enough to be correct.** Access is two levels: anyone signed in can read, and writing is restricted to an explicit allowlist enforced in the database's own security rules rather than only in the interface. For a company of this size that is honest and verifiable; anything more elaborate would have been enforced in the client and therefore not enforced at all. Every authenticated user can read every record, including personal data. That is appropriate for a small office where everyone with a login is already trusted with the filing cabinet, and it would not be appropriate at a larger headcount. --- ## [Project] eBay Sniper Bot URL: https://vincenzo.imperati.dev/projects/ebay-sniper-bot Category: Personal Stack: Python, DrissionPage, Jupyter Repository: https://github.com/VincenzoImp/ebay-sniper-bot Published: 2024-10-12 A bidding bot whose entire job is one click, executed at a time typed by hand, and whose only real idea is parking the mouse on the button before the wait begins. import RepoCard from '../../components/ui/RepoCard.astro' import SpecPanel from '../../components/ui/SpecPanel.astro' Auction sniping means bidding late enough that nobody has time to respond. This automates exactly the last step of that and nothing else: a human opens the browser, logs in, finds the auction and types the maximum bid, then stops at the confirmation button and hands over. ## What it is It does not watch the auction. It never reads the listing, the current price or the closing time. It watches the local system clock against a time the user enters, conventionally one second before the auction ends. The one interesting line runs *before* the wait: the mouse is moved onto the confirmation button and the resulting action object is held. Element lookup and cursor travel are paid in advance, so when the moment arrives the only remaining work is the click itself. The wait is then a bare busy loop with no sleep, spinning to get sub-second resolution out of a second-resolution comparison. Credentials are deliberately absent. There is no login code, no cookie handling and no stored session, because the human logs in, which also means there is nothing in the repository worth stealing. ## What I took from it Everything precise about it is precise for one reason: the expensive work was moved out of the critical path. Everything fragile about it comes from what it does not know: a single hardcoded CSS selector, a target with a time but no date, no confirmation that the click landed, and accuracy bounded entirely by whether the local clock agrees with eBay's. It is a small, honest demonstration that latency work is mostly deciding what to do early. --- ## [Project] Safeblow URL: https://vincenzo.imperati.dev/projects/safeblow Category: Hackathon Stack: TypeScript, Solidity Award: 1st Web3Privacy Now · 1st LaserRomae · ETHRome 2024 Published: 2024-10-05 A privacy-preserving whistleblowing and sensitive-disclosure system built at ETHRome 2024. import Callout from '../../components/ui/Callout.astro' import SpecPanel from '../../components/ui/SpecPanel.astro' Safeblow is a privacy-preserving system for whistleblowing and sensitive disclosure, built at ETHRome 2024. It won 1st place from Web3Privacy Now and 1st place from LaserRomae. The team's repository is [SafeBlow/safeblow](https://github.com/SafeBlow/safeblow), which is owned by a teammate rather than by me. What is public there is the landing page; the working implementation is not in it. The implementation has not received cryptographic review and is not suitable for handling a real disclosure. The page records the verified scope of the hackathon work, not a safety guarantee. ## The problem Whistleblowing systems have to satisfy two requirements that pull against each other: a recipient needs grounds to believe a disclosure is genuine, and the discloser needs the process itself not to identify them. --- ## [Project] Cantina Royale Tools URL: https://vincenzo.imperati.dev/projects/cantina-royale-tools Category: Professional Stack: TypeScript, SQLite, Next.js, Python Repository: https://github.com/VincenzoImp/cantinaroyale.tools Published: 2024-05-29 A public explorer for a live blockchain game's 20,413 NFTs, built on an offline snapshot compiled into SQLite at build time, so the data is reviewed like code rather than fetched at request time. import Callout from '../../components/ui/Callout.astro' import RepoCard from '../../components/ui/RepoCard.astro' import SpecPanel from '../../components/ui/SpecPanel.astro' The public explorer for Cantina Royale: five NFT collections, their traits and stats, the gameplay balance tables behind them, and current market activity. It is the player-facing surface of the data work I did as the game's data engineer. This repository contains the public explorer. The internal data engineering for the live game, including the reconciliation work that surfaced a reward-farming exploit, is separate from it. ## The problem A game's data does not live in one place. Ownership and traits are on chain, item metadata sits behind the studio's own endpoints, listings live on a marketplace, and the numbers that decide what an item actually *does* – damage curves, upgrade costs, reward pools – are balance tables that ship with the game and change when designers change them. An explorer has to join all of that, and it has to do it without either hammering four external services on every page view or shipping the whole corpus to the browser. ## What I built The data is not fetched at request time. A Python pipeline runs offline against the chain API, the metadata hosts and the marketplace, and writes a JSON snapshot that is **committed to the repository**. At build time that snapshot and 48 balance CSVs are compiled into a SQLite database, which the application then queries server-side and returns as small paginated pages. That choice has a consequence I like: a data refresh is a pull request. The snapshot diff is reviewed, and validation plus an asset-size budget run before it merges. Bad data cannot arrive silently, because arriving is a reviewed event. ## Hard parts **Getting the payload off the client.** The naive version of this ships the collection to the browser and filters it there, which is fine at a hundred items and unusable at twenty thousand. Moving the query into SQLite on the server means a collection view returns one page rather than the whole corpus, and the trait-heavy detail data is loaded only when one item is opened. **Making the build atomic.** The database is written to a temporary file and only swapped over the live one after a successful build, so a failed compile leaves the previous database in place rather than a half-written one. Deploys are all-or-nothing by construction. **Retiring the robot that committed to main.** The refresh used to run in a container that woke at midnight, cloned the repository with a token, ran the pipeline, and pushed a commit called `Auto-update` to `main`. It worked, and it meant unreviewed data could land in production overnight. Replacing it with a manual, reviewed refresh cost freshness and bought the ability to see what changed. Treating a dataset as source code – committed, diffed, reviewed, gated by CI – costs you real-time freshness and buys you every guarantee you already have about code. For data that changes weekly and is read constantly, that is the correct side of the trade. --- ## [Project] Gen4Olive Field App URL: https://vincenzo.imperati.dev/projects/gen4olive Category: Professional Stack: React Native, Expo, JavaScript, i18next Published: 2024-04-18 The field application for an EU Horizon 2020 project on olive genetic resources: camera capture and model-assisted cultivar and disease recognition, for agronomists working in a grove. import Callout from '../../components/ui/Callout.astro' import SpecPanel from '../../components/ui/SpecPanel.astro' Gen4Olive is an EU Horizon 2020 research project on olive genetic resources. The field application is the part an agronomist actually holds: point a phone at a leaf or a fruit, and get back candidate cultivars or candidate diseases, with the reference material for each. This is one of the few things here that is closed source. The repository lives under the Sapienza systems lab and is private, so there is no code to link. This page covers only the field application I built; the consortium's scientific work and publications are separate. ## The problem The users are agronomists, and the place they need this is a grove: outdoors, one-handed, on a phone, frequently without a usable connection, and under light conditions nobody chose. The reference material for hundreds of cultivars exists, but it is organised for reading rather than for identifying the tree in front of you. ## What I built An Expo/React Native application in four parts. **Capture.** Camera and gallery input, with image manipulation applied before anything is sent: the photograph a phone produces is not the input a model expects, and normalising it in the app is what keeps the recognition step consistent across devices and lighting. **Recognition.** Two prediction paths, one for cultivar and one for disease, each posting the prepared image to its own endpoint. Inference is server-side; the app is the client. The response is rendered as a list of candidates rather than a single answer, and every candidate links through to its reference entry. **Reference.** The cultivar and disease material, structured for lookup – browse, list, detail – so that a suggestion can be confirmed against the source rather than trusted. **Localisation.** i18next over a translation bundle, with the device locale detected at startup, because the consortium spans several countries and the people using it in a field are not necessarily reading English. ## Hard parts **The model is a suggestion and the interface has to say so.** The recognition result is presented as ranked candidates, each one navigable to the underlying reference entry. That costs a tap compared to showing one answer, and it is the difference between assisting an expert and asking them to trust a black box they cannot check. **Preprocessing on the device, inference off it.** Splitting the pipeline at that seam keeps the model replaceable without shipping an app update, and keeps the app small, at the cost of a network round trip in exactly the environment least likely to have one. --- ## [Project] python-oced URL: https://vincenzo.imperati.dev/projects/python-oced Category: MSc Stack: Python, pandas, JSON, XML Repository: https://github.com/VincenzoImp/python-oced Published: 2024-02-19 A reference implementation of the Object-Centric Event Data model in one module and, more usefully, the design journal of the eleven modelling problems it ran into. import Callout from '../../components/ui/Callout.astro' import RepoCard from '../../components/ui/RepoCard.astro' import SpecPanel from '../../components/ui/SpecPanel.astro' Classical process mining assumes every event belongs to exactly one case. Object-Centric Event Data drops that assumption: an event can create, delete, modify or merely involve any number of objects, and those objects carry attributes and relationships that change over time. This implements that model in a single module. ## The problem The single-case assumption is convenient and false. An order, the items in it, the payment and the shipment are separate objects with separate lifecycles, and forcing each event to belong to one of them means choosing which perspective to lose. Removing that assumption creates a harder question, though, and it is not about code: if an event can touch any number of objects in any combination, what exactly is an event *allowed* to do, and what does the store have to remember so that a question asked later still has an answer? ## What I built Everything an event does is expressed as an ordered list of qualifiers, and the qualifiers form a grid: create, delete, modify and involve, applied to objects, object relations and object attribute values. Thirteen of those combinations exist. An `Event` carries a time, a type, its ordered qualifiers and its own attributes, and lands in thirteen interlinked tables. Insertion is precondition, then execute, then log. The precondition validates the *entire* qualifier list before any of it runs, so an event that is invalid halfway through leaves no trace: there is no partial application to unwind. Sixty-three distinct validation sites enforce types, identifier uniqueness, existence, self-relations and no-op modifications. Deletion is soft: an `existency` flag rather than a removed row, so history survives. Getters return deep copies, so no caller can reach in and mutate the store. ## Hard parts The interesting part of this repository is not the code. It is `NOTE.md`, a handwritten journal of eleven modelling problems, each with the option taken and the cost accepted. **Order inside an event.** If one event creates an object, deletes it, and creates it again, the store has to record that object three separate times and preserve the sequence. The resolution was to keep the full ordered qualifier history rather than collapse to a final state, so the true order is recoverable afterwards. **What belongs in the key.** Attribute values are keyed by value and object, deliberately not by name. The note records the price of that: two attributes can end up with different identifiers but the same name and the same value, and a human reading the table cannot tell them apart. **Rules that only hold locally.** Modifying a value to the value it already has is forbidden, but the check only compares a qualifier with its immediate successor, so modify to 2, modify to 1, modify to 2 slips through. The note writes out that exact hole rather than claiming the rule is general. A data model is a set of decisions about what you are allowed to ask later, and most of them are made silently. Writing down the eleven that were not silent – including the four still unresolved – turned out to be worth more than the module they describe. The serialisation format is specific to this implementation rather than the OCEL interchange standard. Attribute values are strings only, and the journal leaves cascade deletion and object resurrection unresolved. --- ## [Project] Lesson Booking System URL: https://vincenzo.imperati.dev/projects/lesson-booking-system Category: MSc Stack: Modelica, OpenModelica, Python, LaTeX Repository: https://github.com/VincenzoImp/lesson-booking-system Published: 2023-04-25 The object-oriented design of a university room-booking system, paired with an executable model that treats the booking load as a stochastic process and searches for how often the platform must refresh its view. import RepoCard from '../../components/ui/RepoCard.astro' import SpecPanel from '../../components/ui/SpecPanel.astro' A software-engineering deliverable in two halves that never meet in code: thirty-four design diagrams for a university classroom booking system, and a Modelica model that simulates it and checks its requirements while it runs. There is no implemented application, deliberately. ## What it was The model treats the system as a clocked discrete-event process rather than as CRUD. A room's availability is a three-state Markov chain with a written transition matrix, resampled every four hours and mapped to capacities of a hundred, fifty or zero seats. Student bookings arrive each minute from a categorical distribution that includes cancellations. Two real university platforms are modelled as sampling relays that re-read the room's state periodically, so the interesting quantity is how *stale* the platform's view is allowed to get. Two monitors turn the requirements into signals. One raises a safety flag if bookings ever go negative or exceed capacity, and a liveness flag if bookings arrive without the count moving. The other compares the true state against what the platform perceives and tracks the worst discrepancy. Both campaigns ran and their logs are in the repository: ten runs with randomised seeds, all passing, and a ten-point sweep of the platform's polling period against an explicit objective that prices a state mismatch heavily and squares the booking error. ## What I took from it Writing the requirement as a monitor rather than as a sentence is what makes the sweep possible. Once "the platform's view must not drift" is a signal you can plot, choosing how often to poll stops being a judgement call and becomes a search, and the objective function you write down is where the real judgement moved to. --- ## [Project] Algorithms Course Exercises URL: https://vincenzo.imperati.dev/projects/algorithms-course-exercises Category: MSc Stack: Python, Jupyter, NetworkX, LaTeX Repository: https://github.com/VincenzoImp/algorithms-course-exercises Published: 2023-04-12 Twelve weeks of exercise sessions for an undergraduate algorithm-design course: problem statement, worked reasoning, Python, and the slides used to teach each one. import RepoCard from '../../components/ui/RepoCard.astro' import SpecPanel from '../../components/ui/SpecPanel.astro' Teaching material for the exercise sessions of an undergraduate algorithm-design course at Sapienza: thirty problems across twelve weeks, each written out as the statement, a recap of the definitions it needs, and the reasoning toward a solution. ## What it is Each week ships as three artefacts: a notebook with the problems worked through, a LaTeX-typeset PDF of the solutions, and, for the first eight weeks, the slide deck used to present them. The problems are in Italian, because the course was. The material is graph-heavy: classifying DFS edges, strongly connected components, orienting an undirected graph into a DAG, bridges, why Dijkstra breaks on negative weights, and several minimum-spanning-tree properties. Then divide and conquer, dynamic programming, and a set of backtracking generation problems. The Python is deliberately plain: graphs as adjacency-list dictionaries, with NetworkX used only to draw the examples. ## What I took from it Writing a solution for someone else to read is a different job from producing one. The students were almost never stuck on Python; they were stuck on why a particular approach was the right one, which means the useful artefact is the argument, not the code that follows from it. That shows up in the structure – statement, the definitions you need to have in your head, then the reasoning – and it is the same instinct behind every technical page I write now. --- ## [Project] NFT Collections Generator URL: https://vincenzo.imperati.dev/projects/nft-collections-generator Category: MSc Stack: Python, PyTorch, OpenCV, CUDA Repository: https://github.com/VincenzoImp/nft-collections-generator Published: 2022-11-27 Training GANs on two real 10,000-image NFT collections to generate and upscale new artwork, including the attempt to blend the two, which did not work and is written up as not working. import RepoCard from '../../components/ui/RepoCard.astro' import SpecPanel from '../../components/ui/SpecPanel.astro' A computer-vision course project: scrape two real 10,000-image NFT collections, train a DCGAN on each to generate new artwork at 64×64, then upscale with an SRGAN. Then try to make a model that blends the two collections into one. ## What it was The two collections were pulled from where they actually live – the Apes from IPFS, the Elrond collection from its chain API – and each trained a separate DCGAN, a hundred-dimensional latent vector through five transposed-convolution layers to a 64×64 image. Generated images then go through denoising, a four-times SRGAN upscale to 256×256, and denoising again. The comparison between the two collections is the useful part. The Apes have wildly varied backgrounds, and the model learned the palette without learning that the colours are mutually exclusive: it knows blue and green are both plausible, not that a background is one or the other. The Elrond collection's backgrounds are uniformly dark, and it trained faster and better on identical settings. Only that one was considered good enough to be worth training the upscaler on. The blending experiment used one generator against **two discriminators**, each with its own dataset, with the generator's loss averaged between them. The presentation is blunt about the outcome: it does not work, and a DCGAN is the wrong architecture for it. StyleGAN is the one that does this. ## What I took from it The dataset decided the result more than the architecture did. Two collections, the same model, the same thousand epochs, and one trained cleanly while the other learned a palette instead of a picture, because of how much its backgrounds varied. That is a cheaper thing to check than a hyperparameter sweep, and I did not check it first. --- ## [Project] Peer Store URL: https://vincenzo.imperati.dev/projects/peer-store Category: MSc Stack: Kotlin, Android, Firebase, ML Kit Repository: https://github.com/VincenzoImp/peer-store Published: 2022-11-27 An Android marketplace built on Firebase with on-device object detection and, in hindsight, a good example of how an async API returns a value that is always zero. import RepoCard from '../../components/ui/RepoCard.astro' import SpecPanel from '../../components/ui/SpecPanel.astro' An Android marketplace for an MSc mobile-development course: register, post an item with a photo and a price, browse what other people have posted, and leave messages under a listing. Ten activities in Kotlin over Firebase auth, Firestore and Storage. ## What it was The interesting piece is that the object detection runs **on the device**. ML Kit's bundled base model labels the uploaded photo and the labels are stored alongside the listing: no network call, no API key, no image leaving the phone. It does not fill in the form; the seller still types the title, description and price, and the labels appear as their own field on the listing. Messages under a listing are a public thread rather than a private conversation, kept as an array on the post document. ## What I took from it Two bugs in this repository taught me more than the app did. The first is a shape I now recognise instantly. Location is read like this: declare a variable, ask for the last known location, assign it inside the success callback, return the variable. The callback cannot have run by the time the function returns, so every listing was written with a latitude and longitude of exactly zero, and every distance in the browse screen rendered as zero. Nothing threw. The code reads correctly if you do not know that the call is asynchronous. The second is the message thread: read the array, append, write the whole array back. Two people posting at the same moment means one message disappears, and no error is raised anywhere. The fix – an atomic array append – is one method call, and knowing it exists is the entire difference. Neither is a subtle race in production code. They are both the default mistake for the API in question, which is exactly why they are worth remembering. --- ## [Project] MeltyFi URL: https://vincenzo.imperati.dev/projects/meltyfi Category: MSc Stack: Solidity, Move, Sui, Next.js Award: Most Innovative Project, Sui Hackathon Published: 2022-11-23 NFT-backed lending that replaces price-based liquidation with a lottery: the borrower is funded by ticket sales, and a default hands the NFT to a ticket holder rather than to a liquidator. import RepoCard from '../../components/ui/RepoCard.astro' import SpecPanel from '../../components/ui/SpecPanel.astro' MeltyFi lends against an NFT without ever needing to know what the NFT is worth. The collateral is split into lottery tickets that lenders buy; if the loan is repaid the lenders are refunded and the NFT returns to its owner, and if it is not, one ticket holder receives the NFT itself. ## The problem NFT-backed lending normally works the way any collateralised loan does: the lender watches a price, and when the collateral falls below a threshold it is seized and sold. That mechanism needs a price, which for an NFT means a floor price: a number derived from whatever happens to be listed on a marketplace at that moment, which can be manipulated by a single sale and does not exist at all for a thin collection. So the liquidation machinery is built on the least reliable input in the system, and the borrower's collateral can be taken because of an oracle rather than because of anything they did. ## What I built The protocol removes the price from the design entirely. An owner deposits an NFT and opens a lottery over it; lenders buy tickets – *WonkaBars* – and the proceeds go to the owner immediately, which is the loan. At resolution one ticket is drawn. If the owner repays, the lottery closes and the NFT is returned; if they do not, the drawn ticket holder takes the NFT. Everyone who participated is minted *ChocoChip*, a fungible reward token, either way. There is no threshold, no oracle, and no forced sale. What replaces the floor price as the pricing mechanism is the lenders themselves: the amount raised *is* the market's valuation, set by people willing to put money behind it. On the EVM version the pieces are a core contract holding the logic, an ERC-1155 for the tickets, an ERC-20 for the rewards, a governance contract, and Chainlink VRF for the draw. The Sui rewrite models the same protocol in Move as three on-chain assets: the tickets, the reward coin, and a shared `Lottery` object that escrows both the NFT and the raised SUI and carries the state machine (`active`, `concluded`, `cancelled`, `expired`), with a fixed 5% protocol fee. ## Hard parts **The draw has to be unriggable, and the way to get randomness is not portable.** A lottery whose outcome the borrower can influence is not a loan, it is a coin flip they control. On the EVM implementation that means Chainlink VRF: an external oracle, a callback, and a dependency the protocol otherwise does not want. The Sui rewrite uses `sui::random`, randomness native to the chain. Same requirement, completely different shape: the one part of the design that had to be rewritten rather than translated when the protocol moved. None of the implementations has received a security audit, and the incentive design has not been analysed formally. The work establishes the mechanism, not that it is safe to resolve a lottery over real assets. **Tickets have to be ownable and countable at the same time.** A ticket is a transferable asset, but the draw needs to map a single winning number onto a holder. The Sui implementation records, per holding, the lottery it belongs to and the contiguous *range* of ticket numbers it covers, so a draw resolves to an owner without iterating over every participant. **Four implementations of one protocol.** It exists on EVM, on Sui in Move, and on the XRPL EVM sidechain, with a current consolidation on Solidity 0.8.30 and Foundry. What is portable is the mechanism; what is not is everything touching the chain's own primitives: randomness, object ownership, and how an escrow is represented. --- ## [Project] Who Funds a Rug Pull URL: https://vincenzo.imperati.dev/projects/ethereum-address-clustering Category: MSc Stack: Python, Ethereum, NetworkX, Geth Repository: https://github.com/VincenzoImp/ethereum-address-clustering Published: 2022-10-21 A reverse funding graph over 16,584 Ethereum accounts that created a liquidity pool and drained it within a day, built to ask whether the people behind them share a source of money. import Callout from '../../components/ui/Callout.astro' import MetricRow from '../../components/ui/MetricRow.astro' import RepoCard from '../../components/ui/RepoCard.astro' import SpecPanel from '../../components/ui/SpecPanel.astro' A one-day exit scam is a specific and easily dated event: an account creates a liquidity pool, attracts deposits, and removes the liquidity within twenty-four hours. Starting from 21,788 of them, this walks the chain *backwards* to find who paid for the accounts that did it. ## The problem Bitcoin's common-input-ownership heuristic does not exist on Ethereum: the account model has no inputs to share. Most of the linkage techniques that work on a UTXO chain have no analogue here, which is why this does not attempt them. What remains available is provenance. An account that deploys a scam had to be funded by something, and if many scam accounts trace back to the same funder, that is a relationship the chain states directly rather than one a heuristic infers. ## What I built A backwards collector and a graph. The collector reads every block from genesis to 14,339,974 in chunks of a thousand, spread across a pool of twenty-plus worker processes talking to a local Geth node over IPC, and keeps any transaction whose recipient is one of the seed accounts. Each seed carries a `use_untill` bound – the block at which it removed liquidity – so a transaction only counts if it arrived while that account was still active. Progress is written to a chunk log, so a run that dies resumes rather than restarts. The analysis then removes 485 labelled service accounts – centralised exchanges, DEXs, NFT platforms, gaming – before building the graph. The README states why: those are high-degree hubs, and left in they act as spurious bridges connecting addresses that have nothing to do with each other. Removing them dropped roughly 30% of all collected transactions, leaving 32,474 accounts and 35,643 funding edges, which fall into 7,929 connected components. The largest holds 6,219 accounts; the busiest single funder paid 110 distinct scam accounts. ## Hard parts **The hubs are the whole difficulty.** Almost every account on Ethereum has touched an exchange, so a funding graph that includes exchanges collapses into one component and says nothing. The filter is the analysis: what survives it is the set of relationships that do not route through a service everybody uses. The cost is that any scam funded through an exchange withdrawal becomes invisible, which is not a small exclusion. **Time has to bound the edge.** An address that funded a scammer two years after the scam is not a funder of that scam. Carrying a per-account cut-off block through the collection makes the resulting edge mean something specific: money that arrived while the account was live. The graph reaches one hop back and stops, and the study ends at component statistics rather than a conclusion. It shows that funding relationships between separate scam accounts exist and how many; it does not establish who anyone is. --- ## [Project] SDN Performance Analysis URL: https://vincenzo.imperati.dev/projects/sdn-performance-analysis Category: MSc Stack: Python, Mininet, Ryu, D-ITG Repository: https://github.com/VincenzoImp/sdn-performance-analysis Published: 2022-08-30 Two software-defined network topologies differing by one link, measured across 303 traffic loads: the added link doubles the load before saturation and removes a 33% latency penalty. import RepoCard from '../../components/ui/RepoCard.astro' import SpecPanel from '../../components/ui/SpecPanel.astro' Four hosts, three switches, and one question: what does adding a single link do? Both topologies are emulated in Mininet under a Ryu shortest-path controller and driven with UDP flows at increasing rates until they break. ## What it was Four unidirectional UDP flows with Poisson arrivals, swept across a hundred load levels on the first topology and two hundred on the second, measuring link utilisation from the controller's port counters and end-to-end delay and packet loss from the traffic generator. The report predicts the results before measuring them, from flow counts alone. In the first topology, one link carries all four flows while every other link carries two, so it should saturate at half the load, and one host's packets should take a third longer than the other's because they cross an extra hop. Both predictions hold exactly. The bottleneck saturates at half the offered load of everything else; the disadvantaged host measures 80.6 ms against 60.4 ms, a ratio of 1.333. Adding the one link equalises every path to 60.3 ms, spreads the load evenly, and pushes the onset of sustained packet loss from a rate of 108 to 244, more than double. ## What I took from it Predicting the shape of the result before running the experiment is what makes the experiment worth running. Counting flows per link gave the utilisation ratio and the latency penalty on paper, and the measurement's job was to confirm or refute that rather than to discover it. The second topology also leaves one link carrying almost nothing: 0.4% utilisation. It is the cost of the fix, and the report does not call it one. --- ## [Project] Bitcoin Address Clustering URL: https://vincenzo.imperati.dev/projects/bitcoin-address-clustering Category: MSc Stack: Python, Spark, NetworkX, Streamlit Repository: https://github.com/VincenzoImp/bitcoin-address-clustering Published: 2022-06-02 Collapsing 334,177 Bitcoin addresses into 109,074 entities over the chain's first two years, using a heuristic chain tuned to avoid merging entities that are not the same one. import Callout from '../../components/ui/Callout.astro' import MetricRow from '../../components/ui/MetricRow.astro' import RepoCard from '../../components/ui/RepoCard.astro' import SpecPanel from '../../components/ui/SpecPanel.astro' Bitcoin addresses are pseudonymous individually and leaky in aggregate: the way transactions are constructed reveals which addresses are controlled by the same entity. This builds the transaction graph for the chain's first 115,000 blocks and applies a chain of ownership heuristics to it. ## The problem Common-input-ownership – if two addresses fund the same transaction, one entity controls both – is the heuristic everybody quotes, and on its own it recovers very little. The rest of the signal is in change addresses: a payment usually has two outputs, one to the recipient and one back to the sender, and deciding which is which is where the real inference happens. Every one of those inferences can be wrong, and the errors do not stay local. Merging two entities that are not the same entity does not produce one bad row; it welds two subgraphs together and corrupts every conclusion downstream. ## What I built The graph is transaction-centric: transactions are nodes, UTXOs are edges carrying the address and value, with synthetic nodes for coinbase and unspent outputs. It is assembled from the blockchain.info block API rather than from a node, with Spark handling the extraction and aggregation and the clustering itself running as a two-pass sweep in temporal order. The heuristics are a chain, not a single rule: coinbase outputs grouped per miner, a special case for early Satoshi-era blocks, consolidation transactions, common-input-ownership on payments, and a family of five change-address tests tried in order: the same address appearing in input and output, unnecessary inputs, an output address never seen before, round-number amounts, and address reuse. Transactions that look like CoinJoins are detected and *excluded* from the heuristics, with a single round of taint analysis run instead. A Streamlit app with a PyVis rendering lets you pick an entity and see its subgraph. ## Hard parts **Choosing precision and paying for it.** The notebook states the design position directly: the algorithm aims to reduce false positives as much as possible, avoiding the merge of two addresses belonging to different entities. The cost is written in the results: most clusters end up holding one or two addresses, which is why a second, weaker pass exists that discards singletons and takes the count from 109,074 down to 14,933. That second number is more useful and less defensible, and the notebook keeps them separate rather than reporting one. **Knowing when not to apply a heuristic.** A CoinJoin deliberately violates common-input ownership. Running the heuristics over one does not fail: it confidently merges unrelated people. Detecting the pattern and stepping back from it is the only correct response, and it means accepting that those transactions stay unresolved. Clustering heuristics produce attributions about real people, and they are wrong some of the time in a way the output does not show. The numbers here describe what the heuristics produced, not ground truth. There is none. --- ## [Project] Online News Popularity URL: https://vincenzo.imperati.dev/projects/online-news-popularity Category: MSc Stack: Python, scikit-learn, imbalanced-learn, Jupyter Repository: https://github.com/VincenzoImp/online-news-popularity Published: 2022-01-23 A graded machine-learning challenge where every model scored 80% accuracy by predicting one class for every article, and the work was making that failure visible and then fixing it. import RepoCard from '../../components/ui/RepoCard.astro' import SpecPanel from '../../components/ui/SpecPanel.astro' A course challenge: predict how widely a Mashable article will be shared, from a deliberately corrupted version of a public dataset, with the share count discretised into five ordered classes. ## What it was The dataset was supplied broken on purpose – malformed rows, out-of-domain values – so cleaning carried its own marks. Feature importance from a random forest then cut fifty-nine predictors to the eighteen scoring above a threshold. The real problem appeared at the first evaluation. Every model returned about 80% accuracy and an F1-macro of 0.18, and the confusion matrix showed why: the classifier predicted the majority class for all 12,546 test articles. It had learned the base rate and nothing else, and accuracy was rewarding it. The fix was resampling to a *graded* distribution rather than a uniform one: undersampling and then SMOTE to class targets of one third, one quarter, one fifth, one sixth and one seventh. The reasoning is written in the notebook: non-viral articles genuinely outnumber viral ones, so flattening the classes entirely trains for a world that does not exist. Uniform balancing was tried and rejected on measured results. ## What I took from it Accuracy at 80% and accuracy at 65% were the same model getting better. Choosing the metric before looking at the numbers is what stops a result like the first one from being reported as a success, and the honest finish is that even after balancing, the top class scores an F1 of 0.02. The model still cannot find a viral article; it just no longer pretends none exist. --- ## [Project] Smart Houses URL: https://vincenzo.imperati.dev/projects/smart-houses Category: MSc Stack: Python, Keras, keras-tuner, pyqlearning Repository: https://github.com/VincenzoImp/smart-houses Published: 2021-11-12 A reimplementation of a published demand-response system: an LSTM forecasting twelve hours of electricity prices, feeding reinforcement-learning agents that schedule electric-vehicle charging. import RepoCard from '../../components/ui/RepoCard.astro' import SpecPanel from '../../components/ui/SpecPanel.astro' A reimplementation of Lu, Hong and Yu's demand-response system from *IEEE Transactions on Smart Grid*, in two stages: forecast the next twelve hourly electricity prices, then use those forecasts to decide when to charge an electric car. ## What it was The work splits cleanly, and the git history shows it: Deborah Dore wrote the forecasting stage and the data pipeline; I wrote the simulator underneath the second stage. The forecaster reads fifty past hourly prices and predicts twelve ahead, and a three-hour hyperparameter search improved its RMSE from 0.059 to 0.043. My half models the car battery four ways – never shifted, shifted as one block, split greedily across cheap hours, or continuously modulated by a Q-learning agent – and scores each against doing nothing, on cost, on energy actually delivered, and on how full the battery is when unplugged. ## What I took from it The committed results say something the report does not. There is a weighting between saving money and inconveniencing the driver, and everything depends on it. When cost is weighted four times more heavily than convenience, the scheduler wins clearly and cuts the bill by about a third, but it does so by delivering roughly a third less charge. Weight the two equally, and every reinforcement-learning variant scores *worse than doing nothing*. Weight convenience more heavily and all of them do. That is the lesson, and it is not about reinforcement learning: a result reported at one setting of a trade-off parameter is a result about that setting. The evaluation ran at three, and only the flattering one made it into the write-up. --- ## [Project] Modelica Notes URL: https://vincenzo.imperati.dev/projects/modelica-notes Category: MSc Stack: Modelica, OpenModelica, Python, OMPython Repository: https://github.com/VincenzoImp/modelica-notes Published: 2021-09-09 Six sessions of annotated Modelica examples that build up model-based control design: plant, controller, monitor, Monte Carlo verification, then synthesising the parameters that still satisfy the requirement. import RepoCard from '../../components/ui/RepoCard.astro' import SpecPanel from '../../components/ui/SpecPanel.astro' Seventeen worked examples from six sessions of a cyber-physical systems course, re-typed and annotated in Italian with a written note per session explaining what each model changes and why. ## What it is Despite the name, almost none of it is physics. The models are discrete-event and control: a traffic light as a clocked state machine, a car whose driver "only presses the accelerator and does not fear death", a controller replacing that driver, and a monitor that formalises whether the controller got it right. The arc is what makes the collection worth keeping. Once the monitor exists, a Python driver sweeps every arrival scenario through the compiled model and reports where the controller breaks, and the notes record it breaking: the first version stops for a red light even after it has already crossed the junction, which, as the note says, makes no sense. The fix is a controller that only obeys a light still in front of it. Then the question turns from correctness to cost. Reading the light continuously is expensive, so the sampling period becomes a variable and a search finds the largest period that still passes every test. The last session goes further: a second monitor adds a conflicting requirement – keep a buffer near half full – and the two are weighted 0.8 to 0.2 so a grid search can optimise against both at once. ## What I took from it One line in the notes has stayed with me longer than the syntax: with a Markov-driven input stream, a badly shaped transition matrix makes overflow unavoidable *no matter how good the controller is*. The environment, not the controller, decides whether the requirement is satisfiable at all, and finding that out is what the monitor and the sweep are for. --- ## [Project] Parallel Sudoku solver URL: https://vincenzo.imperati.dev/projects/parallel-sudoku-solver Category: BSc Stack: C, Pthreads, OpenMP Repository: https://github.com/VincenzoImp/parallel-sudoku-solver Published: 2021-09-09 Two parallel Sudoku solvers in C, benchmarked honestly: the Pthread version gains 5% at two threads and then gets slower, and the OpenMP version never beats its own serial run. import RepoCard from '../../components/ui/RepoCard.astro' import SpecPanel from '../../components/ui/SpecPanel.astro' Three C solvers for 9×9 Sudoku, written with Gaetano Conti for a concurrent-programming course: a Pthread depth-first search with task delegation, a variant that can delegate several branches at once to push the search toward breadth-first, and an OpenMP level-by-level breadth-first solver. ## What it was The Pthread design has three thread roles. Idle threads block on their own semaphore; a thread that generates several candidate boards checks a shared FIFO of idle threads, becomes a delegator, and hands one board to one sleeper while keeping the rest. The OpenMP solver instead expands a whole level of the search tree at a time, with the append into the next level serialised inside a critical section. The result is negative, and the report says so. The Pthread solver's best mean is 0.0073 seconds per puzzle at two threads against 0.0077 serial – about 5% – after which it degrades steadily to 0.0146 seconds at thirty-two threads, roughly twice as slow as not parallelising at all. The OpenMP solver's fastest configuration is its one-thread run. Two more things the report admits and most reports would not. The hybrid variant was excluded from the benchmarks entirely because of a race condition that appears under a particular thread schedule after the solution is found, and was never fixed. And the 100-puzzle test set was chosen easy enough for the OpenMP solver to finish, which biases the comparison toward the weaker implementation. ## What I took from it The benchmark hardware was a laptop with two physical cores, so measuring at eight, sixteen and thirty-two threads was measuring contention. That is the lesson that stuck: a scaling curve without the machine next to it is not a result, and an honest negative finding – including a failed optimisation, where shuffling the fill order lost to filling cells linearly – is worth more than a speedup number nobody can reproduce. --- ## [Project] QuizArt URL: https://vincenzo.imperati.dev/projects/quizart-app-design Category: BSc Stack: HCI, User research, Prototyping, Python Repository: https://github.com/VincenzoImp/quizart-app-design Published: 2021-09-09 An HCI project for a cultural-heritage quiz app, where 23 interviews and 261 survey responses chose the feature list, including which idea to drop. import RepoCard from '../../components/ui/RepoCard.astro' import SpecPanel from '../../components/ui/SpecPanel.astro' A human-computer-interaction project with three others: design a mobile app that turns Italian cultural-heritage visits into a game, where points earned in daily and weekly quizzes convert into vouchers redeemable at partner sites. There is no application: the deliverable is the design process. ## What it was The survey was not decoration. Two hundred and sixty-one responses were segmented by age and gender across thirteen dimensions and scored with a small Python script, and the resulting numbers picked the feature list: vouchers scored highest, then in-quiz facts, then the nearby-sites board. The lowest-scoring idea, a friends leaderboard, was cut. The prototypes went paper first, then two rounds of interactive mockups, and the testing method changed with them: a person acting as the machine while the screens were paper, then think-aloud once they were clickable. Six sessions were recorded. Paper prototyping was abandoned partway through, and the report says why: the quiz flows had too many screens to draw. There is also a feasibility study on the part that would actually be hard – persuading heritage sites to honour vouchers from an unknown app – benchmarked against two existing Italian schemes, and honest that it needs partner agreements the team could not obtain. ## What I took from it The feature list came out of a spreadsheet, not a meeting. Scoring six candidate ideas across 261 people and then cutting the one that scored lowest is a small thing to do and a hard habit to keep: the friends leaderboard was the idea that felt most obviously right, and it was the one the data removed. --- ## [Project] Social Game System URL: https://vincenzo.imperati.dev/projects/social-game-system Category: BSc Stack: Java, XChart, Swing Repository: https://github.com/VincenzoImp/social-game-system Published: 2021-09-09 A four-person Java simulation where agents with moods, inherited character traits and private vocabularies talk, befriend, reproduce and die, and where optimists take over until a performance fix causes a population crash. import RepoCard from '../../components/ui/RepoCard.astro' import SpecPanel from '../../components/ui/SpecPanel.astro' Built with Lorenzo Camilli, Gaetano Conti and Matteo Dispoto for a Java programming-methodologies course: a society of agents, each with a mood, one of eight character traits, and a private vocabulary that maps numbers to meanings. They meet, exchange messages, interpret them through their own vocabulary, gain or lose mood, have children, and die. ## What it was The rule that makes it interesting is inheritance. A child receives its parent's *entire* vocabulary and output dictionary, so the mapping from a number to a meaning passes down a lineage. Two agents can exchange the same message and one is cheered by it while the other is insulted, and that disagreement is heritable rather than random. Each day, sixty per cent of the population attempts a new friendship, formed only if both sides independently agree. Friendships are re-weighted every five exchanges and can break. Mood above a hundred produces a child; mood at zero, or a predetermined death day, ends the agent. ## What I took from it Two things the simulation did that nobody designed. Optimists win. Over long runs the positive trait dominates the population, because good moods produce children and children inherit the trait disproportionately, while the negative trait tends to die alone, its agents losing mood in isolation. And then the fix became the finding. Tens of thousands of agents made each iteration crawl, so we added an overpopulation rule: above a threshold, lifespans shorten and families are capped. That was a performance workaround. What it produced was a population curve that climbs and then falls off a cliff: a boom and bust that came out of a decision made for entirely unrelated reasons. It is the clearest lesson in the project, and none of it was intended. --- ## [Project] Bachelor thesis, scheduling EV charging with reinforcement learning URL: https://vincenzo.imperati.dev/projects/bachelor-thesis Category: BSc Stack: Python, Q-learning, LaTeX Repository: https://github.com/VincenzoImp/bachelor-thesis Published: 2021-07-02 My BSc thesis at Sapienza: four ways to model an electric car inside a home energy management system, simulated over a year, and the finding that the simplest of them beat both reinforcement-learning models. import Callout from '../../components/ui/Callout.astro' import RepoCard from '../../components/ui/RepoCard.astro' import SpecPanel from '../../components/ui/SpecPanel.astro' Electricity is cheaper at some hours than others, and an electric car is a large, flexible load that mostly does not care when it charges as long as it is full by morning. The thesis reproduces a published home-energy-management algorithm and extends it with a car, modelled four different ways, to find out which framing actually pays. ## The problem Shifting a load to a cheaper hour is only free if nobody notices. Charging the car at 3am saves money; leaving it half full at 8am does not, and the two effects have to be traded against each other explicitly rather than assumed away. The thesis does that with a weighting between cost and comfort, evaluated at three settings so the answer is not tuned to one preference. The other constraint is knowledge. Two of the four models need to know when the car will be unplugged; one does not. That turns out to matter more than the learning algorithm. ## What I built A simulator in which each household device runs as its own thread with its own independent Q-learner – no shared table – stepping hour by hour through a year of real price and consumption data. Four models of the car sit inside it: a do-nothing baseline, a *shiftable* battery that postpones a single contiguous charge, a *controllable* battery that modulates charging power across twenty-five levels, and a deliberately naive one that simply charges whenever it is cheap. ## Hard parts **The simplest model won.** The naive battery beat both reinforcement-learning models in every scenario, because it can split charging across several disjoint cheap intervals while the shiftable model can only move one block. Its best result cuts cost by 10.8% while charging exactly as many kilowatt-hours; a variant that leaves the battery 3% short reaches 16.5%, at the cost of 5.8% less energy delivered, and loses outright once comfort is weighted heavily. **Three deviations from the reference paper, each with a negative result recorded.** The paper's state definition was unclear, so both a two-dimensional and a three-dimensional Q-table were tried: the flatter one won for the shiftable battery, the richer one for the controllable battery, whose utility genuinely depends on state of charge. The paper's convergence criterion never converged – the car's bursty load keeps the environment too dynamic – so a fixed episode count replaced it. And carrying a Q-table across hours, rather than resetting it, degraded results as the table accumulated bad tendencies. The simulation is fed the true future prices rather than a forecast, standing in for the price-prediction step of the original paper. No forecaster is implemented here, so the results describe an upper bound under perfect foresight. --- ## [Project] CatSScript Website URL: https://vincenzo.imperati.dev/projects/catsscript-website Category: BSc Stack: PHP, JavaScript, PostgreSQL, MySQL Repository: https://github.com/VincenzoImp/catsscript-website Published: 2021-04-12 A cat-themed site for learning HTML, CSS and JavaScript, with a timed quiz per topic, built with a classmate for a BSc exam, and ported to a second database when the free host turned out not to run PostgreSQL. import RepoCard from '../../components/ui/RepoCard.astro' import SpecPanel from '../../components/ui/SpecPanel.astro' Built with a classmate for a BSc web-programming exam: fifteen lesson pages covering HTML, CSS and JavaScript, and three ten-question quizzes with a fifteen-second timer, wrapped in kitten artwork with lobby music and podium sounds. The name is a pun on CSS and JavaScript. ## What it was There is no framework and no router: the directory layout *is* the routing, and each section carries its own copy of the navigation bar and its own logout script. Accounts are PHP sessions over a users table. The part worth keeping is the folder named `varianti server`. The site was written against a local PostgreSQL, and the free host they could actually deploy to only offered MySQL, so the repository ends up with two complete sets of authentication scripts, one per database, and a commit message explaining that the two servers needed different kinds of query. ## What I took from it Comparing those two variants years later is more instructive than anything in the lessons. The PostgreSQL version uses bound parameters. The MySQL port interpolates the user's input straight into the query string. The port is functionally identical and quietly injectable, and nobody noticed, because the thing you check after a migration is whether it still works, and it did. That is the lesson: a port preserves behaviour and silently drops properties, and the properties are the part nobody has a test for. --- # Research ## [Research] The Conspiracy Money Machine: Uncovering Telegram's Conspiracy Channels and their Profit Model URL: https://vincenzo.imperati.dev/research/conspiracy-money-machine Venue: USENIX Security 2025 (conference) Status: published Authors: Vincenzo Imperati, Massimo La Morgia, Alessandro Mei, Alberto Maria Mongardini, Francesco Sassi Published: 2025-08-13 A Telegram-to-web measurement pipeline over 120K+ channels and 498M+ messages that traces how conspiracy communities are monetized, quantifying a USD 71M ecosystem. import Callout from '../../components/ui/Callout.astro' import MetricRow from '../../components/ui/MetricRow.astro' import SpecPanel from '../../components/ui/SpecPanel.astro' Conspiracy communities are usually studied as a content problem. This paper studies them as an **economic** one: who gets paid, through which infrastructure, and how much. ## Research question How is the conspiracy ecosystem on Telegram monetized, and can the flow from community to revenue be measured end to end rather than inferred? ## Method We built a measurement pipeline that follows the path from Telegram communities out to the open web: channel discovery and collection, message parsing, URL extraction, and resolution of the destinations those URLs lead to, preserving provenance at every hop, so any figure in the paper can be traced back to the message it came from. ## Findings The monetization does not run through the platforms most moderation work focuses on. It runs through ordinary web infrastructure – storefronts, payment processors, donation platforms, and affiliate links – reached from communities that themselves host no transaction. Measured end to end, that ecosystem accounts for approximately USD 71 million. The interesting engineering problem was provenance, not scale. Keeping every derived number traceable to its source message is what makes a claim like "$71M" defensible instead of merely large. ## Engineering contribution The study required infrastructure before it could require analysis: collection at a scale where retries, duplicates, and partial failures are the normal case; a schema that separates observed data from derived facts; and a pipeline that can be re-run to reproduce any figure. I have written about [the infrastructure behind it](/posts/measurement-infrastructure) separately. ## Limitations The measurement covers public channels observed during the study window and destinations reachable from them. It does not generalize to private groups, and revenue figures are lower bounds constrained by what is publicly observable. --- ## [Research] Hard to See, Harder to Block: Exploring the Challenges in Email Tracking Pixels Detection URL: https://vincenzo.imperati.dev/research/email-tracking-pixel-detection Venue: IEEE Access (journal) Status: published Authors: Federico Cernera, Vincenzo Imperati, Massimo La Morgia, Alessandro Mei, Alberto Maria Mongardini, Eugenio Nerio Nemmi, Luciano Ortenzi, Francesco Sassi DOI: 10.1109/ACCESS.2025.3628124 Published: 2025-03-01 An evaluation of email tracking-pixel detection across 44,710 real-world emails from 620 sender domains and 22 commercial services, characterising how brittle deployed defenses are. import Callout from '../../components/ui/Callout.astro' import MetricRow from '../../components/ui/MetricRow.astro' import SpecPanel from '../../components/ui/SpecPanel.astro' Blocking tracking pixels in email is treated as a solved problem: mail clients and privacy tools ship with defenses on by default. This paper measures how well those defenses actually hold up against email as it is really sent. ## Research question How reliably do deployed tracking-pixel defenses detect tracking in real email, and what characteristics of a pixel cause them to fail? ## Method We assembled a corpus of real-world emails rather than synthetic samples, and evaluated detection across commercial tracking services and mail-side defenses, measuring not just whether a tracker was present, but whether each defense recognised it. ## Findings Detection is brittle in a specific way: defenses tend to recognise the canonical shapes of a tracking pixel and miss variants that are trivially different. Because the tracking services themselves vary in how they construct pixels, coverage differs sharply depending on which service sent the mail, which means a user's effective protection depends on who is emailing them, not on the tool they chose. A defense evaluated on synthetic inputs measures the defense. A defense evaluated on real traffic measures the gap between the threat model and the world, and the gap is where the interesting result lives. ## Engineering contribution The study needed a corpus that reflected real senders, and a harness that could run each defense against each message consistently. Building the collection and evaluation pipeline, with validation and reproducible runs, was the precondition for the measurement. I have written about [the wider acquisition system it belongs to](/posts/measurement-infrastructure) separately. ## Limitations The corpus covers the senders and services observed during the study window. Detection results describe the versions of the defenses evaluated at that time; both trackers and defenses change. --- # Posts ## [Post] Changing a specification is mostly a migration problem URL: https://vincenzo.imperati.dev/posts/changing-a-specification Tags: Nostr, Developer tools Published: 2026-07-28 The Nostr wiki format change was agreed before I touched it. What kept it from merging was that nobody had written the converter, which is a different kind of work from being right. A change to NIP-54, the Nostr wiki specification, is merged: articles are written in [Djot](https://djot.net/) instead of Asciidoc. I wrote that change. I did not have the idea, I did not win an argument, and I did not persuade anybody of anything. What I did was write the converter that made the question "and who migrates everything that already exists?" stop being a reason to wait. The gap between a change everyone agrees with and a change that ships is where most specification work actually lives, and it is not an intellectual gap. ## The decision had already been taken In October 2025, fiatjaf, who created the protocol, opened an issue titled *"wikis as djot: this time is different"*. The body is one line: *"I'm sorry for the AsciiDoc detour."* That is as settled as a design question gets in an open specification. The author of the protocol had changed his mind in public and said so. The issue was then closed with nothing merged. Not because anyone objected, but because agreeing to a format change and writing one are different tasks, and nobody had picked up the second. ## The review was one question I opened the migration PR, and Vitor Pamplona, who maintains one of the most used Nostr clients, asked the only question that mattered: > Has anybody coded the migration to Djot? Not "is Djot better". Not "do we agree". The specification could be rewritten in an afternoon; the wiki articles that existed in the old format could not. Every one of them lives on relays, published by people who are not reading this pull request and will not rewrite anything by hand. A format change without a migration path is a proposal to break other people's data and let them deal with it. That question is the whole review, and it is the correct review. ## The migration tool `asciidoc-to-djot` parses the Asciidoc AST with `@asciidoctor/core` and emits Djot through a custom converter backend, rather than trying to rewrite the text with regular expressions. That choice is most of why it works: the constructs that matter in a wiki article are structural: headings, links, and NIP-54's wikilink syntax. NIP-54 wikilinks in particular have to become Djot reference-style links, `[cryptocurrency][]` rather than `[[cryptocurrency]]`. A regex pass handles common constructs and silently corrupts the rest. The package exposes the converter as both a library and a CLI. I posted it on the pull request as the migration path the review had asked for. The specification change merged three weeks later. ## What the tool did and did not do It is worth being precise, because the flattering version of this story is not the true one. The converter does not appear in the merged diff: that diff is one file, the NIP itself, 63 lines added and 37 removed. The tool changed nothing about the specification. It changed what a reviewer had to assume about the consequences of merging it. ## The transferable part When an agreed change remains unmerged, the unresolved question is often who pays its migration cost. The useful contribution is not another argument for the change; it is the tool, compatibility layer, or transition plan that keeps existing users from absorbing that cost individually. The merged specification diff does not contain the converter, but the decision to merge depended on knowing the converter existed. That is a useful way to read specification work: the text names the new rule, while the migration determines whether the ecosystem can adopt it. --- ## [Post] A duplicate can be an observation URL: https://vincenzo.imperati.dev/posts/the-duplicate-is-the-data Tags: Data engineering, Distributed systems, Infrastructure Published: 2026-07-28 BigBrotr stores one Nostr event once and records every relay that served it, because repeated content is redundant while repeated observation is the measurement. The same record arriving twice is normally filed as a problem to remove on the way in. In a network observatory, the repeated content is redundant but the second observation is not. It says that another relay carried the event, and discarding it deletes part of the measurement. Deduplication is therefore not a cleanup step. It is a decision about which identity belongs in which table. ## When the protocol picks the key for you BigBrotr archives signed events from thousands of independent Nostr relays. The same event propagates: a note published once is served by every relay that accepted it, so the archive sees it forty times, from forty sources, at forty different moments. The identity question here is already settled, and not by me. A Nostr event's id *is* the SHA-256 hash of its canonical serialisation: the protocol content-addresses its own records. So the events table takes that hash as the primary key directly, as `BYTEA`, and inserts run `ON CONFLICT (id) DO NOTHING`. Storing the same event a second time is not an error to handle and not a reconciliation job to schedule later. It is a no-op. That is the easy half, and it is easy only because the protocol did the hard part. When the identity of a record is a property of the record rather than something you assign on arrival, deduplication stops being a pipeline stage. ## The half that is an actual decision Collapsing forty copies into one row would have destroyed the dataset, because *which relays carried this event* is most of what an observatory exists to know. Propagation, redundancy, and how much of the network any single relay can actually see are all questions about the copies. So the copies are kept, in a second table. `event_relay` holds `(event_id, relay_url, seen_at)` with the pair as its composite primary key and the timestamp of first observation, and it is written with the same `ON CONFLICT DO NOTHING`. One event row; forty junction rows. The duplicate is not discarded. It is reclassified, from a redundant copy of an event into an observation of a relay. Both tables are hash-partitioned sixteen ways on the event id, so the junction rows for an event land in the same partition as the event itself, and `content` and `tags` are LZ4-compressed because the text is the bulk of the archive. What this cost is the join, and a junction table that is far larger than the table it points at. What it bought is that "how many relays carried this?" and "which relay saw it first?" are queries rather than a re-collection. ## What to actually ask Before writing the deduplication step, the useful question is not "how do I detect duplicates" but **what does a second copy of this mean?** If it means nothing – the same fact restated by an interchangeable source – put the identity in the primary key and let the database reject it. If it means another observer saw it, the schema needs a second identity for the observation itself. Getting this wrong is expensive in a specific way. Both mistakes are silent, and both are discovered a year later, when someone asks a question the data can no longer answer. --- ## [Post] An index must preserve the specification's distinctions URL: https://vincenzo.imperati.dev/posts/the-same-bug-in-the-spec-and-the-storage Tags: Nostr, Distributed systems Published: 2026-07-28 A Nostr storage backend returned the wrong rows because its index kept tag values but discarded tag keys, collapsing a distinction required by the protocol. A specification can be implemented faithfully in the application layer and violated by one derived database column. The code still runs, the index still answers quickly, and the caller receives the wrong rows. ## The line A Nostr event carries tags: arrays whose first element is the tag name and whose remaining elements are its values. NIP-01 lets clients filter on them: give me events with this `e` tag, or that `p` tag. To make that fast, an implementation extracts the tag values into an indexed column. And there the representation choice appears: do you store the value, or do you store the value *with its tag key attached*? Store the bare value and the index cannot distinguish an `e` tag whose value is `X` from a `p` tag whose value is `X`. A filter on one matches the other. That is a merged pull request to `fiatjaf/eventstore`, the collection of storage backends maintained by the protocol's author: prefix tag values with their tag key, for correct NIP-01 filtering. It is a small diff. It is also the kind of bug that does not crash, does not appear in a log, and does not fail a test that nobody wrote. It just quietly returns the wrong rows to whoever asked. ## The same line, in my own system BigBrotr archives events from thousands of relays, and it has to answer the same filter questions over 250 million of them. Its events table carries a `tagvalues` column, computed at insert time and indexed with GIN precisely so those queries are cheap. Which means I made this decision twice: once as a fix in someone else's storage backend, and once as a schema choice in my own. The protocol does not decide it for you. It defines what a filter *means* and leaves every implementation to find a representation that preserves that meaning under indexing, and there is more than one way to get it wrong. The schema bug and the specification use different languages for the same invariant. The specification says filters distinguish tag keys; the index must therefore preserve the tag key in its representation. Once the invariant is stated that way, both the implementation and its tests have something precise to enforce. This is the useful boundary between specification and storage. A specification defines the distinctions an implementation must preserve. A storage design decides which of those distinctions remain representable after denormalisation, indexing, and compression. Performance work is correct only while that mapping remains intact. --- ## [Post] A failed crawl is not an empty result URL: https://vincenzo.imperati.dev/posts/measurement-infrastructure Tags: Measurement, Data engineering Published: 2025-08-13 A measurement pipeline has to preserve three different states: observed, absent, and unavailable. Collapsing them produces confident findings from data the collector never saw. A failed request and an empty response are different facts. If a measurement pipeline stores both as “zero records”, every analysis built on it can turn a collection failure into a finding about the world. That distinction shaped the infrastructure behind two studies: one followed links from more than 498 million Telegram messages into the open web; the other evaluated tracking-pixel detection on 44,710 real emails. The subjects differ, but both depend on knowing not only what was observed, but what could not be observed and why. ## Three states, not one For any source and collection window, there are at least three outcomes: 1. **Observed.** The source answered and the collector stored what it returned. 2. **Absent.** The source answered successfully and the requested record was not there. 3. **Unavailable.** The source timed out, rejected the request, changed shape, or returned something the collector could not interpret. Only the second supports an absence claim. The third supports a claim about collection coverage. Treating them as the same value is convenient at ingestion time and expensive at review time, when somebody asks whether a missing record means “not present” or “not seen”. Retries do not remove this problem. They change how often the unavailable state occurs. A source can refuse every retry, answer only part of a request, or return a successful response whose body is an interstitial rather than the requested content. The pipeline still needs a durable account of what happened. ## Raw observations and derived facts have different lifetimes The collector stores the response and its collection context before asking what the response means. Normalised records, classifications, resolved destinations, and aggregate counts are separate derived facts that retain a link to that observation. This costs storage and makes queries less direct. It also makes correction possible. A parser, normalisation rule, or classifier can be changed and rerun over existing observations. If the normalised row overwrites the raw one, the same correction requires collecting the source again, assuming the source still exists and will answer in the same way. That assumption is especially weak on the open web. Pages disappear, redirects change, domains expire, and platforms alter what an unauthenticated client can retrieve. Re-collection is not a neutral replay; it is a new measurement taken under different conditions. ## Checkpoints protect progress, not validity At this scale, interruption is ordinary. Collection is split into resumable units, and a committed checkpoint records which unit completed. That prevents a worker crash from restarting a run over hundreds of millions of records. A checkpoint proves less than it appears to. It can prove that the worker finished processing a unit; it cannot prove that the source returned everything expected inside that unit. Completeness needs its own evidence: boundary checks, response counts, explicit failure records, and coverage summaries that remain visible to the analysis. The useful operational question is therefore not only “where do I resume?” but “which gaps will a resume preserve?” A pipeline that advances its checkpoint after a partial response is reliable in the narrow sense that it keeps running, and unreliable in the sense that matters to a paper. ## Provenance is part of the result The USD 71 million figure in the Telegram study is the end of a chain: message, extracted URL, resolved destination, classified service, attributed monetisation mechanism, aggregate. Each edge has to remain traversable in the opposite direction. That does not make the result automatically correct. It makes it inspectable. A reviewer can move from the aggregate to the records that contributed to it; a changed classifier can identify which outputs it affects; an unavailable source remains unavailable rather than becoming a zero. The general rule is simple: preserve observations, derivations, and collection failures as different data. Storage is cheaper than recollection, and uncertainty recorded at ingestion is more useful than certainty reconstructed after publication. ---