<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Vincenzo Imperati: everything</title><description>Projects, research, and posts from Vincenzo Imperati.</description><link>https://vincenzo.imperati.dev/</link><language>en</language><item><title>[Post] Changing a specification is mostly a migration problem</title><link>https://vincenzo.imperati.dev/posts/changing-a-specification/</link><guid isPermaLink="true">https://vincenzo.imperati.dev/posts/changing-a-specification/</guid><description>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.</description><pubDate>Tue, 28 Jul 2026 00:00:00 GMT</pubDate><content:encoded>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 &quot;and who
migrates everything that already exists?&quot; 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
*&quot;wikis as djot: this time is different&quot;*. The body is one line: *&quot;I&apos;m sorry for
the AsciiDoc detour.&quot;* 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:

&gt; Has anybody coded the migration to Djot?

Not &quot;is Djot better&quot;. Not &quot;do we agree&quot;. 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&apos;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&apos;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.</content:encoded></item><item><title>[Post] A duplicate can be an observation</title><link>https://vincenzo.imperati.dev/posts/the-duplicate-is-the-data/</link><guid isPermaLink="true">https://vincenzo.imperati.dev/posts/the-duplicate-is-the-data/</guid><description>BigBrotr stores one Nostr event once and records every relay that served it, because repeated content is redundant while repeated observation is the measurement.</description><pubDate>Tue, 28 Jul 2026 00:00:00 GMT</pubDate><content:encoded>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&apos;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 &quot;how many relays carried this?&quot; and
&quot;which relay saw it first?&quot; are queries rather than a re-collection.

## What to actually ask

Before writing the deduplication step, the useful question is not &quot;how do I
detect duplicates&quot; 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.</content:encoded></item><item><title>[Post] An index must preserve the specification&apos;s distinctions</title><link>https://vincenzo.imperati.dev/posts/the-same-bug-in-the-spec-and-the-storage/</link><guid isPermaLink="true">https://vincenzo.imperati.dev/posts/the-same-bug-in-the-spec-and-the-storage/</guid><description>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.</description><pubDate>Tue, 28 Jul 2026 00:00:00 GMT</pubDate><content:encoded>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&apos;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&apos;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.</content:encoded></item><item><title>[Project] sitesift</title><link>https://vincenzo.imperati.dev/projects/sitesift/</link><guid isPermaLink="true">https://vincenzo.imperati.dev/projects/sitesift/</guid><description>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.</description><pubDate>Sat, 11 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import Callout from &apos;../../components/ui/Callout.astro&apos;
import RepoCard from &apos;../../components/ui/RepoCard.astro&apos;
import SpecPanel from &apos;../../components/ui/SpecPanel.astro&apos;

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.

