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.
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:
- Generates
ncandidate images (the artist — a pluggableBackend). - Scores each one against the prompt (the judge — a pluggable
Scorerthat returns a number; higher is better). - 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).
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:
| Feature | Adds | Runtime |
|---|---|---|
| (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) |
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.ppmThe CLI has two subcommands: generate (best-of-N) and similar
(find look-alike images). If you don't have just,
cargo install just or use the cargo run -p eligo-cli -- … forms below.
With both features on, eligo generates actual Stable Diffusion images and keeps the one CLIP judges best:
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).
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:
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.
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:
cargo run -p eligo-cli --features clip -- similar \
query.png ./photos -k 5 \
--clip-model clip.onnx --clip-tokenizer tokenizer.jsonmost similar to query.png:
1.0000 ./photos/query.png # itself
0.9238 ./photos/other_a.png
0.9101 ./photos/other_b.png
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:
/// The artist: prompt + seed → image.pubtraitBackend{fngenerate(&self,prompt:&str,seed:u64) -> Result<Image>;}/// The judge: prompt + image → reward (higher is better).pubtraitScorer{fnscore(&self,prompt:&str,image:&Image) -> Result<f32>;}pubfnbest_of_n(backend:&dynBackend,scorer:&dynScorer,cfg:&GenerateConfig)
-> Result<Selection>;| You want to… | Do this |
|---|---|
| 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 |
Return an RGB8 [Image]; the loop handles seeding (candidate i gets
seed + i) and selection for you.
use eligo::{Backend,Image,Result};structMyApiBackend{client:MyClient}implBackendforMyApiBackend{fngenerate(&self,prompt:&str,seed:u64) -> Result<Image>{let pixels = self.client.txt2img(prompt, seed)?;// your model / serviceImage::new(width, height, pixels)// RGB8, row-major}}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.
use eligo::{Scorer,Image,Result};/// Prefer images that are mostly *not* dark.structPreferBright;implScorerforPreferBright{fnscore(&self,_prompt:&str,image:&Image) -> Result<f32>{let mean = image.rgb.iter().map(|&b| b asf32).sum::<f32>()
/ image.rgb.len()asf32;Ok(mean / 255.0)// 0..1, brighter = higher}}Drop either into the same loop:
use eligo::{best_of_n,GenerateConfig};let selection = best_of_n(&MyApiBackend{ .. },&PreferBright,&GenerateConfig::new("a sunset"))?;println!("winner seed = {}", selection.best().seed);ClipEmbedder is factored out so you can use the embeddings directly — no need
to go through the scorer:
use eligo::{ClipEmbedder, cosine_similarity};let embedder = ClipEmbedder::from_files("clip.onnx","tokenizer.json")?;let a = embedder.embed_image(&img_a)?;// image → L2-normalized vectorlet 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.
| Item | Use |
|---|---|
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 |
just check-all runs the exact gate CI enforces — formatting, clippy
(-D warnings), tests, and docs — before you push.
| Task | Command |
|---|---|
| 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 for the milestone history (M0 loop → M1
judge → M2 artist → M3 quality → M4 embeddings/similarity) and the explicit
non-goals.
- Update
CHANGELOG.mdunder a new## [x.y.z]heading and commit. just release x.y.z— bumps versions, tags, and pushes.- CI builds binaries for macOS (arm64 + x86_64), Linux, and Windows, and publishes a GitHub Release with checksums and the changelog notes.
- To also publish to crates.io:
PUBLISH=1 just release x.y.z.
Licensed under either of Apache License, Version 2.0 or MIT license at your option.

