argus-quarry — Design Doc (Draft)
argus-quarry 0.4.1.dev5+g9370dbd @ 9370dbdPyPI (opens in new tab)Source (opens in new tab)Changelog (opens in new tab)
Imported from DESIGN.md on argus-quarry's dev branch at that commit. Fixes go upstream, not here.
Status: implemented (released through v0.3.x): CLI, ingest, provenance store, read-only server and the public-domain archives. Still pending: the Phase 2/3 downloaders (LoC, Smithsonian, Rijksmuseum, LAC, Europeana, Flickr Commons) and the Wikidata SPARQL subject harvester. The rest of this doc keeps its original design rationale; where the build diverged, the section says so. Owner: smk762 · Suite: Argus · Sibling of
argus-lens,argus-curator,argus-studio.
The quarry is where the suite digs up raw material. argus-quarry acquires
public-domain / CC0 images from upstream archives and lands them — with full
provenance and licensing — into a folder the rest of the Argus suite already
knows how to consume (DATASET_DIR → /data/images).
Subjects are grouped into LoRA-training categories — identity (people),
wardrobe (garments), setting (scenes/environments) and concept
(styles/themes) — and land sorted into <category>/<subject>/ subfolders. A
category is independent of identity: a subject can be a person, a garment, a
scene, or a visual concept.
It is deliberately lean: an acquisition + provenance tool, nothing more.
Everything downstream (quality scoring, near-dup, faces, embeddings, selection,
captioning, viewing) is already owned by argus-curator and argus-lens, and
argus-quarry must not re-implement it.
1. Where it fits in the suite
argus-quarry (NEW) argus-curator (:8101) argus-lens (:8100) imogen / kohya
─ download ─┐ ─ scan + score ─┐ ─ caption ─┐ ─ train ─
─ verify ─┤ images + ─ near-dup ─┤ manifest ─ buckets ─┤ dataset ─ LoRA ─
─ provenance┤ provenance ─ face-cluster ─┤ ─ (ident/ ─┴──────────► ───────►
─ SHA256 ─┴───────────► ─ select+export ─┴──────────► wardrobe)
/data/images (DATASET_DIR) ───────────────────────────────►
argus-quarry sits upstream of everything. It is a producer of
DATASET_DIR; curator and lens are the consumers. The only integration
surface is the shared images folder plus a new gallery compose profile —
the exact loosely-coupled pattern the suite already uses.
2. Scope
In scope (the genuinely new capability)
- Source downloader modules — one per archive, behind a common contract.
- Provenance & licensing capture — never lose source URL, licence, or attribution. This is the reason the tool exists ("provenance-first").
- Resumable, rate-limited, retrying downloads with integrity verification.
- Exact dedup at ingest — SHA256 only. Skip bytes we already have.
- Provenance database — SQLite:
subjects+photographs. - Category-aware subject seeds (identity / wardrobe / setting / concept).
- A thin, category-sorted folder layout that lands cleanly as
DATASET_DIR.
Out of scope (delegated — do NOT rebuild)
Net effect vs. the original brief: the quality table and all CV
(quality/faces/embeddings) sections are dropped from quarry. The DB shrinks to
subjects + photographs. phash is optional metadata only (recorded if cheap,
never the basis of quarry's dedup — SHA256 is). Search is provenance/licence
oriented, not quality/ranking oriented.
3. Repo layout (mirrors argus-curator conventions)
argus-quarry/
├── pyproject.toml # hatchling, src layout, optional-dependency extras
├── Makefile # help/install/dev/lint/fmt/test/build/smoke
├── Dockerfile
├── README.md
├── DESIGN.md # this file
├── LICENSE # MIT
├── src/argus_quarry/
│ ├── __init__.py # exports + __version__
│ ├── py.typed
│ ├── models.py # SourceRecord, Subject, Photograph (pydantic)
│ ├── store.py # SQLite provenance DB (sqlite3 stdlib, WAL)
│ ├── ingest.py # download → verify → SHA256 dedup → land → record
│ ├── net.py # httpx client: rate limit, retry/backoff, resume
│ ├── config.py # QuarryConfig: per-source settings, resolution + total-GB caps
│ ├── subjects.py # load per-category seed lists; optional Wikidata SPARQL harvester
│ ├── cli.py # typer app: run / fetch / export / list / stats / verify / subjects / serve / archives
│ ├── seeds/
│ │ ├── identity.yaml # curated persons seed (name, wikidata_id, aliases)
│ │ ├── wardrobe.yaml # garments / outfits
│ │ ├── setting.yaml # scenes / environments
│ │ └── concept.yaml # styles / themes / objects
│ ├── downloaders/
│ │ ├── __init__.py # registry (name -> Downloader)
│ │ ├── base.py # Downloader protocol / ABC -> yields SourceRecord
│ │ ├── commons.py # Wikimedia Commons (Phase 1)
│ │ ├── loc.py # Library of Congress (Phase 2)
│ │ ├── smithsonian.py # Smithsonian Open Access (Phase 2)
│ │ ├── rijksmuseum.py # Rijksmuseum Open Data (Phase 2)
│ │ ├── lac.py # Library & Archives Canada (Phase 2, Karsh)
│ │ ├── europeana.py # Europeana (Phase 3, rights-messy)
│ │ └── flickr.py # Flickr Commons (optional) (Phase 3)
│ ├── archives/ # PD12M / Megalith-10m metadata search (DuckDB index; semantic.py: CLIP vectors), Megalith mirror offsets — see §9
│ └── server/ # read-only provenance API (`serve`, :8102) — see §9
└── tests/
Package name argus_quarry, distribution argus-quarry, CLI entrypoint
argus-quarry (Typer), structlog for logging, pydantic v2 for models —
identical toolchain to curator so the suite stays consistent.
4. The common contract: SourceRecord
Every downloader is source-independent because it yields the same object. The rest of the pipeline never learns which archive a file came from.
class SourceRecord(BaseModel):
# subject / category
subject: str # canonical folder name, e.g. "Albert_Einstein" | "Red_dress"
category: str = "identity" # identity | wardrobe | setting | concept
# identity-only metadata (None for non-person subjects)
wikidata_id: str | None = None
birth_year: int | None = None
death_year: int | None = None
occupation: str | None = None
# the asset
title: str | None = None
photographer: str | None = None
year: int | None = None
remote_url: str # full-resolution source URL
# provenance / licence (NEVER optional in spirit — this is the point)
source: str # "commons" | "loc" | ...
source_url: str # human-facing landing page
licence: str # "PD" | "CC0" | "PD-US" | ...
attribution: str | None = None # required credit line if any
Downloader.harvest(subject) -> Iterator[SourceRecord] streams candidates
(searching with subject.query, stamping each record's category);
ingest.py turns each into bytes on disk + a DB row (idempotently), landing
under <category>/<subject>/. (PortraitRecord / Person remain importable as
backward-compatible aliases of SourceRecord / Subject.)
The subject seeds (Q2, resolved: hybrid + per-category)
The subject list is decoupled from the downloaders. subjects.py supplies the
subjects each downloader harvests around, one curated YAML per category, from
two interchangeable sources:
- Curated seeds (default, deterministic):
seeds/<category>.yaml— small, hand-maintained lists (name, optionalsearch,aliases; pluswikidata_id/ birth / death foridentity). A downloader searchesSubject.queries:search(elsename), then each alias, paging each one until--limitcandidates per subject are found. This is what dev/QA runs against so results are reproducible and licence-safe.load_subjects()merges all categories;--categoryrestricts to one. - Wikidata SPARQL harvester (optional,
--from-wikidata, identity only): query "humans with a Commons portrait" (+ filters like occupation / death-year for PD likelihood) to scale toward the 5–7k target. Cached underQUARRY_HOME/cache.
Both resolve to the same Subject shape, so downloaders never care which was
used. Seeds ship in Phase 1; SPARQL harvester lands in Phase 2.
5. Data model (SQLite)
Two tables. Provenance-first; no CV columns.
subjects
id · name · category · wikidata_id · birth_year · death_year · occupation
(unique on (name, category); identity-only columns are NULL elsewhere)
photographs
id · subject_id (fk) · category · title · photographer · year · source · source_url · licence · attribution · width · height · file_size · filename · sha256 (unique) · phash (nullable, informational) · remote_url · fetched_from (nullable) · status · downloaded_at
sha256isUNIQUE→ exact-dup ingest is a no-op (idempotent reruns).statustracks resumability:pending | downloading | complete | failed, plus the terminalduplicate(identical bytes already landed) andquarantined(no accepted licence) states.phashrecorded opportunistically (cheap with Pillow+ImageHash) but never drives dedup here — that's curator's job.- SQLite in WAL mode; single writer, safe concurrent readers.
Deliberately no quality table (dropped from the brief — see §2).
6. Folder structure produced
Two-stage layout: quarry fetches into a raw pool it fully owns, then
export publishes a clean, curator-ready tree into DATASET_DIR.
$QUARRY_HOME/ # sibling ./quarry — side-car state, NEVER scanned
├── images/ # the RAW POOL — every byte quarry has landed
│ ├── identity/
│ │ └── Albert_Einstein/
│ │ ├── albert_einstein_1921_commons_<sha8>.jpg
│ │ └── ...
│ ├── wardrobe/Red_dress/...
│ ├── setting/Modern_kitchen/...
│ └── concept/Cyberpunk/...
├── metadata/portraits.sqlite
├── cache/ # HTTP cache / partial downloads (resume)
├── logs/
└── thumbnails/ # OPTIONAL; curator makes its own previews
$DATASET_DIR/ # == /data/images — PUBLISHED view (via `export`)
├── identity/Albert_Einstein/ # symlinks (default) or copies into the pool
│ └── albert_einstein_1921_commons_<sha8>.jpg -> $QUARRY_HOME/images/...
├── wardrobe/Red_dress/...
└── ...
Key decisions (Q4 + Q5, resolved):
QUARRY_HOMEis a sibling./quarrydir, fully outside the image tree, so the DB/cache/logs a curator scan would choke on are never in view.- Images land in the raw pool first, then
argus-quarry exportbuilds the<category>/<subject>/tree inDATASET_DIR— symlink by default (cheap, no duplication),--copywhen a mount can't cross the boundary. This keeps quarry's provenance-complete pool separate from the curated view: you can re-publish a subset (e.g. onlylicence = CC0) without re-downloading, and a curator scan only ever sees clean images.
7. Downloader requirements
Each source module must:
- Resume interrupted downloads (partial-file +
statusin DB). - Skip anything already
complete(byremote_url/ expectedsha256). - Respect rate limits (per-source token bucket in
net.py; politeUser-Agent). - Retry transient network errors with exponential backoff + jitter.
- Verify integrity (content-length, decodes as an image via Pillow).
- Record licence + attribution — a record with no licence is quarantined, not landed.
- Prefer high resolution within a configurable cap (Q3): request the largest
rendition the API offers, but downscale past a per-file ceiling
(
QuarryConfig.max_megapixels, default 12 MP, envQUARRY_MAX_MEGAPIXELS). As built, only the megapixel cap is enforced:max_file_bytesexists inQuarryConfigbut ingest does not pass it to the downloader, since a byte cap on the download would reject large originals that the megapixel cap can downscale instead. The full-resolutionremote_urlis always kept in the DB, so a capped image can be re-fetched at original size on demand without losing provenance. Keeps the archive inside the 20–40 GB budget by default while never throwing away the ability to go bigger. - Respect a total-archive budget (
QuarryConfig.max_total_gb, envQUARRY_MAX_GB, default e.g.40;0/unset = unlimited): before each write, check the current raw-pool size (QUARRY_HOME/images, tracked incrementally fromphotographs.file_sizeso it's O(1), not a directory walk). When the next file would exceed the ceiling, stop the run cleanly — mark remaining candidatespending(resumable later if the cap is raised), log abudget_reachedevent, and exit non-error. This bounds disk use predictably for dev/QA regardless of how many sources/subjects are queued. - Log all failures (structlog →
logs/), never crash the whole run.
Everything is idempotent: rerunning fetch resumes/repairs, never duplicates.
8. Suite integration (compose)
Add a gallery profile to the demo's compose.yaml. It's a run-to-completion
job (not a long-lived server). Quarry fetches into its own pool
($QUARRY_HOME/images) and then publishes into DATASET_DIR — both mounts are
present so a single up can fetch-then-export:
argus-quarry:
profiles: ["gallery"]
build:
context: ../argus-quarry
image: argus-quarry:latest
environment:
- QUARRY_HOME=/data/quarry
- QUARRY_MAX_GB=${QUARRY_MAX_GB:-40} # total raw-pool ceiling; 0 = unlimited
- COMMONS_USER_AGENT=${COMMONS_USER_AGENT:-argus-quarry/0.1 (contact@example.com)}
volumes:
- ${QUARRY_HOME:-./quarry}:/data/quarry # raw pool + db/cache/logs
- ${DATASET_DIR:-./data}:/data/images # published (curator-ready) view
# fetch into the pool, then publish a symlinked tree into DATASET_DIR
command: ["run", "--source", "commons", "--limit", "500", "--export", "--licence", "CC0,PD"]
restart: "no"
Symlinks only resolve inside the container if both targets are mounted; since the published tree points back into
/data/quarry/images, the curator/lens containers must also mountQUARRY_HOMEor quarry should publish with--copy. Simplest for the suite: curator/lens add the same${QUARRY_HOME}:/data/quarryread-only mount. Documented in.env.example.
Usage stays true to the suite's profile idiom:
docker compose --profile gallery up --build # fetch -> pool -> publish DATASET_DIR
docker compose --profile curator up --build # then curate the published view
New .env knobs (documented in .env.example): QUARRY_HOME (default
./quarry), QUARRY_MAX_GB (total raw-pool ceiling, default 40, 0 =
unlimited), per-source API keys / contact User-Agent strings (Commons and LoC
want a real UA; Rijksmuseum and Europeana need API keys).
9. On the "local viewer"
The brief asks for a Flask/FastAPI viewer. That overlaps with the existing Next.js frontend, so:
- Phase 1–2: no standalone UI.
argus-quarry stats/liston the CLI is enough to inspect provenance. - Later (optional) — now built: a tiny read-only FastAPI
server/(argus-quarry serve, theserverextra,:8102) exposing provenance queries:/health,/ready,/stats,/subjects,/photos(filter by subject / category / source / licence / status, paginated),/photos/{id}and a/thumbWEBP renderer over the pooled files. The demo frontend surfaces it as the/galleryroute — consistent with how/curatealready talks to curator. Strictly read-only: no mutation endpoints. - Public-domain archives (2026-10):
/archives/*search the local metadata of PD12M and Megalith-10m (a DuckDB index undercache/archives/) and plan slices. This adds the server's one write route, deliberately:POST /archives/{name}/fetchlands a slice throughIngestEngine(the same licence-gate → dedup → land path asfetch). It is off unlessQUARRY_ALLOW_FETCH=1; when off, the UI downloads the slice JSON and the operator runsargus-quarry archives fetch slice.json. The provenance routes below remain strictly read-only. - Megalith mirror (2026-11, #16): Flickr photos deleted since Megalith was
built return 404/410/403.
archives mirror-indexrecords where each image sits in the Drawthings mirror's tar shards, and ingest falls back to a byte-range read of that member. The fallback is source-independent (SourceRecord.alternates), andfetched_fromnames the mirror that served the bytes.remote_urlis never rewritten: it stays the full-resolution primary and the resume key, so resume doesn't depend on the mirror index or its pinned revision. - Semantic archive search (2026-11, #15): PD12M's published CLIP ViT-L/14
embeddings get a vector index next to the DuckDB one (
archives/semantic.py): FAISS IVF-PQ for candidates, plus a local float16 copy for exact rescoring, because the archived.npyfiles sit on a NAS where a random row costs ~40 ms.items.posmaps a hit back to its item. It is the row in the metadata files concatenated in sorted-filename order, which matches the embeddings: on 88 sampled rows, caption↔image cosine was 0.27 aligned against 0.09 one row off. Filters run inside the vector search as a FAISS bitmap selector, so ranking never post-filters a page empty. The text tower is an optional, lazy extra ([search]) so the default install stays torch-free. No sibling serves CLIP text embeddings, and quarry does not depend on cortex. This is ranking only: it records nothing and never drives dedup (that is stillsha256, and near-dup selection is curator's).
Strictly read-only means the store, not just the routes. The server opens the
pool via ProvenanceStore.open_readable(), which never mkdirs, never migrates
and never relocates files — so an unmigrated or absent pool reports 503
instead of being silently created or upgraded by a GET. It prefers a mode=ro
URI (which tracks a live fetch through the WAL) and falls back to
immutable=1 only on a read-only directory where the -shm sidecar cannot be
created, and only when no -wal exists for that mode to read past. The same
entry point backs the read-only CLI commands, so stats / list / export /
verify work against a :ro $QUARRY_HOME too (issue #5).
Liveness (/health) and readiness (/ready) are split. /health is pure
liveness — 200 while the process answers, no DB dependency — so a container
mounted at an empty QUARRY_HOME that is seeded later (argus-halo restores the
pool from tape after boot) is live from first request. /ready opens the pool
and 503s until it is serveable, which is the signal an orchestrator routes on.
Folding readiness into /health made a data-less image permanently unhealthy
and blocked the release smoke test (issue #10).
This keeps us to one real UI (the demo) instead of maintaining a second.
10. Licensing / feasibility notes (for the 5–7k, 20–40 GB target)
- Reliable PD/CC0 with real APIs: Wikimedia Commons, Library of Congress, Smithsonian Open Access, Rijksmuseum Open Data. Start here.
- Messier rights: Europeana and Flickr Commons mix licences per-item — the downloader must read per-record rights and quarantine anything not clearly PD/CC0.
- LAC / Karsh: many Karsh works are not PD (photographer d. 2002); treat as a curated allow-list, not a bulk scrape.
- For dev/QA this dataset is plenty; do not advertise uniformly clean licences across every source — enforce it per-record instead.
11. Design principles (unchanged from the brief, enforced by the above)
Modular · source-independent (SourceRecord) · idempotent (SHA256 + status)
· extensible (downloader registry) · category-sorted (identity / wardrobe /
setting / concept) · reproducible · provenance-first (a record with no
licence never lands) · optimised as the input to the suite's existing
CV/search/curation stages rather than duplicating them.
12. Phased delivery
Phase 1 — walking skeleton
pyproject.toml(extras:cli,server,dev),Makefile,Dockerfile.models.SourceRecord,store(SQLitesubjects+photographs, WAL, migrations).net(rate limit + retry + resume),ingest(download→verify→cap→SHA256→pool→record,<category>/<subject>/).subjects.py+ per-categoryseeds/*.yaml(identity / wardrobe / setting / concept) curated loaders.downloaders/commons.py(Wikimedia Commons).export(symlink/--copypublished tree, with--licence/--categoryfilters).- Typer CLI:
run(fetch+export),fetch,export,list,stats,verify,subjects. gallerycompose profile wired intoargus-studio/compose.yaml.
Phase 2 — breadth
loc,smithsonian,rijksmuseum, curatedlac(Karsh allow-list) downloaders.- Wikidata SPARQL identity harvester (
subjects --category identity --from-wikidata); incremental update mode. - Opportunistic
phashmetadata (informational only).
Phase 3 — polish (optional)
europeana,flickrwith strict per-record rights filtering.- Read-only provenance FastAPI +
/galleryroute in the demo frontend.
13. Resolved decisions
- Name —
argus-quarry(acquisition connotation, no clash with the frontend's "viewing"). ✅ - Subject list — hybrid + per-category: curated
seeds/<category>.yaml(identity / wardrobe / setting / concept) for deterministic dev/QA (Phase 1), plus an optional Wikidata SPARQL harvester (identity) to scale toward 5–7k (Phase 2). See §4. ✅ - Resolution — configurable per-file cap (12 MP default; megapixels only, see §7),
full-res
remote_urlretained for on-demand re-fetch; overridable per run. Keeps the 20–40 GB budget without discarding fidelity. See §7. ✅ QUARRY_HOME— sibling./quarrydir, fully outside the image tree. See §6. ✅- Landing — raw pool + export: fetch into
QUARRY_HOME/images, thenexportpublishes a<category>/<subject>/tree intoDATASET_DIR(symlink default,--copyfallback), with optional--licence/--categoryfilters. See §6/§8. ✅ - Categories — subjects are grouped into identity / wardrobe / setting /
concept and land in
<category>/<subject>/subfolders, so one pool serves multiple LoRA-training workflows. See §4/§6. ✅
Follow-ups that surfaced while resolving
- Symlink cross-mount: for the published symlink tree to resolve inside
curator/lens containers, they must also mount
QUARRY_HOMEread-only, else quarry publishes with--copy. Needs a one-line.env.example+ compose note when wiring thegalleryprofile (already flagged in §8). - Wikidata → PD likelihood: SPARQL harvester should pre-filter on death-year / country to reduce quarantines, but licence is still enforced per-record at ingest (never trust the query alone).