&lt;SpecPanel
  items={[
    { label: &apos;Input&apos;, value: &apos;A list of URLs&apos; },
    { label: &apos;Output&apos;, value: &apos;Validated records with per-decision method and confidence&apos; },
    { label: &apos;Split&apos;, value: &apos;Deterministic evidence, then LLM judgment&apos; },
    { label: &apos;Role&apos;, value: &apos;Author&apos; },
    { label: &apos;Repository&apos;, value: &apos;VincenzoImp/sitesift&apos;, href: &apos;https://github.com/VincenzoImp/sitesift&apos; },
  ]}
/&gt;

&lt;RepoCard
  repo=&quot;VincenzoImp/sitesift&quot;
  description=&quot;Collect structured, validated metadata for URLs at scale: site type, topic, language, quality flags. Deterministic pipeline plus LLM judgment.&quot;
  language=&quot;Python&quot;
/&gt;

## 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&apos;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.

&lt;Callout type=&quot;insight&quot;&gt;
  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.
&lt;/Callout&gt;</content:encoded></item><item><title>[Project] academic-research-skills</title><link>https://vincenzo.imperati.dev/projects/academic-research-skills/</link><guid isPermaLink="true">https://vincenzo.imperati.dev/projects/academic-research-skills/</guid><description>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.</description><pubDate>Tue, 19 May 2026 00:00:00 GMT</pubDate><content:encoded>import Callout from &apos;../../components/ui/Callout.astro&apos;
import RepoCard from &apos;../../components/ui/RepoCard.astro&apos;
import SpecPanel from &apos;../../components/ui/SpecPanel.astro&apos;

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.

&lt;SpecPanel
  items={[
    { label: &apos;Contents&apos;, value: &apos;Eight skills, each a portable SKILL.md&apos; },
    { label: &apos;Companion&apos;, value: &apos;create-academic-research&apos;, href: &apos;/projects/create-academic-research&apos; },
    { label: &apos;Distribution&apos;, value: &apos;Installed project-locally by the scaffolder&apos; },
    { label: &apos;Repository&apos;, value: &apos;VincenzoImp/academic-research-skills&apos;, href: &apos;https://github.com/VincenzoImp/academic-research-skills&apos; },
  ]}
/&gt;

&lt;RepoCard
  repo=&quot;VincenzoImp/academic-research-skills&quot;
  description=&quot;Agent skills for academic research: digest, explore, survey, contribute, package, submit and review.&quot;
  language=&quot;Markdown&quot;
/&gt;

## 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&apos;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.

&lt;Callout type=&quot;insight&quot;&gt;
  The useful rule turned out not to be &quot;check the citations&quot; but &quot;make an unverifiable citation
  impossible to produce in the first place&quot;. Provenance recorded at digestion time is cheap; an
  audit after the fact is expensive and never complete.
&lt;/Callout&gt;</content:encoded></item><item><title>[Project] create-academic-research</title><link>https://vincenzo.imperati.dev/projects/create-academic-research/</link><guid isPermaLink="true">https://vincenzo.imperati.dev/projects/create-academic-research/</guid><description>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.</description><pubDate>Tue, 19 May 2026 00:00:00 GMT</pubDate><content:encoded>import Callout from &apos;../../components/ui/Callout.astro&apos;
import RepoCard from &apos;../../components/ui/RepoCard.astro&apos;
import SpecPanel from &apos;../../components/ui/SpecPanel.astro&apos;

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.

&lt;SpecPanel
  items={[
    { label: &apos;Shape&apos;, value: &apos;npm creator, scaffold only, no other commands&apos; },
    { label: &apos;Model&apos;, value: &apos;Four entities: SOTA, survey, contributions, papers&apos; },
    { label: &apos;Companion&apos;, value: &apos;academic-research-skills&apos;, href: &apos;/projects/academic-research-skills&apos; },
    { label: &apos;Repository&apos;, value: &apos;VincenzoImp/create-academic-research&apos;, href: &apos;https://github.com/VincenzoImp/create-academic-research&apos; },
  ]}
/&gt;

&lt;RepoCard
  repo=&quot;VincenzoImp/create-academic-research&quot;
  description=&quot;Scaffold agent-ready academic research repositories: SOTA, survey, contributions and per-venue paper submissions.&quot;
  language=&quot;JavaScript&quot;
/&gt;

## 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&apos;s template, artifacts packaged to that venue&apos;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.

&lt;Callout type=&quot;insight&quot;&gt;
  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.
&lt;/Callout&gt;</content:encoded></item><item><title>[Project] SkillGuard</title><link>https://vincenzo.imperati.dev/projects/skillguard/</link><guid isPermaLink="true">https://vincenzo.imperati.dev/projects/skillguard/</guid><description>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.</description><pubDate>Fri, 08 May 2026 00:00:00 GMT</pubDate><content:encoded>import RepoCard from &apos;../../components/ui/RepoCard.astro&apos;
import SpecPanel from &apos;../../components/ui/SpecPanel.astro&apos;

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&apos;s owner approves or rejects it.

&lt;SpecPanel
  items={[
    { label: &apos;Shape&apos;, value: &apos;Signed manifest → policy → human inbox → on-chain receipt&apos; },
    { label: &apos;On-chain&apos;, value: &apos;Anchor program on Solana devnet, hashes only&apos; },
    { label: &apos;Approval&apos;, value: &apos;Android app over Mobile Wallet Adapter&apos; },
    { label: &apos;Built in&apos;, value: &apos;Three days, tagged v0.1.0-devnet&apos; },
    { label: &apos;Repository&apos;, value: &apos;VincenzoImp/skillguard&apos;, href: &apos;https://github.com/VincenzoImp/skillguard&apos; },
  ]}
/&gt;

&lt;RepoCard
  repo=&quot;VincenzoImp/skillguard&quot;
  description=&quot;Approval and policy layer between AI agents and a Solana wallet, with on-chain decision receipts.&quot;
  language=&quot;TypeScript&quot;
/&gt;

## 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&apos;s hash, the policy result&apos;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.</content:encoded></item><item><title>[Project] nSealr</title><link>https://vincenzo.imperati.dev/projects/nsealr/</link><guid isPermaLink="true">https://vincenzo.imperati.dev/projects/nsealr/</guid><description>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.</description><pubDate>Wed, 06 May 2026 00:00:00 GMT</pubDate><content:encoded>import Callout from &apos;../../components/ui/Callout.astro&apos;
import MetricRow from &apos;../../components/ui/MetricRow.astro&apos;
import RepoCard from &apos;../../components/ui/RepoCard.astro&apos;
import SpecPanel from &apos;../../components/ui/SpecPanel.astro&apos;

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.

&lt;SpecPanel
  items={[
    { label: &apos;Products&apos;, value: &apos;Vault (Pi and ESP32) · Key · Card · One&apos; },
    { label: &apos;Contract&apos;, value: &apos;220 vectors · 11 protocol documents · 11 schemas&apos; },
    { label: &apos;Signing boundary&apos;, value: &apos;Pi vault signs; ESP32 enforces signing_disabled&apos; },
    { label: &apos;Licences&apos;, value: &apos;CC0 specs · MIT code · CERN-OHL-P hardware&apos; },
    { label: &apos;Repository&apos;, value: &apos;nSealr/specs&apos;, href: &apos;https://github.com/nSealr/specs&apos; },
  ]}
/&gt;

&lt;RepoCard
  repo=&quot;nSealr/specs&quot;
  description=&quot;Shared nSealr protocol contracts, schemas, QR/review contracts, approval digests, and NIP-01/BIP-340 conformance vectors.&quot;
  language=&quot;Python&quot;
/&gt;

## 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&apos;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.

&lt;MetricRow
  items={[
    { value: &apos;220&apos;, label: &apos;conformance vectors&apos; },
    { value: &apos;107&apos;, label: &apos;of them refusals&apos; },
  ]}
/&gt;

The answer to the screen problem is the **approval digest**. It is a SHA-256 over the request&apos;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.

&lt;Callout type=&quot;insight&quot;&gt;
  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.
&lt;/Callout&gt;</content:encoded></item><item><title>[Project] Proxmox Desk Display</title><link>https://vincenzo.imperati.dev/projects/proxmox-desk-display/</link><guid isPermaLink="true">https://vincenzo.imperati.dev/projects/proxmox-desk-display/</guid><description>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.</description><pubDate>Sun, 03 May 2026 00:00:00 GMT</pubDate><content:encoded>import Callout from &apos;../../components/ui/Callout.astro&apos;
import RepoCard from &apos;../../components/ui/RepoCard.astro&apos;
import SpecPanel from &apos;../../components/ui/SpecPanel.astro&apos;

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.

&lt;SpecPanel
  items={[
    { label: &apos;Bridge&apos;, value: &apos;Go, polls Proxmox and serves a versioned JSON schema&apos; },
    { label: &apos;Device&apos;, value: &apos;LILYGO T-Display-S3, two buttons, 19 screens&apos; },
    { label: &apos;Contract&apos;, value: &apos;proxmox-desk-display.v1 over bearer-authenticated HTTP&apos; },
    { label: &apos;Repository&apos;, value: &apos;VincenzoImp/proxmox-desk-display&apos;, href: &apos;https://github.com/VincenzoImp/proxmox-desk-display&apos; },
  ]}
/&gt;

&lt;RepoCard
  repo=&quot;VincenzoImp/proxmox-desk-display&quot;
  description=&quot;Physical Proxmox homelab status display: a Go bridge and ESP32 firmware sharing one protocol.&quot;
  language=&quot;Go&quot;
/&gt;

## The problem

The obvious build is to have the microcontroller call the Proxmox API directly. The repository&apos;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&apos;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&apos;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.

&lt;Callout type=&quot;note&quot;&gt;
  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.
&lt;/Callout&gt;</content:encoded></item><item><title>[Project] Encrypted Local Tally Integrity</title><link>https://vincenzo.imperati.dev/projects/encrypted-local-tally-integrity/</link><guid isPermaLink="true">https://vincenzo.imperati.dev/projects/encrypted-local-tally-integrity/</guid><description>A protocol and reproducible artifact for federated elections in which each authority publishes an encrypted local tally and the aggregation stays verifiable on-chain.</description><pubDate>Sat, 02 May 2026 00:00:00 GMT</pubDate><content:encoded>import RepoCard from &apos;../../components/ui/RepoCard.astro&apos;
import SpecPanel from &apos;../../components/ui/SpecPanel.astro&apos;

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.

&lt;SpecPanel
  items={[
    { label: &apos;Origin&apos;, value: &apos;Cryptography track, De Cifris × XRPL Commons hackathon 2024&apos; },
    { label: &apos;Role&apos;, value: &apos;First author, with Lorenzo Camilli and Raffaele Ruggeri&apos; },
    { label: &apos;Stack&apos;, value: &apos;Solidity · Circom · Groth16 · ElGamal&apos; },
    { label: &apos;Repository&apos;, value: &apos;VincenzoImp/encrypted-local-tally-integrity&apos;, href: &apos;https://github.com/VincenzoImp/encrypted-local-tally-integrity&apos; },
  ]}
/&gt;

&lt;RepoCard
  repo=&quot;VincenzoImp/encrypted-local-tally-integrity&quot;
  description=&quot;Manuscript and reproducible artifact for federated elections with encrypted local tallies and verifiable on-chain aggregation.&quot;
  language=&quot;TeX&quot;
/&gt;

The manuscript is submitted to Koine and has not been peer reviewed. The artifact establishes the
protocol&apos;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&apos;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&apos;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&apos;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 &quot;verifiable&quot;.** 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.</content:encoded></item><item><title>[Project] Entangle</title><link>https://vincenzo.imperati.dev/projects/entangle/</link><guid isPermaLink="true">https://vincenzo.imperati.dev/projects/entangle/</guid><description>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.</description><pubDate>Wed, 22 Apr 2026 00:00:00 GMT</pubDate><content:encoded>import Callout from &apos;../../components/ui/Callout.astro&apos;
import RepoCard from &apos;../../components/ui/RepoCard.astro&apos;
import SpecPanel from &apos;../../components/ui/SpecPanel.astro&apos;

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.

&lt;SpecPanel
  items={[
    { label: &apos;Shape&apos;, value: &apos;Self-hosted federated runtime, TypeScript monorepo&apos; },
    { label: &apos;Coordination&apos;, value: &apos;Signed Nostr events&apos; },
    { label: &apos;Artifacts&apos;, value: &apos;Git-backed, referenced rather than embedded&apos; },
    { label: &apos;Role&apos;, value: &apos;Author&apos; },
    { label: &apos;Repository&apos;, value: &apos;entangle-run/entangle&apos;, href: &apos;https://github.com/entangle-run/entangle&apos; },
  ]}
/&gt;

&lt;RepoCard
  repo=&quot;entangle-run/entangle&quot;
  description=&quot;Self-hosted federated runtime for observable coding-agent organizations: signed graph nodes, distributed runners, Nostr coordination, and git-backed artifacts.&quot;
  language=&quot;TypeScript&quot;
/&gt;

## 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.

&lt;Callout type=&quot;insight&quot;&gt;
  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 &quot;AI system&quot; back into an ordinary distributed
  system, with an ordinary test suite and an ordinary CI bill.
&lt;/Callout&gt;</content:encoded></item><item><title>[Project] nsec leak checker</title><link>https://vincenzo.imperati.dev/projects/nsec-leak-checker/</link><guid isPermaLink="true">https://vincenzo.imperati.dev/projects/nsec-leak-checker/</guid><description>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.</description><pubDate>Tue, 17 Mar 2026 00:00:00 GMT</pubDate><content:encoded>import Callout from &apos;../../components/ui/Callout.astro&apos;
import MetricRow from &apos;../../components/ui/MetricRow.astro&apos;
import RepoCard from &apos;../../components/ui/RepoCard.astro&apos;
import SpecPanel from &apos;../../components/ui/SpecPanel.astro&apos;

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.

&lt;SpecPanel
  items={[
    { label: &apos;Shape&apos;, value: &apos;NIP-90 data-vending machine, kinds 5300 and 6300&apos; },
    { label: &apos;Authentication&apos;, value: &apos;The request signature is the proof of ownership&apos; },
    { label: &apos;Answer&apos;, value: &apos;NIP-44 encrypted to the requester alone&apos; },
    { label: &apos;Dataset&apos;, value: &apos;40M events from 1,079 relays, never committed&apos; },
    { label: &apos;Repository&apos;, value: &apos;BigBrotr/nsec-leak-checker&apos;, href: &apos;https://github.com/BigBrotr/nsec-leak-checker&apos; },
  ]}
/&gt;

&lt;RepoCard
  repo=&quot;BigBrotr/nsec-leak-checker&quot;
  description=&quot;A Nostr DVM that checks whether your private key has been exposed in public events.&quot;
  language=&quot;Python&quot;
/&gt;

## 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.

&lt;MetricRow
  items={[
    { value: &apos;16,000+&apos;, label: &apos;exposed keys found&apos; },
    { value: &apos;40M&apos;, label: &apos;events searched&apos; },
  ]}
/&gt;

## 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.

&lt;Callout type=&quot;warning&quot;&gt;
  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.
&lt;/Callout&gt;</content:encoded></item><item><title>[Project] nostr-linkr</title><link>https://vincenzo.imperati.dev/projects/nostr-linkr/</link><guid isPermaLink="true">https://vincenzo.imperati.dev/projects/nostr-linkr/</guid><description>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.</description><pubDate>Tue, 10 Mar 2026 00:00:00 GMT</pubDate><content:encoded>import RepoCard from &apos;../../components/ui/RepoCard.astro&apos;
import SpecPanel from &apos;../../components/ui/SpecPanel.astro&apos;

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.

&lt;SpecPanel
  items={[
    { label: &apos;Parts&apos;, value: &apos;Contract · SDK · example client · NIP proposal&apos; },
    { label: &apos;Proposal&apos;, value: &apos;NIP-XX, On-Chain EVM Identity Linking (open)&apos;, href: &apos;https://github.com/nostr-protocol/nips/pull/2262&apos; },
    { label: &apos;Role&apos;, value: &apos;Author&apos; },
    { label: &apos;Repository&apos;, value: &apos;VincenzoImp/nostr-linkr&apos;, href: &apos;https://github.com/VincenzoImp/nostr-linkr&apos; },
  ]}
/&gt;

&lt;RepoCard
  repo=&quot;VincenzoImp/nostr-linkr&quot;
  description=&quot;On-chain Ethereum↔Nostr identity bridge with BIP-340 Schnorr verification: smart contract, TypeScript SDK, and example client.&quot;
  language=&quot;TypeScript&quot;
/&gt;

## The problem

Nostr already has two ways to say &quot;this account is also me somewhere else&quot;, 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&apos;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&apos;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&apos;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&apos;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&apos;s contents. The contract
serialises the fields per NIP-01 and derives the id itself, and only compares afterwards.</content:encoded></item><item><title>[Project] VendTON</title><link>https://vincenzo.imperati.dev/projects/vendton/</link><guid isPermaLink="true">https://vincenzo.imperati.dev/projects/vendton/</guid><description>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.</description><pubDate>Mon, 09 Mar 2026 00:00:00 GMT</pubDate><content:encoded>import RepoCard from &apos;../../components/ui/RepoCard.astro&apos;
import SpecPanel from &apos;../../components/ui/SpecPanel.astro&apos;

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.

&lt;SpecPanel
  items={[
    { label: &apos;Event&apos;, value: &apos;BSA EPFL Hackathon 2026 · AlphaTON Capital + ENS track&apos; },
    { label: &apos;Payments&apos;, value: &apos;x402 over TON, settled in USDT&apos; },
    { label: &apos;Naming&apos;, value: &apos;ENS, one subdomain per endpoint&apos; },
    { label: &apos;Role&apos;, value: &apos;Team member&apos; },
    { label: &apos;Repository&apos;, value: &apos;VincenzoImp/vendton&apos;, href: &apos;https://github.com/VincenzoImp/vendton&apos; },
  ]}
/&gt;

&lt;RepoCard
  repo=&quot;VincenzoImp/vendton&quot;
  description=&quot;Paid APIs on TON, powered by AI and your wallet. Deploy DVMs, earn USDT, ask AI anything.&quot;
  language=&quot;TypeScript&quot;
/&gt;

## 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&apos;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.</content:encoded></item><item><title>[Project] asciidoc-to-djot</title><link>https://vincenzo.imperati.dev/projects/asciidoc-to-djot/</link><guid isPermaLink="true">https://vincenzo.imperati.dev/projects/asciidoc-to-djot/</guid><description>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.</description><pubDate>Sat, 28 Feb 2026 00:00:00 GMT</pubDate><content:encoded>import Callout from &apos;../../components/ui/Callout.astro&apos;
import RepoCard from &apos;../../components/ui/RepoCard.astro&apos;
import SpecPanel from &apos;../../components/ui/SpecPanel.astro&apos;

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.

&lt;SpecPanel
  items={[
    { label: &apos;Shape&apos;, value: &apos;npm package, library and CLI&apos; },
    { label: &apos;Method&apos;, value: &apos;Asciidoc AST → custom Djot converter backend&apos; },
    { label: &apos;Role&apos;, value: &apos;Author&apos; },
    { label: &apos;Repository&apos;, value: &apos;VincenzoImp/asciidoc-to-djot&apos;, href: &apos;https://github.com/VincenzoImp/asciidoc-to-djot&apos; },
  ]}
/&gt;

&lt;RepoCard
  repo=&quot;VincenzoImp/asciidoc-to-djot&quot;
  description=&quot;Convert NIP-54 wiki articles from Asciidoc to Djot format.&quot;
  language=&quot;TypeScript&quot;
/&gt;

## 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.

&lt;Callout type=&quot;note&quot;&gt;
  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).
&lt;/Callout&gt;</content:encoded></item><item><title>[Project] nopayloaddb</title><link>https://vincenzo.imperati.dev/projects/nopayloaddb/</link><guid isPermaLink="true">https://vincenzo.imperati.dev/projects/nopayloaddb/</guid><description>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.</description><pubDate>Mon, 23 Feb 2026 00:00:00 GMT</pubDate><content:encoded>import Callout from &apos;../../components/ui/Callout.astro&apos;
import RepoCard from &apos;../../components/ui/RepoCard.astro&apos;
import SpecPanel from &apos;../../components/ui/SpecPanel.astro&apos;

`nopayloaddb` is the HEP Software Foundation&apos;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?

&lt;SpecPanel
  items={[
    { label: &apos;Project&apos;, value: &apos;HEP Software Foundation reference conditions database&apos; },
    { label: &apos;My role&apos;, value: &apos;External contributor, 1 merged, 7 open&apos; },
    { label: &apos;Merged&apos;, value: &apos;Fix comb_iov calculation in PayloadIOVAttachAPIView&apos; },
    { label: &apos;Shipped in&apos;, value: &apos;v5.0.0 and v5.1.0&apos; },
    { label: &apos;Repository&apos;, value: &apos;BNLNPPS/nopayloaddb&apos;, href: &apos;https://github.com/BNLNPPS/nopayloaddb&apos; },
  ]}
/&gt;

&lt;RepoCard
  repo=&quot;BNLNPPS/nopayloaddb&quot;
  description=&quot;HEP Software Foundation reference conditions database. Django REST Framework over PostgreSQL.&quot;
  language=&quot;Python&quot;
/&gt;

## 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 &quot;which payload applies at (major, minor)?&quot; 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&apos;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&apos;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.

&lt;Callout type=&quot;warning&quot;&gt;
  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.
&lt;/Callout&gt;</content:encoded></item><item><title>[Project] GSoC 2026 Explorer</title><link>https://vincenzo.imperati.dev/projects/gsoc-2026-explorer/</link><guid isPermaLink="true">https://vincenzo.imperati.dev/projects/gsoc-2026-explorer/</guid><description>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.</description><pubDate>Fri, 20 Feb 2026 00:00:00 GMT</pubDate><content:encoded>import Callout from &apos;../../components/ui/Callout.astro&apos;
import MetricRow from &apos;../../components/ui/MetricRow.astro&apos;
import RepoCard from &apos;../../components/ui/RepoCard.astro&apos;
import SpecPanel from &apos;../../components/ui/SpecPanel.astro&apos;

Choosing where to apply for Google Summer of Code means reading 184 organizations&apos; 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.

&lt;SpecPanel
  items={[
    { label: &apos;Coverage&apos;, value: &apos;184 organizations, 180 with ideas content&apos; },
    { label: &apos;Corpus&apos;, value: &apos;574 sub-pages · 940 Markdown files&apos; },
    { label: &apos;Site&apos;, value: &apos;Statically exported, client-side fuzzy search&apos; },
    { label: &apos;Repository&apos;, value: &apos;VincenzoImp/gsoc-2026-explorer&apos;, href: &apos;https://github.com/VincenzoImp/gsoc-2026-explorer&apos; },
  ]}
/&gt;

&lt;RepoCard
  repo=&quot;VincenzoImp/gsoc-2026-explorer&quot;
  description=&quot;Every GSoC 2026 organization and its project ideas, searchable, with the dataset committed as JSON and Markdown.&quot;
  language=&quot;TypeScript&quot;
/&gt;

## 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.

&lt;MetricRow
  items={[
    { value: &apos;184&apos;, label: &apos;organizations&apos; },
    { value: &apos;574&apos;, label: &apos;sub-pages crawled&apos; },
  ]}
/&gt;

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, &quot;verify you are human&quot;, 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.

&lt;Callout type=&quot;warning&quot;&gt;
  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.
&lt;/Callout&gt;</content:encoded></item><item><title>[Project] Chain Lens</title><link>https://vincenzo.imperati.dev/projects/chain-lens/</link><guid isPermaLink="true">https://vincenzo.imperati.dev/projects/chain-lens/</guid><description>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.</description><pubDate>Mon, 16 Feb 2026 00:00:00 GMT</pubDate><content:encoded>import Callout from &apos;../../components/ui/Callout.astro&apos;
import RepoCard from &apos;../../components/ui/RepoCard.astro&apos;
import SpecPanel from &apos;../../components/ui/SpecPanel.astro&apos;

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.

&lt;SpecPanel
  items={[
    { label: &apos;Context&apos;, value: &apos;Timed developer challenge, with an automated grader&apos; },
    { label: &apos;Surfaces&apos;, value: &apos;CLI (JSON) and web visualiser&apos; },
    { label: &apos;Inputs&apos;, value: &apos;Raw transactions and Bitcoin Core blk*.dat / rev*.dat&apos; },
    { label: &apos;Role&apos;, value: &apos;Author&apos; },
    { label: &apos;Repository&apos;, value: &apos;VincenzoImp/2026-developer-challenge-1-chain-lens&apos;, href: &apos;https://github.com/VincenzoImp/2026-developer-challenge-1-chain-lens&apos; },
  ]}
/&gt;

&lt;RepoCard
  repo=&quot;VincenzoImp/2026-developer-challenge-1-chain-lens&quot;
  description=&quot;Bitcoin transaction and block analyzer: CLI and web visualiser over one analysis engine.&quot;
  language=&quot;TypeScript&quot;
/&gt;

## 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&apos;s weight accounting, Taproot&apos;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&apos;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&apos;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.

&lt;Callout type=&quot;warning&quot;&gt;
  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.
&lt;/Callout&gt;</content:encoded></item><item><title>[Project] OmniCredit</title><link>https://vincenzo.imperati.dev/projects/omnicredit/</link><guid isPermaLink="true">https://vincenzo.imperati.dev/projects/omnicredit/</guid><description>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.</description><pubDate>Sat, 22 Nov 2025 00:00:00 GMT</pubDate><content:encoded>import RepoCard from &apos;../../components/ui/RepoCard.astro&apos;
import SpecPanel from &apos;../../components/ui/SpecPanel.astro&apos;

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.

&lt;SpecPanel
  items={[
    { label: &apos;Role&apos;, value: &apos;Team member&apos; },
    { label: &apos;Messaging&apos;, value: &apos;LayerZero V2&apos; },
    { label: &apos;Prices&apos;, value: &apos;Pyth Network&apos; },
    { label: &apos;Repository&apos;, value: &apos;VincenzoImp/omnicredit&apos;, href: &apos;https://github.com/VincenzoImp/omnicredit&apos; },
  ]}
/&gt;

&lt;RepoCard
  repo=&quot;VincenzoImp/omnicredit&quot;
  description=&quot;Omnichain lending protocol with continuous credit scoring.&quot;
  language=&quot;Solidity&quot;
/&gt;

## 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&apos;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.</content:encoded></item><item><title>[Project] DotFusion</title><link>https://vincenzo.imperati.dev/projects/dot-fusion/</link><guid isPermaLink="true">https://vincenzo.imperati.dev/projects/dot-fusion/</guid><description>Trustless ETH↔DOT atomic swaps between Ethereum and Polkadot using hash time-locked contracts, with Polkadot&apos;s XCM precompile propagating the secret across chains automatically.</description><pubDate>Sat, 18 Oct 2025 00:00:00 GMT</pubDate><content:encoded>import RepoCard from &apos;../../components/ui/RepoCard.astro&apos;
import SpecPanel from &apos;../../components/ui/SpecPanel.astro&apos;

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.

&lt;SpecPanel
  items={[
    { label: &apos;Event&apos;, value: &apos;ETHRome 2025 (48h)&apos; },
    { label: &apos;Result&apos;, value: &apos;1st 1inch · 1st Polkadot · 1st BuidlGuidl · ENS honorable mention&apos; },
    { label: &apos;Role&apos;, value: &apos;Team lead&apos; },
    { label: &apos;Networks&apos;, value: &apos;Ethereum Sepolia · Polkadot Paseo Asset Hub&apos; },
    { label: &apos;Repository&apos;, value: &apos;VincenzoImp/dot-fusion&apos;, href: &apos;https://github.com/VincenzoImp/dot-fusion&apos; },
  ]}
/&gt;

&lt;RepoCard
  repo=&quot;VincenzoImp/dot-fusion&quot;
  description=&quot;Trustless, secure, and fast token exchanges between Ethereum and Polkadot ecosystems.&quot;
  language=&quot;TypeScript&quot;
/&gt;

## 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&apos;s problem, or a service&apos;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&apos;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 &quot;the swap is done&quot; 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.</content:encoded></item><item><title>[Project] Bitcoin Full Node in Docker</title><link>https://vincenzo.imperati.dev/projects/bitcoin-fullnode-docker/</link><guid isPermaLink="true">https://vincenzo.imperati.dev/projects/bitcoin-fullnode-docker/</guid><description>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.</description><pubDate>Wed, 08 Oct 2025 00:00:00 GMT</pubDate><content:encoded>import Callout from &apos;../../components/ui/Callout.astro&apos;
import RepoCard from &apos;../../components/ui/RepoCard.astro&apos;
import SpecPanel from &apos;../../components/ui/SpecPanel.astro&apos;

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.

&lt;SpecPanel
  items={[
    { label: &apos;Stack&apos;, value: &apos;Bitcoin Core v27 · Tor · Mempool · BTC RPC Explorer&apos; },
    { label: &apos;Networking&apos;, value: &apos;onlynet=onion, no clearnet peers&apos; },
    { label: &apos;Index&apos;, value: &apos;txindex=1, prune=0 · roughly 600 GB&apos; },
    { label: &apos;Repository&apos;, value: &apos;VincenzoImp/bitcoin-fullnode-docker&apos;, href: &apos;https://github.com/VincenzoImp/bitcoin-fullnode-docker&apos; },
  ]}
/&gt;

&lt;RepoCard
  repo=&quot;VincenzoImp/bitcoin-fullnode-docker&quot;
  description=&quot;Docker setup for a private Bitcoin full node with Tor, a Mempool dashboard and a Python RPC client.&quot;
  language=&quot;Python&quot;
/&gt;

## The problem

Analysis over Bitcoin usually starts by calling somebody&apos;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&apos;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&apos;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&apos;s availability depend on
Tor&apos;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.

&lt;Callout type=&quot;warning&quot;&gt;
  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.
&lt;/Callout&gt;</content:encoded></item><item><title>[Project] Job Search Tool</title><link>https://vincenzo.imperati.dev/projects/job-search-tool/</link><guid isPermaLink="true">https://vincenzo.imperati.dev/projects/job-search-tool/</guid><description>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.</description><pubDate>Tue, 07 Oct 2025 00:00:00 GMT</pubDate><content:encoded>import Callout from &apos;../../components/ui/Callout.astro&apos;
import RepoCard from &apos;../../components/ui/RepoCard.astro&apos;
import SpecPanel from &apos;../../components/ui/SpecPanel.astro&apos;

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.

&lt;SpecPanel
  items={[
    { label: &apos;Shape&apos;, value: &apos;A scheduler and one ASGI application&apos; },
    { label: &apos;Surfaces&apos;, value: &apos;React dashboard · REST API · MCP tools&apos; },
    { label: &apos;Storage&apos;, value: &apos;SQLite, plus a local ChromaDB index for similarity&apos; },
    { label: &apos;Repository&apos;, value: &apos;VincenzoImp/job-search-tool&apos;, href: &apos;https://github.com/VincenzoImp/job-search-tool&apos; },
  ]}
/&gt;

&lt;RepoCard
  repo=&quot;VincenzoImp/job-search-tool&quot;
  description=&quot;Local-first job search: multi-board scraping, configurable scoring, a dashboard, a REST API and MCP tools over one store.&quot;
  language=&quot;Python&quot;
/&gt;

## 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&apos;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.

&lt;Callout type=&quot;insight&quot;&gt;
  The value of one application layer is not that it saves code: it is that it makes &quot;does the
  agent see what the dashboard sees?&quot; a question with a structural answer instead of an empirical
  one.
&lt;/Callout&gt;</content:encoded></item><item><title>[Research] The Conspiracy Money Machine: Uncovering Telegram&apos;s Conspiracy Channels and their Profit Model</title><link>https://vincenzo.imperati.dev/research/conspiracy-money-machine/</link><guid isPermaLink="true">https://vincenzo.imperati.dev/research/conspiracy-money-machine/</guid><description>A Telegram-to-web measurement pipeline over 120K+ channels and 498M+ messages that traces how conspiracy communities are monetized, quantifying a USD 71M ecosystem.</description><pubDate>Wed, 13 Aug 2025 00:00:00 GMT</pubDate><content:encoded>import Callout from &apos;../../components/ui/Callout.astro&apos;
import MetricRow from &apos;../../components/ui/MetricRow.astro&apos;
import SpecPanel from &apos;../../components/ui/SpecPanel.astro&apos;

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.

&lt;SpecPanel
  items={[
    { label: &apos;Venue&apos;, value: &apos;USENIX Security 2025&apos; },
    { label: &apos;Type&apos;, value: &apos;Conference paper (peer-reviewed)&apos; },
    { label: &apos;Status&apos;, value: &apos;Published&apos; },
    { label: &apos;Role&apos;, value: &apos;Measurement pipeline, data engineering, analysis&apos; },
  ]}
/&gt;

## 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.

&lt;MetricRow
  items={[
    { value: &apos;120K+&apos;, label: &apos;channels&apos; },
    { value: &apos;498M+&apos;, label: &apos;messages&apos; },
    { value: &apos;205M&apos;, label: &apos;URLs extracted&apos; },
    { value: &apos;$71M&apos;, label: &apos;monetization traced&apos; },
  ]}
/&gt;

## 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.

&lt;Callout type=&quot;insight&quot;&gt;
  The interesting engineering problem was provenance, not scale. Keeping every derived number
  traceable to its source message is what makes a claim like &quot;$71M&quot; defensible instead of
  merely large.
&lt;/Callout&gt;

## 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.</content:encoded></item><item><title>[Post] A failed crawl is not an empty result</title><link>https://vincenzo.imperati.dev/posts/measurement-infrastructure/</link><guid isPermaLink="true">https://vincenzo.imperati.dev/posts/measurement-infrastructure/</guid><description>A measurement pipeline has to preserve three different states: observed, absent, and unavailable. Collapsing them produces confident findings from data the collector never saw.</description><pubDate>Wed, 13 Aug 2025 00:00:00 GMT</pubDate><content:encoded>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.</content:encoded></item><item><title>[Project] Relay Shadow</title><link>https://vincenzo.imperati.dev/projects/relay-shadow/</link><guid isPermaLink="true">https://vincenzo.imperati.dev/projects/relay-shadow/</guid><description>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.</description><pubDate>Thu, 07 Aug 2025 00:00:00 GMT</pubDate><content:encoded>import RepoCard from &apos;../../components/ui/RepoCard.astro&apos;
import SpecPanel from &apos;../../components/ui/SpecPanel.astro&apos;

Relay Shadow answers &quot;which relays should I use?&quot;, 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.

&lt;SpecPanel
  items={[
    { label: &apos;Built for&apos;, value: &apos;Bitcoin++ Privacy Edition hackathon, 2025&apos; },
    { label: &apos;Result&apos;, value: &apos;3rd prize, Baltic Honeybadger 2025&apos; },
    { label: &apos;Interface&apos;, value: &apos;NIP-90 DVM, kinds 5600 / 5601 / 7000&apos; },
    { label: &apos;Data&apos;, value: &apos;BigBrotr archive, in PostgreSQL&apos;, href: &apos;/projects/bigbrotr&apos; },
    { label: &apos;Repository&apos;, value: &apos;BigBrotr/relay-shadow-dvm&apos;, href: &apos;https://github.com/BigBrotr/relay-shadow-dvm&apos; },
  ]}
/&gt;

&lt;RepoCard
  repo=&quot;BigBrotr/relay-shadow-dvm&quot;
  description=&quot;Data Vending Machine providing privacy-focused relay recommendations using BigBrotr data.&quot;
  language=&quot;JavaScript&quot;
/&gt;

## 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&apos;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.</content:encoded></item><item><title>[Project] nostr-tools</title><link>https://vincenzo.imperati.dev/projects/nostr-tools/</link><guid isPermaLink="true">https://vincenzo.imperati.dev/projects/nostr-tools/</guid><description>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.</description><pubDate>Mon, 04 Aug 2025 00:00:00 GMT</pubDate><content:encoded>import Callout from &apos;../../components/ui/Callout.astro&apos;
import RepoCard from &apos;../../components/ui/RepoCard.astro&apos;
import SpecPanel from &apos;../../components/ui/SpecPanel.astro&apos;

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.

&lt;SpecPanel
  items={[
    { label: &apos;Implements&apos;, value: &apos;NIP-01 · NIP-11 · NIP-66 · BIP-340 · bech32&apos; },
    { label: &apos;Probes&apos;, value: &apos;Openable, readable, writable, each with its own timing&apos; },
    { label: &apos;Compatibility&apos;, value: &apos;Python 3.9–3.13&apos; },
    { label: &apos;Repository&apos;, value: &apos;BigBrotr/nostr-tools&apos;, href: &apos;https://github.com/BigBrotr/nostr-tools&apos; },
  ]}
/&gt;

&lt;RepoCard
  repo=&quot;BigBrotr/nostr-tools&quot;
  description=&quot;An async Python library for Nostr events, filters, subscriptions, signing, and relay capability probing.&quot;
  language=&quot;Python&quot;
/&gt;

## The problem

A relay&apos;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&apos;s own URL, so repeatedly measuring the same
relay updates one record rather than accumulating a trail in somebody else&apos;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 &quot;not readable&quot; 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.

&lt;Callout type=&quot;insight&quot;&gt;
  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.
&lt;/Callout&gt;</content:encoded></item><item><title>[Project] Nostreum</title><link>https://vincenzo.imperati.dev/projects/nostreum/</link><guid isPermaLink="true">https://vincenzo.imperati.dev/projects/nostreum/</guid><description>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.</description><pubDate>Tue, 15 Jul 2025 00:00:00 GMT</pubDate><content:encoded>import RepoCard from &apos;../../components/ui/RepoCard.astro&apos;
import SpecPanel from &apos;../../components/ui/SpecPanel.astro&apos;

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.

&lt;SpecPanel
  items={[
    { label: &apos;Event&apos;, value: &apos;NapulETH 2025&apos; },
    { label: &apos;Result&apos;, value: &apos;Three 1st prizes and one 3rd prize&apos; },
    { label: &apos;Role&apos;, value: &apos;Team lead&apos; },
    { label: &apos;Identity layer&apos;, value: &apos;nostr-linkr&apos;, href: &apos;/projects/nostr-linkr&apos; },
    { label: &apos;Repository&apos;, value: &apos;VincenzoImp/nostreum&apos;, href: &apos;https://github.com/VincenzoImp/nostreum&apos; },
  ]}
/&gt;

&lt;RepoCard
  repo=&quot;VincenzoImp/nostreum&quot;
  description=&quot;Nostr social client with on-chain Ethereum identity verification, powered by nostr-linkr.&quot;
  language=&quot;TypeScript&quot;
/&gt;

## 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&apos;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 &quot;verified&quot; 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.</content:encoded></item><item><title>[Project] URI Generic Regex</title><link>https://vincenzo.imperati.dev/projects/uri-generic-regex/</link><guid isPermaLink="true">https://vincenzo.imperati.dev/projects/uri-generic-regex/</guid><description>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.</description><pubDate>Sat, 10 May 2025 00:00:00 GMT</pubDate><content:encoded>import Callout from &apos;../../components/ui/Callout.astro&apos;
import RepoCard from &apos;../../components/ui/RepoCard.astro&apos;
import SpecPanel from &apos;../../components/ui/SpecPanel.astro&apos;

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.

&lt;SpecPanel
  items={[
    { label: &apos;Size&apos;, value: &apos;One module, a regex and a demo entry point&apos; },
    { label: &apos;Groups&apos;, value: &apos;scheme · userinfo · host · domain · localhost · port · path · query · fragment&apos; },
    { label: &apos;Covers&apos;, value: &apos;Domain names, IPv4 and IPv6 literals&apos; },
    { label: &apos;Repository&apos;, value: &apos;VincenzoImp/uri-generic-regex&apos;, href: &apos;https://github.com/VincenzoImp/uri-generic-regex&apos; },
  ]}
/&gt;

&lt;RepoCard
  repo=&quot;VincenzoImp/uri-generic-regex&quot;
  description=&quot;A Python regular expression for parsing URI components per RFC 3986, with named capture groups.&quot;
  language=&quot;Python&quot;
/&gt;

## What it is

The URI regexes that circulate online mostly answer &quot;does this look like a link?&quot;, which is a
different question from &quot;what are its parts?&quot;, and they tend to be assembled by hand from
examples rather than from the grammar. This one follows the RFC&apos;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.

&lt;Callout type=&quot;warning&quot;&gt;
  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.
&lt;/Callout&gt;

## 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.</content:encoded></item><item><title>[Project] BigBrotr</title><link>https://vincenzo.imperati.dev/projects/bigbrotr/</link><guid isPermaLink="true">https://vincenzo.imperati.dev/projects/bigbrotr/</guid><description>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.</description><pubDate>Tue, 29 Apr 2025 00:00:00 GMT</pubDate><content:encoded>import Callout from &apos;../../components/ui/Callout.astro&apos;
import MetricRow from &apos;../../components/ui/MetricRow.astro&apos;
import RepoCard from &apos;../../components/ui/RepoCard.astro&apos;
import SpecPanel from &apos;../../components/ui/SpecPanel.astro&apos;

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.

&lt;SpecPanel
  items={[
    { label: &apos;Funding&apos;, value: &apos;OpenSats grant&apos; },
    { label: &apos;Shape&apos;, value: &apos;Eight independent async services, one PostgreSQL 18 database&apos; },
    { label: &apos;Networks&apos;, value: &apos;Clearnet · Tor · I2P · Lokinet&apos; },
    { label: &apos;Repository&apos;, value: &apos;BigBrotr/bigbrotr&apos;, href: &apos;https://github.com/BigBrotr/bigbrotr&apos; },
  ]}
/&gt;

&lt;RepoCard
  repo=&quot;BigBrotr/bigbrotr&quot;
  description=&quot;Modular Nostr network observatory: relay discovery, health monitoring, event archiving, analytics, and data access.&quot;
  language=&quot;Python&quot;
/&gt;

## 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 &quot;did I get everything this relay has?&quot; 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.

&lt;MetricRow
  items={[
    { value: &apos;250M+&apos;, label: &apos;events archived&apos; },
    { value: &apos;5,000+&apos;, label: &apos;relays monitored&apos; },
    { value: &apos;2,000+/s&apos;, label: &apos;sustained ingest&apos; },
  ]}
/&gt;

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&apos;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&apos;s per-thread
arenas never returning pages from the binding&apos;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.

&lt;Callout type=&quot;insight&quot;&gt;
  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.
&lt;/Callout&gt;</content:encoded></item><item><title>[Research] Hard to See, Harder to Block: Exploring the Challenges in Email Tracking Pixels Detection</title><link>https://vincenzo.imperati.dev/research/email-tracking-pixel-detection/</link><guid isPermaLink="true">https://vincenzo.imperati.dev/research/email-tracking-pixel-detection/</guid><description>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.</description><pubDate>Sat, 01 Mar 2025 00:00:00 GMT</pubDate><content:encoded>import Callout from &apos;../../components/ui/Callout.astro&apos;
import MetricRow from &apos;../../components/ui/MetricRow.astro&apos;
import SpecPanel from &apos;../../components/ui/SpecPanel.astro&apos;

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.

&lt;SpecPanel
  items={[
    { label: &apos;Venue&apos;, value: &apos;IEEE Access&apos; },
    { label: &apos;Type&apos;, value: &apos;Journal article (peer-reviewed)&apos; },
    { label: &apos;Status&apos;, value: &apos;Published&apos; },
    { label: &apos;Role&apos;, value: &apos;Measurement, data collection, analysis&apos; },
  ]}
/&gt;

## 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.

&lt;MetricRow
  items={[
    { value: &apos;44,710&apos;, label: &apos;real-world emails&apos; },
    { value: &apos;620&apos;, label: &apos;sender domains&apos; },
    { value: &apos;22&apos;, label: &apos;commercial services&apos; },
  ]}
/&gt;

## 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&apos;s effective protection depends on who is emailing
them, not on the tool they chose.

&lt;Callout type=&quot;insight&quot;&gt;
  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.
&lt;/Callout&gt;

## 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.</content:encoded></item><item><title>[Project] HR Management System</title><link>https://vincenzo.imperati.dev/projects/hr-management-system/</link><guid isPermaLink="true">https://vincenzo.imperati.dev/projects/hr-management-system/</guid><description>Replaced an Italian industrial contractor&apos;s paper personnel files with a web application built around the question the office actually asks: who can weld this, and how well.</description><pubDate>Sun, 20 Oct 2024 00:00:00 GMT</pubDate><content:encoded>import Callout from &apos;../../components/ui/Callout.astro&apos;
import RepoCard from &apos;../../components/ui/RepoCard.astro&apos;
import SpecPanel from &apos;../../components/ui/SpecPanel.astro&apos;

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.

&lt;SpecPanel
  items={[
    { label: &apos;Engagement&apos;, value: &apos;Freelance&apos; },
    { label: &apos;Domain&apos;, value: &apos;Nine metalworking trades, rated 0–5&apos; },
    { label: &apos;Stack&apos;, value: &apos;Next.js · Firebase Auth, Firestore and Storage&apos; },
    { label: &apos;Interface&apos;, value: &apos;Italian, with all copy centralised in one file&apos; },
    { label: &apos;Repository&apos;, value: &apos;VincenzoImp/human-resources-management-system&apos;, href: &apos;https://github.com/VincenzoImp/human-resources-management-system&apos; },
  ]}
/&gt;

&lt;RepoCard
  repo=&quot;VincenzoImp/human-resources-management-system&quot;
  description=&quot;Employee records, documents and per-trade competence ratings for an Italian industrial contractor.&quot;
  language=&quot;TypeScript&quot;
/&gt;

## 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 &quot;who on the books can do this job&quot; 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 &quot;qualified&quot; 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&apos;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.

&lt;Callout type=&quot;warning&quot;&gt;
  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.
&lt;/Callout&gt;</content:encoded></item><item><title>[Project] eBay Sniper Bot</title><link>https://vincenzo.imperati.dev/projects/ebay-sniper-bot/</link><guid isPermaLink="true">https://vincenzo.imperati.dev/projects/ebay-sniper-bot/</guid><description>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.</description><pubDate>Sat, 12 Oct 2024 00:00:00 GMT</pubDate><content:encoded>import RepoCard from &apos;../../components/ui/RepoCard.astro&apos;
import SpecPanel from &apos;../../components/ui/SpecPanel.astro&apos;

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.

&lt;SpecPanel
  items={[
    { label: &apos;Scope&apos;, value: &apos;One click, at a time the user types&apos; },
    { label: &apos;Stack&apos;, value: &apos;Python · DrissionPage · Chromium&apos; },
    { label: &apos;Repository&apos;, value: &apos;VincenzoImp/ebay-sniper-bot&apos;, href: &apos;https://github.com/VincenzoImp/ebay-sniper-bot&apos; },
  ]}
/&gt;

&lt;RepoCard
  repo=&quot;VincenzoImp/ebay-sniper-bot&quot;
  description=&quot;Last-second bid confirmation for eBay auctions, driven with DrissionPage.&quot;
  language=&quot;Jupyter Notebook&quot;
/&gt;

## 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&apos;s. It is a small, honest
demonstration that latency work is mostly deciding what to do early.</content:encoded></item><item><title>[Project] Safeblow</title><link>https://vincenzo.imperati.dev/projects/safeblow/</link><guid isPermaLink="true">https://vincenzo.imperati.dev/projects/safeblow/</guid><description>A privacy-preserving whistleblowing and sensitive-disclosure system built at ETHRome 2024.</description><pubDate>Sat, 05 Oct 2024 00:00:00 GMT</pubDate><content:encoded>import Callout from &apos;../../components/ui/Callout.astro&apos;
import SpecPanel from &apos;../../components/ui/SpecPanel.astro&apos;

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.

&lt;SpecPanel
  items={[
    { label: &apos;Event&apos;, value: &apos;ETHRome 2024&apos; },
    { label: &apos;Result&apos;, value: &apos;1st Web3Privacy Now · 1st LaserRomae&apos; },
    { label: &apos;Role&apos;, value: &apos;Team lead&apos; },
    { label: &apos;Team repository&apos;, value: &apos;SafeBlow/safeblow&apos;, href: &apos;https://github.com/SafeBlow/safeblow&apos; },
  ]}
/&gt;

&lt;Callout type=&quot;note&quot;&gt;
  The team&apos;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.
&lt;/Callout&gt;

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.</content:encoded></item><item><title>[Project] Cantina Royale Tools</title><link>https://vincenzo.imperati.dev/projects/cantina-royale-tools/</link><guid isPermaLink="true">https://vincenzo.imperati.dev/projects/cantina-royale-tools/</guid><description>A public explorer for a live blockchain game&apos;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.</description><pubDate>Wed, 29 May 2024 00:00:00 GMT</pubDate><content:encoded>import Callout from &apos;../../components/ui/Callout.astro&apos;
import RepoCard from &apos;../../components/ui/RepoCard.astro&apos;
import SpecPanel from &apos;../../components/ui/SpecPanel.astro&apos;

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&apos;s data engineer.

&lt;SpecPanel
  items={[
    { label: &apos;Scope&apos;, value: &apos;5 collections · 20,413 NFTs · 48 gameplay tables&apos; },
    { label: &apos;Chain&apos;, value: &apos;MultiversX, with XOXNO marketplace data&apos; },
    { label: &apos;Serving&apos;, value: &apos;SQLite compiled at build time, queried server-side&apos; },
    { label: &apos;Role&apos;, value: &apos;Data engineer&apos; },
    { label: &apos;Repository&apos;, value: &apos;VincenzoImp/cantinaroyale.tools&apos;, href: &apos;https://github.com/VincenzoImp/cantinaroyale.tools&apos; },
  ]}
/&gt;

&lt;RepoCard
  repo=&quot;VincenzoImp/cantinaroyale.tools&quot;
  description=&quot;Public Cantina Royale NFT explorer for collections, characters, weapons, traits, stats and market data.&quot;
  language=&quot;TypeScript&quot;
/&gt;

&lt;Callout type=&quot;note&quot;&gt;
  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.
&lt;/Callout&gt;

## The problem

A game&apos;s data does not live in one place. Ownership and traits are on chain, item metadata sits
behind the studio&apos;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.

&lt;Callout type=&quot;insight&quot;&gt;
  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.
&lt;/Callout&gt;</content:encoded></item><item><title>[Project] Gen4Olive Field App</title><link>https://vincenzo.imperati.dev/projects/gen4olive/</link><guid isPermaLink="true">https://vincenzo.imperati.dev/projects/gen4olive/</guid><description>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.</description><pubDate>Thu, 18 Apr 2024 00:00:00 GMT</pubDate><content:encoded>import Callout from &apos;../../components/ui/Callout.astro&apos;
import SpecPanel from &apos;../../components/ui/SpecPanel.astro&apos;

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.

&lt;SpecPanel
  items={[
    { label: &apos;Context&apos;, value: &apos;EU Horizon 2020 research project&apos; },
    { label: &apos;Engagement&apos;, value: &apos;Freelance, Nov 2024 – Mar 2025&apos; },
    { label: &apos;Scope&apos;, value: &apos;600+ olive cultivars&apos; },
    { label: &apos;Stack&apos;, value: &apos;React Native · Expo · JavaScript&apos; },
    { label: &apos;Repository&apos;, value: &apos;Private, SystemsLab-Sapienza&apos; },
  ]}
/&gt;

&lt;Callout type=&quot;note&quot;&gt;
  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&apos;s scientific work and publications are separate.
&lt;/Callout&gt;

## 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.</content:encoded></item><item><title>[Project] python-oced</title><link>https://vincenzo.imperati.dev/projects/python-oced/</link><guid isPermaLink="true">https://vincenzo.imperati.dev/projects/python-oced/</guid><description>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.</description><pubDate>Mon, 19 Feb 2024 00:00:00 GMT</pubDate><content:encoded>import Callout from &apos;../../components/ui/Callout.astro&apos;
import RepoCard from &apos;../../components/ui/RepoCard.astro&apos;
import SpecPanel from &apos;../../components/ui/SpecPanel.astro&apos;

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.

&lt;SpecPanel
  items={[
    { label: &apos;Shape&apos;, value: &apos;One module, 13 tables, 13 qualifier types, 15 classes&apos; },
    { label: &apos;Storage&apos;, value: &apos;Interlinked pandas DataFrames with declared keys&apos; },
    { label: &apos;Design notes&apos;, value: &apos;NOTE.md, 11 numbered problems each with its resolution&apos; },
    { label: &apos;Role&apos;, value: &apos;Author&apos; },
    { label: &apos;Repository&apos;, value: &apos;VincenzoImp/python-oced&apos;, href: &apos;https://github.com/VincenzoImp/python-oced&apos; },
  ]}
/&gt;

&lt;RepoCard
  repo=&quot;VincenzoImp/python-oced&quot;
  description=&quot;An implementation of the Object-Centric Event Data meta-model in Python.&quot;
  language=&quot;Python&quot;
/&gt;

## 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.

&lt;Callout type=&quot;insight&quot;&gt;
  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.
&lt;/Callout&gt;

&lt;Callout type=&quot;warning&quot;&gt;
  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.
&lt;/Callout&gt;</content:encoded></item><item><title>[Project] Lesson Booking System</title><link>https://vincenzo.imperati.dev/projects/lesson-booking-system/</link><guid isPermaLink="true">https://vincenzo.imperati.dev/projects/lesson-booking-system/</guid><description>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.</description><pubDate>Tue, 25 Apr 2023 00:00:00 GMT</pubDate><content:encoded>import RepoCard from &apos;../../components/ui/RepoCard.astro&apos;
import SpecPanel from &apos;../../components/ui/SpecPanel.astro&apos;

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.

&lt;SpecPanel
  items={[
    { label: &apos;Design half&apos;, value: &apos;34 draw.io diagrams, plus a LaTeX write-up&apos; },
    { label: &apos;Model half&apos;, value: &apos;316 lines of Modelica, 7 components, 2 monitors&apos; },
    { label: &apos;Results&apos;, value: &apos;10 randomised-seed runs passed; a 10-point parameter sweep&apos; },
    { label: &apos;Repository&apos;, value: &apos;VincenzoImp/lesson-booking-system&apos;, href: &apos;https://github.com/VincenzoImp/lesson-booking-system&apos; },
  ]}
/&gt;

&lt;RepoCard
  repo=&quot;VincenzoImp/lesson-booking-system&quot;
  description=&quot;Object-oriented design plus an executable Modelica model with runtime requirement monitors.&quot;
  language=&quot;Modelica&quot;
/&gt;

## What it was

The model treats the system as a clocked discrete-event process rather than as CRUD. A room&apos;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&apos;s state periodically, so the interesting
quantity is how *stale* the platform&apos;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&apos;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 &quot;the platform&apos;s view must not drift&quot; 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.</content:encoded></item><item><title>[Project] Algorithms Course Exercises</title><link>https://vincenzo.imperati.dev/projects/algorithms-course-exercises/</link><guid isPermaLink="true">https://vincenzo.imperati.dev/projects/algorithms-course-exercises/</guid><description>Twelve weeks of exercise sessions for an undergraduate algorithm-design course: problem statement, worked reasoning, Python, and the slides used to teach each one.</description><pubDate>Wed, 12 Apr 2023 00:00:00 GMT</pubDate><content:encoded>import RepoCard from &apos;../../components/ui/RepoCard.astro&apos;
import SpecPanel from &apos;../../components/ui/SpecPanel.astro&apos;

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.

&lt;SpecPanel
  items={[
    { label: &apos;Context&apos;, value: &apos;Teaching assistant, Sapienza, spring 2023&apos; },
    { label: &apos;Contents&apos;, value: &apos;12 sets · 30 problems · 8 slide decks&apos; },
    { label: &apos;Stack&apos;, value: &apos;Python · Jupyter · NetworkX · LaTeX&apos; },
    { label: &apos;Repository&apos;, value: &apos;VincenzoImp/algorithms-course-exercises&apos;, href: &apos;https://github.com/VincenzoImp/algorithms-course-exercises&apos; },
  ]}
/&gt;

&lt;RepoCard
  repo=&quot;VincenzoImp/algorithms-course-exercises&quot;
  description=&quot;Twelve weeks of exercise sessions for an undergraduate algorithms course: statements, worked solutions, and slides.&quot;
  language=&quot;Jupyter Notebook&quot;
/&gt;

## 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.</content:encoded></item><item><title>[Project] NFT Collections Generator</title><link>https://vincenzo.imperati.dev/projects/nft-collections-generator/</link><guid isPermaLink="true">https://vincenzo.imperati.dev/projects/nft-collections-generator/</guid><description>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.</description><pubDate>Sun, 27 Nov 2022 00:00:00 GMT</pubDate><content:encoded>import RepoCard from &apos;../../components/ui/RepoCard.astro&apos;
import SpecPanel from &apos;../../components/ui/SpecPanel.astro&apos;

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.

&lt;SpecPanel
  items={[
    { label: &apos;Trained on&apos;, value: &apos;Bored Ape Yacht Club and ELRONDAPESCLUB, 10,000 images each&apos; },
    { label: &apos;Pipeline&apos;, value: &apos;DCGAN → denoise → SRGAN 4× → denoise&apos; },
    { label: &apos;Training&apos;, value: &apos;1,000 epochs, about an hour per collection&apos; },
    { label: &apos;Repository&apos;, value: &apos;VincenzoImp/nft-collections-generator&apos;, href: &apos;https://github.com/VincenzoImp/nft-collections-generator&apos; },
  ]}
/&gt;

&lt;RepoCard
  repo=&quot;VincenzoImp/nft-collections-generator&quot;
  description=&quot;DCGAN and SRGAN pipeline for generating and upscaling NFT collection artwork.&quot;
  language=&quot;Jupyter Notebook&quot;
/&gt;

## 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&apos;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&apos;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.</content:encoded></item><item><title>[Project] Peer Store</title><link>https://vincenzo.imperati.dev/projects/peer-store/</link><guid isPermaLink="true">https://vincenzo.imperati.dev/projects/peer-store/</guid><description>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.</description><pubDate>Sun, 27 Nov 2022 00:00:00 GMT</pubDate><content:encoded>import RepoCard from &apos;../../components/ui/RepoCard.astro&apos;
import SpecPanel from &apos;../../components/ui/SpecPanel.astro&apos;

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.

&lt;SpecPanel
  items={[
    { label: &apos;Context&apos;, value: &apos;MSc mobile development, Sapienza&apos; },
    { label: &apos;Features&apos;, value: &apos;Listings · image labels · public message threads&apos; },
    { label: &apos;Stack&apos;, value: &apos;Kotlin · Firebase · ML Kit object detection&apos; },
    { label: &apos;Repository&apos;, value: &apos;VincenzoImp/peer-store&apos;, href: &apos;https://github.com/VincenzoImp/peer-store&apos; },
  ]}
/&gt;

&lt;RepoCard
  repo=&quot;VincenzoImp/peer-store&quot;
  description=&quot;Android marketplace with Firebase backend and on-device object detection.&quot;
  language=&quot;Kotlin&quot;
/&gt;

## What it was

The interesting piece is that the object detection runs **on the device**. ML Kit&apos;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.</content:encoded></item><item><title>[Project] MeltyFi</title><link>https://vincenzo.imperati.dev/projects/meltyfi/</link><guid isPermaLink="true">https://vincenzo.imperati.dev/projects/meltyfi/</guid><description>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.</description><pubDate>Wed, 23 Nov 2022 00:00:00 GMT</pubDate><content:encoded>import RepoCard from &apos;../../components/ui/RepoCard.astro&apos;
import SpecPanel from &apos;../../components/ui/SpecPanel.astro&apos;

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.

&lt;SpecPanel
  items={[
    { label: &apos;Origin&apos;, value: &apos;MSc, Blockchain and Distributed Ledger Technologies, Sapienza, 2022/23&apos; },
    { label: &apos;Implementations&apos;, value: &apos;EVM, Sui, and the XRPL EVM sidechain&apos; },
    { label: &apos;Award&apos;, value: &apos;Most Innovative Project, Sui Hackathon&apos; },
    { label: &apos;Team&apos;, value: &apos;MSc version with Andrea Princic and Benigno Ansanelli&apos; },
    { label: &apos;Repository&apos;, value: &apos;Princic-1837592/MeltyFi.NFT&apos;, href: &apos;https://github.com/Princic-1837592/MeltyFi.NFT&apos; },
  ]}
/&gt;

&lt;RepoCard
  repo=&quot;Princic-1837592/MeltyFi.NFT&quot;
  description=&quot;Lending and borrowing platform with NFT collateral based on lottery-ticket fundraising.&quot;
  language=&quot;Solidity&quot;
/&gt;

## 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&apos;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&apos;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&apos;s own primitives: randomness, object
ownership, and how an escrow is represented.</content:encoded></item><item><title>[Project] Who Funds a Rug Pull</title><link>https://vincenzo.imperati.dev/projects/ethereum-address-clustering/</link><guid isPermaLink="true">https://vincenzo.imperati.dev/projects/ethereum-address-clustering/</guid><description>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.</description><pubDate>Fri, 21 Oct 2022 00:00:00 GMT</pubDate><content:encoded>import Callout from &apos;../../components/ui/Callout.astro&apos;
import MetricRow from &apos;../../components/ui/MetricRow.astro&apos;
import RepoCard from &apos;../../components/ui/RepoCard.astro&apos;
import SpecPanel from &apos;../../components/ui/SpecPanel.astro&apos;

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.

&lt;SpecPanel
  items={[
    { label: &apos;Question&apos;, value: &apos;Do separate scam accounts share a funder?&apos; },
    { label: &apos;Seed&apos;, value: &apos;21,788 one-day exit scams · 16,584 accounts&apos; },
    { label: &apos;Scan&apos;, value: &apos;Blocks 0 – 14,339,974, in 14,340 chunks&apos; },
    { label: &apos;Method&apos;, value: &apos;Reverse funding graph, one hop, temporally bounded&apos; },
    { label: &apos;Repository&apos;, value: &apos;VincenzoImp/ethereum-address-clustering&apos;, href: &apos;https://github.com/VincenzoImp/ethereum-address-clustering&apos; },
  ]}
/&gt;

&lt;RepoCard
  repo=&quot;VincenzoImp/ethereum-address-clustering&quot;
  description=&quot;Reverse funding-graph study of one-day exit scams on Ethereum.&quot;
  language=&quot;Jupyter Notebook&quot;
/&gt;

## The problem

Bitcoin&apos;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.

&lt;MetricRow
  items={[
    { value: &apos;16,584&apos;, label: &apos;scam accounts&apos; },
    { value: &apos;16,006&apos;, label: &apos;direct funders&apos; },
    { value: &apos;7,929&apos;, label: &apos;components&apos; },
  ]}
/&gt;

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.

&lt;Callout type=&quot;warning&quot;&gt;
  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.
&lt;/Callout&gt;</content:encoded></item><item><title>[Project] SDN Performance Analysis</title><link>https://vincenzo.imperati.dev/projects/sdn-performance-analysis/</link><guid isPermaLink="true">https://vincenzo.imperati.dev/projects/sdn-performance-analysis/</guid><description>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.</description><pubDate>Tue, 30 Aug 2022 00:00:00 GMT</pubDate><content:encoded>import RepoCard from &apos;../../components/ui/RepoCard.astro&apos;
import SpecPanel from &apos;../../components/ui/SpecPanel.astro&apos;

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.

&lt;SpecPanel
  items={[
    { label: &apos;Setup&apos;, value: &apos;4 hosts, 3 switches, every link 20 Mbps / 20 ms&apos; },
    { label: &apos;Difference&apos;, value: &apos;One added link, s1–s3&apos; },
    { label: &apos;Measurements&apos;, value: &apos;303 runs, all raw traces committed&apos; },
    { label: &apos;Repository&apos;, value: &apos;VincenzoImp/sdn-performance-analysis&apos;, href: &apos;https://github.com/VincenzoImp/sdn-performance-analysis&apos; },
  ]}
/&gt;

&lt;RepoCard
  repo=&quot;VincenzoImp/sdn-performance-analysis&quot;
  description=&quot;Mininet and Ryu comparison of two SDN topologies under generated traffic load.&quot;
  language=&quot;Python&quot;
/&gt;

## 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&apos;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&apos;s packets should take a third longer than the other&apos;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&apos;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.</content:encoded></item><item><title>[Project] Bitcoin Address Clustering</title><link>https://vincenzo.imperati.dev/projects/bitcoin-address-clustering/</link><guid isPermaLink="true">https://vincenzo.imperati.dev/projects/bitcoin-address-clustering/</guid><description>Collapsing 334,177 Bitcoin addresses into 109,074 entities over the chain&apos;s first two years, using a heuristic chain tuned to avoid merging entities that are not the same one.</description><pubDate>Thu, 02 Jun 2022 00:00:00 GMT</pubDate><content:encoded>import Callout from &apos;../../components/ui/Callout.astro&apos;
import MetricRow from &apos;../../components/ui/MetricRow.astro&apos;
import RepoCard from &apos;../../components/ui/RepoCard.astro&apos;
import SpecPanel from &apos;../../components/ui/SpecPanel.astro&apos;

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&apos;s first 115,000 blocks and applies a chain of ownership
heuristics to it.

&lt;SpecPanel
  items={[
    { label: &apos;Scope&apos;, value: &apos;Blocks 0–115,000, January 2009 to February 2011&apos; },
    { label: &apos;Data&apos;, value: &apos;blockchain.info block API&apos; },
    { label: &apos;Graph&apos;, value: &apos;559,528 nodes · 630,301 edges&apos; },
    { label: &apos;Explorer&apos;, value: &apos;Streamlit + PyVis, local&apos; },
    { label: &apos;Repository&apos;, value: &apos;VincenzoImp/bitcoin-address-clustering&apos;, href: &apos;https://github.com/VincenzoImp/bitcoin-address-clustering&apos; },
  ]}
/&gt;

&lt;RepoCard
  repo=&quot;VincenzoImp/bitcoin-address-clustering&quot;
  description=&quot;Bitcoin address clustering using multiple ownership heuristics over the transaction graph.&quot;
  language=&quot;Jupyter Notebook&quot;
/&gt;

## 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.

&lt;MetricRow
  items={[
    { value: &apos;334,177&apos;, label: &apos;addresses&apos; },
    { value: &apos;109,074&apos;, label: &apos;entities after clustering&apos; },
    { value: &apos;67.4%&apos;, label: &apos;reduction&apos; },
  ]}
/&gt;

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.

&lt;Callout type=&quot;warning&quot;&gt;
  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.
&lt;/Callout&gt;</content:encoded></item><item><title>[Project] Online News Popularity</title><link>https://vincenzo.imperati.dev/projects/online-news-popularity/</link><guid isPermaLink="true">https://vincenzo.imperati.dev/projects/online-news-popularity/</guid><description>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.</description><pubDate>Sun, 23 Jan 2022 00:00:00 GMT</pubDate><content:encoded>import RepoCard from &apos;../../components/ui/RepoCard.astro&apos;
import SpecPanel from &apos;../../components/ui/SpecPanel.astro&apos;

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.

&lt;SpecPanel
  items={[
    { label: &apos;Context&apos;, value: &apos;Sapienza Machine Learning challenge, 2021/22&apos; },
    { label: &apos;Data&apos;, value: &apos;39,648 rows supplied → 37,638 after cleaning and outlier removal&apos; },
    { label: &apos;Result&apos;, value: &apos;0.65 accuracy, 0.25 F1-macro on a held-out third&apos; },
    { label: &apos;Repository&apos;, value: &apos;VincenzoImp/online-news-popularity&apos;, href: &apos;https://github.com/VincenzoImp/online-news-popularity&apos; },
  ]}
/&gt;

&lt;RepoCard
  repo=&quot;VincenzoImp/online-news-popularity&quot;
  description=&quot;Multi-class prediction of news article popularity under severe class imbalance.&quot;
  language=&quot;Jupyter Notebook&quot;
/&gt;

## 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.</content:encoded></item><item><title>[Project] Smart Houses</title><link>https://vincenzo.imperati.dev/projects/smart-houses/</link><guid isPermaLink="true">https://vincenzo.imperati.dev/projects/smart-houses/</guid><description>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.</description><pubDate>Fri, 12 Nov 2021 00:00:00 GMT</pubDate><content:encoded>import RepoCard from &apos;../../components/ui/RepoCard.astro&apos;
import SpecPanel from &apos;../../components/ui/SpecPanel.astro&apos;

A reimplementation of Lu, Hong and Yu&apos;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.

&lt;SpecPanel
  items={[
    { label: &apos;Reimplements&apos;, value: &apos;Lu, Hong &amp; Yu · IEEE Trans. Smart Grid 10(6), 2019&apos; },
    { label: &apos;My half&apos;, value: &apos;The reinforcement-learning simulator&apos; },
    { label: &apos;Data&apos;, value: &apos;Nord Pool prices · Test-An-EV charging histories · SmartHG&apos; },
    { label: &apos;Repository&apos;, value: &apos;VincenzoImp/smart-houses&apos;, href: &apos;https://github.com/VincenzoImp/smart-houses&apos; },
  ]}
/&gt;

&lt;RepoCard
  repo=&quot;VincenzoImp/smart-houses&quot;
  description=&quot;Two-stage demand response: LSTM price forecasting and reinforcement-learning charge scheduling.&quot;
  language=&quot;Python&quot;
/&gt;

## 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.</content:encoded></item><item><title>[Project] Modelica Notes</title><link>https://vincenzo.imperati.dev/projects/modelica-notes/</link><guid isPermaLink="true">https://vincenzo.imperati.dev/projects/modelica-notes/</guid><description>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.</description><pubDate>Thu, 09 Sep 2021 00:00:00 GMT</pubDate><content:encoded>import RepoCard from &apos;../../components/ui/RepoCard.astro&apos;
import SpecPanel from &apos;../../components/ui/SpecPanel.astro&apos;

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.

&lt;SpecPanel
  items={[
    { label: &apos;Sessions&apos;, value: &apos;Six, October–November 2020&apos; },
    { label: &apos;Methods&apos;, value: &apos;Runtime monitors · Monte Carlo · parameter sweeps&apos; },
    { label: &apos;Stack&apos;, value: &apos;Modelica · OpenModelica · OMPython&apos; },
    { label: &apos;Repository&apos;, value: &apos;VincenzoImp/modelica-notes&apos;, href: &apos;https://github.com/VincenzoImp/modelica-notes&apos; },
  ]}
/&gt;

&lt;RepoCard
  repo=&quot;VincenzoImp/modelica-notes&quot;
  description=&quot;Annotated Modelica examples on discrete-event modelling, control design and requirement verification.&quot;
  language=&quot;Modelica&quot;
/&gt;

## 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 &quot;only presses the accelerator and
does not fear death&quot;, 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.</content:encoded></item><item><title>[Project] Parallel Sudoku solver</title><link>https://vincenzo.imperati.dev/projects/parallel-sudoku-solver/</link><guid isPermaLink="true">https://vincenzo.imperati.dev/projects/parallel-sudoku-solver/</guid><description>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.</description><pubDate>Thu, 09 Sep 2021 00:00:00 GMT</pubDate><content:encoded>import RepoCard from &apos;../../components/ui/RepoCard.astro&apos;
import SpecPanel from &apos;../../components/ui/SpecPanel.astro&apos;

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.

&lt;SpecPanel
  items={[
    { label: &apos;Context&apos;, value: &apos;Concurrent programming course, January 2021&apos; },
    { label: &apos;With&apos;, value: &apos;Gaetano Conti&apos; },
    { label: &apos;Benchmark&apos;, value: &apos;100 puzzles · 1–32 threads · 2-core i7-6500U&apos; },
    { label: &apos;Repository&apos;, value: &apos;VincenzoImp/parallel-sudoku-solver&apos;, href: &apos;https://github.com/VincenzoImp/parallel-sudoku-solver&apos; },
  ]}
/&gt;

&lt;RepoCard
  repo=&quot;VincenzoImp/parallel-sudoku-solver&quot;
  description=&quot;Pthread and OpenMP Sudoku solvers, with the timing results that show neither parallelisation pays.&quot;
  language=&quot;C&quot;
/&gt;

## 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&apos;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&apos;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.</content:encoded></item><item><title>[Project] QuizArt</title><link>https://vincenzo.imperati.dev/projects/quizart-app-design/</link><guid isPermaLink="true">https://vincenzo.imperati.dev/projects/quizart-app-design/</guid><description>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.</description><pubDate>Thu, 09 Sep 2021 00:00:00 GMT</pubDate><content:encoded>import RepoCard from &apos;../../components/ui/RepoCard.astro&apos;
import SpecPanel from &apos;../../components/ui/SpecPanel.astro&apos;

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.

&lt;SpecPanel
  items={[
    { label: &apos;Context&apos;, value: &apos;HCI coursework, January 2021 · team of four&apos; },
    { label: &apos;Research&apos;, value: &apos;6 competitor apps · 23 interviews · 261 survey responses&apos; },
    { label: &apos;Prototypes&apos;, value: &apos;Three iterations, tested two different ways&apos; },
    { label: &apos;Repository&apos;, value: &apos;VincenzoImp/quizart-app-design&apos;, href: &apos;https://github.com/VincenzoImp/quizart-app-design&apos; },
  ]}
/&gt;

&lt;RepoCard
  repo=&quot;VincenzoImp/quizart-app-design&quot;
  description=&quot;HCI design project: user research, three prototype iterations and usability testing for a cultural-heritage quiz app.&quot;
  language=&quot;TeX&quot;
/&gt;

## 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.</content:encoded></item><item><title>[Project] Social Game System</title><link>https://vincenzo.imperati.dev/projects/social-game-system/</link><guid isPermaLink="true">https://vincenzo.imperati.dev/projects/social-game-system/</guid><description>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.</description><pubDate>Thu, 09 Sep 2021 00:00:00 GMT</pubDate><content:encoded>import RepoCard from &apos;../../components/ui/RepoCard.astro&apos;
import SpecPanel from &apos;../../components/ui/SpecPanel.astro&apos;

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.

&lt;SpecPanel
  items={[
    { label: &apos;Context&apos;, value: &apos;Metodologie di Programmazione, Sapienza · team of four&apos; },
    { label: &apos;Model&apos;, value: &apos;8 traits · mood 0–100 · 11-word private vocabulary&apos; },
    { label: &apos;Reference run&apos;, value: &apos;100 agents, 1,000 days&apos; },
    { label: &apos;Repository&apos;, value: &apos;VincenzoImp/social-game-system&apos;, href: &apos;https://github.com/VincenzoImp/social-game-system&apos; },
  ]}
/&gt;

&lt;RepoCard
  repo=&quot;VincenzoImp/social-game-system&quot;
  description=&quot;Agent-based simulation of social dynamics in Java, with live population and mood charts.&quot;
  language=&quot;Java&quot;
/&gt;

## What it was

The rule that makes it interesting is inheritance. A child receives its parent&apos;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.</content:encoded></item><item><title>[Project] Bachelor thesis, scheduling EV charging with reinforcement learning</title><link>https://vincenzo.imperati.dev/projects/bachelor-thesis/</link><guid isPermaLink="true">https://vincenzo.imperati.dev/projects/bachelor-thesis/</guid><description>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.</description><pubDate>Fri, 02 Jul 2021 00:00:00 GMT</pubDate><content:encoded>import Callout from &apos;../../components/ui/Callout.astro&apos;
import RepoCard from &apos;../../components/ui/RepoCard.astro&apos;
import SpecPanel from &apos;../../components/ui/SpecPanel.astro&apos;

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.

&lt;SpecPanel
  items={[
    { label: &apos;Institution&apos;, value: &apos;Sapienza University of Rome, 2021&apos; },
    { label: &apos;Supervisor&apos;, value: &apos;Prof. Igor Melatti&apos; },
    { label: &apos;Simulation&apos;, value: &apos;One year, 8,760 hourly steps&apos; },
    { label: &apos;Compared&apos;, value: &apos;4 battery models × 3 cost-versus-comfort weightings&apos; },
    { label: &apos;Repository&apos;, value: &apos;VincenzoImp/bachelor-thesis&apos;, href: &apos;https://github.com/VincenzoImp/bachelor-thesis&apos; },
  ]}
/&gt;

&lt;RepoCard
  repo=&quot;VincenzoImp/bachelor-thesis&quot;
  description=&quot;Home energy management with reinforcement learning: four models of plug-in electric vehicle charging, evaluated over a simulated year.&quot;
  language=&quot;TeX&quot;
/&gt;

## 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&apos;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&apos;s convergence criterion
never converged – the car&apos;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.

&lt;Callout type=&quot;warning&quot;&gt;
  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.
&lt;/Callout&gt;</content:encoded></item><item><title>[Project] CatSScript Website</title><link>https://vincenzo.imperati.dev/projects/catsscript-website/</link><guid isPermaLink="true">https://vincenzo.imperati.dev/projects/catsscript-website/</guid><description>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.</description><pubDate>Mon, 12 Apr 2021 00:00:00 GMT</pubDate><content:encoded>import RepoCard from &apos;../../components/ui/RepoCard.astro&apos;
import SpecPanel from &apos;../../components/ui/SpecPanel.astro&apos;

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.

&lt;SpecPanel
  items={[
    { label: &apos;Context&apos;, value: &apos;BSc exam project, spring 2021, with a classmate&apos; },
    { label: &apos;Contents&apos;, value: &apos;15 lessons · 3 quizzes × 10 questions · 15-second timer&apos; },
    { label: &apos;Stack&apos;, value: &apos;PHP sessions · jQuery · Bootstrap · PostgreSQL and MySQL&apos; },
    { label: &apos;Repository&apos;, value: &apos;VincenzoImp/catsscript-website&apos;, href: &apos;https://github.com/VincenzoImp/catsscript-website&apos; },
  ]}
/&gt;

&lt;RepoCard
  repo=&quot;VincenzoImp/catsscript-website&quot;
  description=&quot;A cat-themed site for learning front-end web programming, with timed quizzes.&quot;
  language=&quot;PHP&quot;
/&gt;

## 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&apos;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.</content:encoded></item></channel></rss>