# eligo

**Name:** *eligo* is Latin for **"I choose / I pick out"** — the root of *elect*
and *elite*. It names the tool's one job: out of many candidate images, **elect
the best one.**
[](https://crates.io/crates/eligo)
[](https://docs.rs/eligo)
[](https://github.com/vbasky/eligo/actions)
[](#license)
[](https://www.rust-lang.org)
---
## What it does
Image generators are random — every attempt comes out different, some good, some
junk. The usual fix is to make several and let a human pick. **eligo automates
the picking.**
Give it a prompt and it:
1. **Generates** `n` candidate images (the *artist* — a pluggable `Backend`).
2. **Scores** each one against the prompt (the *judge* — a pluggable `Scorer`
that returns a number; higher is better).
3. **Selects** the highest-scoring candidate and returns it.

That generate → score → select loop is the smallest honest **agentic** pattern:
a numeric reward drives a decision. An optional bounded re-roll regenerates the
single worst candidate once — and that's the only loop; there is no open-ended
"keep refining."
**Scope is deliberately bounded.** eligo owns the loop and the two contracts
(`Backend`, `Scorer`). It is not a model zoo, not an editor, and not a
recommendation service — but its parts are the foundation you build those on
(see [Extending eligo](#extending-eligo)).
## Install / layout
```bash
crates/
eligo/ # library: the loop, traits, scorers, embedder
eligo-cli/ # binary (clap), installs as `eligo`
```
The default build needs **no AI models and no native runtime** — it ships a
deterministic mock backend and scorer so the whole loop runs and tests green out
of the box. Real models are opt-in cargo features:
| *(default)* | mock backend + mock scorer | none |
| `clip` | `ClipScorer` (the real judge) + `ClipEmbedder` | ONNX Runtime (`ort`) |
| `sd` | `SdBackend` — Stable Diffusion txt2img (the real artist) | ONNX Runtime (`ort`) |
## Quick start
```bash
just build
just test
# Mock loop — no models, instant. Generate 5, keep the best, write the winner:
just run -- generate "a lighthouse at dusk" -n 5 --reroll-worst --out winner.ppm
```
The CLI has two subcommands: **`generate`** (best-of-N) and **`similar`**
(find look-alike images). If you don't have [`just`](https://github.com/casey/just),
`cargo install just` or use the `cargo run -p eligo-cli -- …` forms below.
## The full thing: real images, real selection
With both features on, eligo generates actual Stable Diffusion images and keeps
the one CLIP judges best:
```bash
cargo run -p eligo-cli --features "sd clip" -- generate \
"a photograph of a red apple on a wooden table" -n 4 --steps 20 \
--sd-model-dir <sd-onnx-dir> --sd-tokenizer <tokenizer.json> \
--clip-model <clip.onnx> --clip-tokenizer <tokenizer.json> \
--out winner.png --save-all
```
- **`--features sd`** — the artist (`SdBackend`): turns the prompt into images.
- **`--features clip`** — the judge (`ClipScorer`): scores each against the prompt.
- **`--quality-weight 0.3`** — also reward sharp, clean images (see below).
- **`--save-all`** — write every candidate, not just the winner, so you can see
what the judge chose between.
Models are standard ONNX exports: a diffusers `text_encoder` / `unet` /
`vae_decoder` directory for SD, and a CLIP `model.onnx` + `tokenizer.json`. Both
are validated end-to-end in `crates/eligo/tests/{sd_real,clip_real}.rs` (ignored
by default; pointed at weights via env vars).
## Scoring beyond the prompt: no-reference quality
CLIP answers *"does this match the words?"* — but a blurry image can still match
the words. The `quality` signal (always in the core, no model needed) answers
*"does it look good?"* using sharpness + contrast. Blend the two:
```rust
use eligo::{ClipScorer, QualityWeighted};
let clip = ClipScorer::from_files("clip.onnx", "tokenizer.json")?;
// 70% prompt-match, 30% image quality:
let scorer = QualityWeighted::new(Box::new(clip), 0.3);
```
On the CLI that's `--quality-weight 0.3`. Raising it makes eligo prefer crisp,
detailed candidates even at a slight cost to literal prompt-match.
## Find similar images (`similar`)
The same CLIP embeddings that judge prompt-match also measure *image↔image*
similarity — the basis for "more like this", dedup, and content-based
recommendations. `ClipEmbedder::embed_image` turns an image into a vector;
nearby vectors are look-alikes. The `similar` subcommand ranks a folder against
a query image:
```bash
cargo run -p eligo-cli --features clip -- similar \
query.png ./photos -k 5 \
--clip-model clip.onnx --clip-tokenizer tokenizer.json
```
```text
most similar to query.png:
1.0000 ./photos/query.png # itself
0.9238 ./photos/other_a.png
0.9101 ./photos/other_b.png
```
---
## Extending eligo
eligo is built around two small traits. Everything else — real models, quality
blending, similarity — is an implementation of one of them, or a reuse of the
embedder. Here is the whole surface you extend against:
```rust
/// The artist: prompt + seed → image.
pub trait Backend {
fn generate(&self, prompt: &str, seed: u64) -> Result<Image>;
}
/// The judge: prompt + image → reward (higher is better).
pub trait Scorer {
fn score(&self, prompt: &str, image: &Image) -> Result<f32>;
}
pub fn best_of_n(backend: &dyn Backend, scorer: &dyn Scorer, cfg: &GenerateConfig)
-> Result<Selection>;
```
| Use a different generator (SDXL, Flux, a diffusion API, even a non-AI renderer) | implement **`Backend`** |
| Change what "best" means (aesthetics, face presence, brand-safety, NSFW filter, OCR legibility, palette) | implement **`Scorer`** |
| Combine several rewards | wrap with **`QualityWeighted`**, or write a composing `Scorer` |
| Build "more like this", dedup, or search | use **`ClipEmbedder::embed_image`** + `cosine_similarity` |
| Power a recommendation engine / media catalogue | embed assets once, store the vectors, do nearest-neighbour lookups *outside* eligo |
| Add a new no-reference metric | sit it next to `quality_score` and blend it in |
### 1. A custom backend (your own artist)
Return an RGB8 [`Image`]; the loop handles seeding (candidate *i* gets
`seed + i`) and selection for you.
```rust
use eligo::{Backend, Image, Result};
struct MyApiBackend { client: MyClient }
impl Backend for MyApiBackend {
fn generate(&self, prompt: &str, seed: u64) -> Result<Image> {
let pixels = self.client.txt2img(prompt, seed)?; // your model / service
Image::new(width, height, pixels) // RGB8, row-major
}
}
```
### 2. A custom scorer (your own definition of "best")
Anything you can turn into a number is a reward. The prompt is provided in case
you want it; ignore it for prompt-independent rewards.
```rust
use eligo::{Scorer, Image, Result};
/// Prefer images that are mostly *not* dark.
struct PreferBright;
impl Scorer for PreferBright {
fn score(&self, _prompt: &str, image: &Image) -> Result<f32> {
let mean = image.rgb.iter().map(|&b| b as f32).sum::<f32>()
/ image.rgb.len() as f32;
Ok(mean / 255.0) // 0..1, brighter = higher
}
}
```
Drop either into the same loop:
```rust
use eligo::{best_of_n, GenerateConfig};
let selection = best_of_n(&MyApiBackend { .. }, &PreferBright, &GenerateConfig::new("a sunset"))?;
println!("winner seed = {}", selection.best().seed);
```
### 3. Similarity & recommendations (reuse the embedder)
`ClipEmbedder` is factored out so you can use the embeddings directly — no need
to go through the scorer:
```rust
use eligo::{ClipEmbedder, cosine_similarity};
let embedder = ClipEmbedder::from_files("clip.onnx", "tokenizer.json")?;
let a = embedder.embed_image(&img_a)?; // image → L2-normalized vector
let b = embedder.embed_image(&img_b)?;
let how_alike = cosine_similarity(&a, &b); // in [-1, 1]
// or: embedder.image_similarity(&img_a, &img_b)?
```
To build a recommender on top, the clean split is: **eligo provides the
embedding and the similarity math**; the consuming catalogue embeds each asset
once, **stores** the vectors, keeps a nearest-neighbour **index** (brute-force
cosine for thousands; an HNSW index for tens of thousands+), and adds any
**per-user** signals. Storage, indexing, and personalization stay out of eligo
so it remains a focused selection library.
### Reusable parts
| `Image` | RGB8 buffer; `Image::open` / `Image::save_png` (with `clip`/`sd`) |
| `cosine_similarity`, `l2_normalize` | vector math for any embedding |
| `quality_score` / `QualityScorer` | no-reference sharpness/contrast quality |
| `mock::{MockBackend, MockScorer}` | deterministic stand-ins for tests |
## Development
`just check-all` runs the exact gate CI enforces — formatting, clippy
(`-D warnings`), tests, and docs — before you push.
| Format | `just fmt` |
| Lint | `just lint` |
| Test | `just test` |
| Test a feature | `cargo test -p eligo --features clip` |
| Docs | `just docs` |
| Dependency audit | `just deny` (needs `cargo install cargo-deny`) |
See [`docs/ROADMAP.md`](docs/ROADMAP.md) for the milestone history (M0 loop → M1
judge → M2 artist → M3 quality → M4 embeddings/similarity) and the explicit
non-goals.
## Releasing
1. Update `CHANGELOG.md` under a new `## [x.y.z]` heading and commit.
2. `just release x.y.z` — bumps versions, tags, and pushes.
3. CI builds binaries for macOS (arm64 + x86_64), Linux, and Windows, and
publishes a GitHub Release with checksums and the changelog notes.
4. To also publish to crates.io: `PUBLISH=1 just release x.y.z`.
## License
Licensed under either of [Apache License, Version 2.0](LICENSE-APACHE) or
[MIT license](LICENSE-MIT) at your option.