Files
file-server/model/DESIGN_clustering.md
Jeffrey Ward 584bad02a7 Add stage-5 phase B: durable single-owner format catalog
Wraps the assign_file scoring core (cluster.jl) in the live catalog the
design's phase B calls for (DESIGN §5B/§9):

- src/catalog.jl: Catalog durable state (frozen-id clusters + sufficient
  stats + processed set + examples); sparse, sidecar-first durable
  save/load; incremental catalog_sweep! (deterministic CRP-predictive
  assignment of new binary/ files); offline compact! that seeds on first
  run and recompacts later; write_nominations! emitting one JSON per
  promotable cluster with a hex magic template.
- bin/cluster_sweep.jl: cron/periodic single-owner runner (--compact
  forces a recluster; first run auto-compacts to seed).
- config.jl: cluster_catalog_path + nominated_dir knobs (FS_CLUSTER_CATALOG,
  FS_NOMINATED_DIR), wired into config_from_env and ensure_dirs.
- Tests: durable round-trip, incremental sweep growth + idempotency,
  §10.1 nothing-from-noise end-to-end (zero promotions), a recurring
  format self-nominating, seed-then-live-assign (165 pass).
- DESIGN_clustering.md: mark phase B built.
2026-07-03 17:03:33 -04:00

315 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Stage-5: Unknown-format discovery by Bayesian header clustering
Status: **phases A and B implemented and calibrated** (`src/cluster.jl` +
`src/catalog.jl`, `bin/cluster_calibrate.jl` + `bin/cluster_sweep.jl`, tests in
`test/runtests.jl`). Phase A (offline Gibbs) is calibrated; phase B's durable
single-owner catalog, incremental sweep, and nomination writer are now built on
top of the `assign_file` scoring core. Product
of a design interview; captures the decisions and — as important — the
assumptions we *rejected* so they don't get silently reintroduced. §11 records
what building it actually taught us, including three assumptions in this document
that the data corrected.
## 1. Goal
Discover **recurring new file formats** hiding in the `binary/` bucket (the
`:unknown` sink from `classify.jl` → stage-3 triage). A genuinely novel format is
a plausible proxy for a genuinely novel producing application, but we do **not**
try to identify producers directly (see §3). The output is a **growing catalog of
discovered formats**, each with a magic-byte signature that can be promoted into
the classifier's fast path.
Task shape (settled): **unsupervised clustering with an unknown number of
clusters.** Not pairwise "same producer" scoring, not classification against a
fixed label set.
## 2. Two phases — build (A) then run (B)
**(A) Batch, offline — the science.** Cluster the accumulated pile from scratch.
Its job is *not* to be the catalog; it is to (i) prove the header-byte signal
actually separates formats, cross-checked against an NCD baseline (§8), and
(ii) **calibrate hyperparameters** against known formats (§7). Ship this first —
it de-risks (B). If (A)'s clusters are garbage, (B)'s machinery is wasted.
**(B) Online, live — the catalog.** The target deliverable. A persistent catalog
where each discovered format has a **durable, frozen ID** and stored sufficient
statistics. New unknown files are scored against existing clusters; only
genuinely novel ones spawn a new entry. Clusters that accumulate enough evidence
are **nominated for promotion** into the classifier (§6).
## 3. What we are and are NOT clustering
We cluster by **file format**, not by producer. The first-*n* header bytes are
format-mandated and producer-invariant: every valid PNG shares the same magic
regardless of which program wrote it; a PDF's producer string lives deep inside
the file, not in the header. Producer identity, where recoverable at all, is
`exiftool`'s job (stage 2), not this stage's.
Corollary already visible in `../training_set`: extension labels are **not**
header-format labels. `docx` *is* a PK zip; `so`/`o`/`elf`/`out` are all ELF.
Merging those is **correct**, not error (see §7).
## 4. Model: DP mixture of per-position categoricals
A cluster is a **product of independent per-position categorical distributions**
over the first *n* header bytes. Position *i* carries a distribution `θᵢ` over a
**257-symbol alphabet**: byte values `0255`, plus symbol `256 = "past EOF"`.
- Invariant positions (magic bytes) learn a spiked `θᵢ`; variable positions
(lengths, timestamps) learn a flat one. A cluster's signature = the vector of
modal symbols + per-position peakedness. That signature **is a magic-number
template** — this is the entire reason for the categorical choice.
- `257` alphabet handles short files honestly: a format that is always 20 bytes
produces a spiked "past-EOF" at positions 2031, which is real, discriminative
signal. No zero-padding (would collide `0x00` padding with real `0x00` bytes).
**Priors:** Dirichlet on each `θᵢ` (conjugate to Categorical); **Dirichlet
process (CRP)** over cluster assignments → unknown *k* falls out natively.
**Why categorical, not Euclidean.** Bytes are categorical, not ordinal: `0x89`
and `0x88` are not "close," `0x00` and `0xFF` are not "far." k-means / Gaussian
mixtures over scaled bytes assert a metric that does not exist in header space.
**Do not reuse `model.jl`'s `[0,1]` byte scaling here** — that scaling is correct
for the Lux net and wrong for this model. We need the raw `0255` byte as a
categorical index.
### 4a. Background component (high-entropy handling)
Add a fixed, **non-adaptive uniform component** (each position uniform over 257)
as the "junk drawer." Compressed/encrypted/structureless blobs are ~uniform after
any magic and would otherwise either (i) mint a singleton per file or (ii)
collapse into one flat cluster that then matches everything. The background
absorbs them cleanly.
Two populations, to be precise:
- **Structured prefix + random tail** (gzip `1f 8b`, PK zip, zstd, most encrypted
*containers*): peaked at positions 03, flat after. These form **real clusters
for free** — genuine discoveries, no special handling.
- **Uniform from byte 0** (raw encrypted streams, key material): nothing in the
header to cluster on → absorbed by background.
The background is **never promotable**. But it is **not a silent sink**: its
size / growth / entropy histogram is surfaced as a first-class signal ("12% of
this week's unknowns are structureless"). If sub-clustering the structureless
residue ever matters, that needs a *different* feature (byte histogram / entropy),
a separate v3 model — header bytes genuinely cannot do it.
### 4b. Feature window
**Front-only, `n = 32`** (config knob; try 64 if under-resolved). Magic lives at
offset 0. Tail window **deferred to v2** — a minority of formats have trailers
(ZIP EOCD, ID3v1, PDF `%%EOF`); add as an independent *second block* of positions
only if real trailer-formats show up in the residue.
**Known blind spot: tar.** `ustar` magic is at **offset 257**, outside the
window, so all 100 training tars scatter to background. Accepted for v1 — tar is
already a *known* format, so discovery doesn't need it. General lesson: a minority
of formats put magic at a fixed deeper offset; the fix (if ever needed) is a
**sparse probe window** at that offset (e.g. bytes 257262 as a third block), not
densely modeling 257 front bytes — that would 8× every cluster's `n×257`
sufficient-stat table to catch one format.
## 5. Inference: different mode per phase (resolves the Bayesian-vs-catalog tension)
A sampler yields a *posterior over partitions*; a catalog needs *one partition
with durable IDs*. Two MCMC gotchas: **label switching** (cluster #3 is not a
stable identity across iterations/runs) and **distribution-not-answer** (1000
partitions, not one). We sidestep both by using two inference modes:
- **Phase (A), offline:** full **collapsed Gibbs** sampler over the
Dirichlet-Categorical (conjugacy → ~100 lines, no continuous approximation,
unknown *k* native). Used to validate signal, tune `α` + Dirichlet strength,
and seed the initial catalog (summarize to a point partition **once**, via a
VI/Binder loss over the posterior similarity matrix — tolerated because it is
offline, never in the hot path).
- **Phase (B), live:** **deterministic sequential CRP-predictive assignment.**
Each catalog cluster stores per-position 257-count vectors (sufficient stats).
A new file's CRP predictive probability of joining each existing cluster vs.
the background vs. spawning a new cluster is computed; assign to the argmax.
A new cluster is minted only if the new-cluster evidence beats the background
by a margin. **IDs are frozen at birth → no label switching.** This is exactly
the Gibbs predictive rule with existing assignments held fixed — same math, not
an ad-hoc hack.
- **Periodic compaction, offline:** re-run Gibbs seeded from the current catalog
to merge drifted clusters / split bloated ones.
## 6. Promotion (closing the loop to the classifier)
**Layered known-check at ingest** becomes:
1. Match against **promoted signatures** (exact, fast) — runs *before* the net.
2. Else the Lux `:known` / `:unknown` classifier.
3. Else route to `binary/` for this stage.
**Promotion = append a magic-byte signature to a registry.** A cluster's spiked
positions (posterior max-prob `> ~0.9`) become required bytes; flat positions
become wildcards — a libmagic-style signature. This is a **data change, not a
retrain**; interpretable, auditable, reversible. Retraining the Lux net is a
separate, *optional periodic* activity using accumulated signature-labeled files,
never the promotion mechanism itself.
**Nominate automatically, activate by hand.** A cluster crossing thresholds —
`≥ N` members (start `N ≈ 2050`, loose dial since a human is the backstop) **and**
`≥ ~3` magic positions **and** not the background — is written to a `nominated/`
registry with its signature, member count, and example files. A human glance
promotes it into the active set. Human gate guards the one hard-to-reverse action
(redefining "known"); everything upstream stays automatic.
## 7. Calibration: recover known formats, then trust on unknowns
Do not pick priors blind. We have ground truth: `../training_set` (100 each of
tgz/tar/pdf/docx, 98 zip, 93 jpg, ELF family) and the `data/done` corpus.
1. Run **labeled known files** through the exact clustering pipeline.
2. Ground truth = **magic-collapsed classes**, *not* extensions:
`{gzip (tgz), PKzip (docx≡zip), ELF (so/o/elf/out/x86_64), JPEG, PDF, tar}`.
Merging docx+zip and the ELF family is the **correct** answer — scoring
against raw extensions would penalize correctness and mistune `α`.
3. Measure recovered-vs-truth agreement with **Adjusted Rand Index / V-measure**.
4. **Grid-tune `α` and the Dirichlet pseudocount to maximize agreement** — the
settings at which the machine rediscovers formats we already know.
5. Freeze, deploy on the `:unknown` pile.
Splitting docx from zip is a **later tier**: the discriminating info
(central-directory filenames like `word/document.xml`) sits at a *variable
offset*, not a fixed position — a different feature problem, deferred.
## 8. Julia package surface
- **Hand-rolled collapsed Gibbs** over Dirichlet-Categorical — recommended. The
conjugacy makes it short/fast; we own the online + promotion logic; no library
impedance. `Distributions.jl` for `Dirichlet`/`Categorical` primitives.
- **`CodecZlib`** for the **NCD (Normalized Compression Distance)** baseline —
model-free gzip-similarity clustering. Excellent at format grouping and a great
step-(A) sanity check, but O(N²), non-generative (no signature, no online
scoring, no promotion) → **baseline only, cannot be the catalog.**
- **`Clustering.jl`** — `randindex` / `vmeasure` for the §7 calibration metric,
plus a throwaway k-modes-ish baseline. **Not** the real model (its k-means is
the Euclidean trap of §4).
- **`Turing.jl`** — considered and rejected: discrete assignment latents + DP are
awkward, and we'd still hand-roll the online path. Overkill.
## 9. Architecture: single-owner batch stage, NOT inline inference
The classifier is stateless, immutable, shared read-only across worker threads
(see `classify.jl`). **The catalog is the opposite: mutable, learned, shared**
every assigned file updates a cluster's counts. It therefore must **not** copy the
classifier's concurrency model (concurrent workers → lock contention, torn reads
of sufficient stats, CRP assignment against stale mass).
Because **promotion is human-gated, nothing here is latency-sensitive.** So:
- Workers stay stateless — they deposit `:unknown` files into `binary/` exactly as
today. **No catalog access on the hot path.**
- A **separate stage-5 process** (periodic / cron, single-threaded) owns the
catalog **exclusively**: sweeps newly-arrived `binary/` files, runs sequential
CRP-predictive assignment, updates sufficient stats, writes nominations.
**One writer, zero locks, no cross-thread shared mutable state.**
- The catalog is a **durable file** mutated by one process — reuse the stage-2
**sidecar-first durable-commit** discipline (`commit_enriched!`: temp write →
fsync bytes → rename → fsync dir) so a crash can't corrupt it or lose a rename.
This slots in as a batch stage, matching how stages 2/3/4 already work. New
`Config` knobs follow the existing `FS_*` env-override convention (e.g.
`FS_CLUSTER_DIR`, `FS_CLUSTER_N`, `FS_CLUSTER_ALPHA`, `FS_CLUSTER_PSEUDOCOUNT`,
`FS_PROMOTE_MIN_MEMBERS`).
## 10. Concrete test assertions (write these first)
1. **Discovers nothing from noise.** Current `data/binary` = 20 small random
blobs + 1 giant PDF. Correct output: PDF is a singleton that **never promotes**
(N=1), 20 blobs absorbed by background, **zero promoted clusters.** Any
promoted cluster from this pile = broken.
2. **Recovers known formats.** On a `../training_set` sample, calibrated settings
cluster into the ~6 magic-collapsed classes with high ARI (docx+zip merged,
ELF family merged, tar in background as the accepted blind spot).
3. **NCD agreement.** Step-(A) Bayesian clusters broadly agree with the NCD
baseline on the same input; large disagreement is a red flag to investigate
before trusting the generative model.
## 11. Implementation status & calibration results (v1)
**Shipped.** `src/cluster.jl` — feature extraction (`header_symbols`, 257-symbol
alphabet), collapsed Gibbs (`gibbs_cluster`, phase A), the sequential
CRP-predictive rule (`assign_file`, phase B core), signatures/promotion
(`signature`, `is_promotable`), and calibration metrics (`adjusted_rand_index`,
`v_measure`). All base-Julia — a base-only Lanczos `loggamma` keeps the
Dirichlet-multinomial marginal dependency-free (no Manifest churn). Config knobs
`FS_CLUSTER_*` (§9) added. `bin/cluster_calibrate.jl` runs the §7 grid and the §8
NCD baseline. Concrete §10 assertions are in the test suite (hermetic synthetic
corpora, so they need neither `../training_set` nor gzip).
**Calibrated defaults** (grid over the 700-file `training_set`, ranked by ARI
excluding tar): **n=32, α=1.0, β=0.1, bg_mass=5.0** → ARI **0.77** (0.885 excl.
tar), V-measure 0.83, homogeneity 0.87. Clusters are clean and promotable:
`pkzip:197` (docx+zip correctly merged, §7.2 ✓), `gzip:100`, `jpeg`, and several
`pdf` clusters all self-nominate. The **NCD baseline agrees** (§10.3): on a
150-file subsample, NCD 1-NN label purity 0.90 vs. the model's same-cluster
purity 0.987 — the generative header model separates formats at least as well as
model-free gzip similarity.
### Three assumptions the data corrected
1. **Tar is not in the background here; it merges into ELF.** §4b/§7 assumed
tar's `ustar`-at-257 magic is out of window so tars scatter to background. But
98/100 tars in the corpus are Hex/Elixir package tarballs whose *first
archived file is named `VERSION`* → a constant, strongly-peaked `VERSION\0`
prefix at offset 0. They do form a peaked cluster — but it **merges with ELF**,
because ELF's ident padding and tar's name-field zero-padding give the two a
long shared run of `0x00` in bytes 531; they differ in only ~3 magic bytes,
and 32 equally-weighted positions let ~20 shared zeros outvote 3 real ones. No
β both separates ELF/tar and keeps the other formats whole. The honest v1
position: this is the *same* "tar is hard" reality §4b flagged, just wearing a
different mask. **Fix (v2):** weight positions by inverse entropy so a
low-information shared-zero run stops dominating a few high-information magic
bytes — this generalizes beyond tar and is the highest-value next lever.
2. **You cannot cold-start every point in the background.** A natural reading of
§4a/§5 is "everything starts in the junk drawer, real clusters condense out."
That **deadlocks**: at a format's first file a fresh cluster and the background
are *both* uniform, so with the `bg_mass ≥ α` that §4a needs for absorption,
the background always wins and no cluster is ever seeded. Fix: **initialize
every file in its own singleton**; same-format singletons merge and snowball,
while a lone random-blob singleton dissolves on resample and is reclaimed by
the (stickier) background. Absorption still works — just not as the *initial*
state.
3. **Two pieces of math that look optional but aren't.** (a) Signature peakedness
is a **Bernoulli** question ("is this position fixed to byte v?"), so it uses a
2-way posterior `(count+β)/(members+2β)`, **not** the 257-way mixture
predictive — the alphabet-wide denominator drags even a unanimous position
below 0.9 once β<1, which would make promotion *impossible*. (b) Ranking Gibbs
restarts needs the **collapsed Dirichlet-multinomial marginal** (with its
`loggamma` normalizer / Occam penalty); a plain product-of-predictives score
omits the penalty and actively **rewards merging** everything into one blob
(observed, then fixed).
### Known v1 limitations (accepted)
- **β=0.1 over-splits** PDF and JPEG into several *pure* sub-clusters (e.g. PDF by
version byte). This costs completeness/ARI but not the mission: each sub-cluster
still carries valid magic and promotes independently, and a human dedupes
overlapping `%PDF-1.x` nominations at the gate.
- The point partition is the **best of N Gibbs restarts by marginal likelihood**,
a MAP-style stand-in for the VI/Binder posterior summary §5 defers adequate
because the formats are strongly separated; revisit if compaction 5) needs it.
- Phase B's **live single-owner batch process** 9) and the durable catalog file
are implemented in `src/catalog.jl` (the `Catalog` durable state, the
incremental `catalog_sweep!`, the offline `compact!` seed/recompaction, and
`write_nominations!`), driven by `bin/cluster_sweep.jl` (cron/periodic; the
first run auto-compacts to seed, subsequent runs sweep incrementally). The
catalog is persisted with the stage-2 sidecar-first tempfsyncrenamefsync-dir
discipline. As §5B predicts, under the calibrated `bg_mass > α` the live sweep
never mints single-file clusters new formats are discovered by the offline
`compact!` re-clustering the background residue, not by the live path.
## Open items (deferred, intentionally)
- **v2, now top priority: inverse-entropy position weighting** (unblocks ELF/tar
and any format pair that shares a long constant run see §11).
- v2: tail-window block; sparse deep-offset probe (tar-class).
- v3: sub-clustering structureless high-entropy residue (needs entropy/histogram
feature, not header bytes).
- Later tier: docx-vs-zip split via variable-offset central-directory names.
- Periodic Lux retrain from accumulated signature-labeled files.