Reality-first specification (v2.0.0, 2026-06-03). Describes what the image pipeline actually does in code today, reconciled against ora-project/. The original v1.1 design — a 6-layer per-invocation pipeline that searched public-domain/CC image commons before falling back to AI, with a five-screen visual-suitability gate, tier-asymmetric AI disclosure, and a placeholder+retry queue — is mostly not implemented. The real pipeline is a single AI render behind a deterministic ~22%/day salience budget, finished by a cream-panel post-processor and a frontmatter patch. The original design is preserved in the Design intent appendix and in vault git history (pre-rename, through commit 24ad52a44b, filename Framework — News Image Generator.md).

Display Name

MSI News Image Generator

Display Description

Produce the hero image for the subset of Main Street Independent articles that earn one under a daily salience budget. Runs AI-generation only (no commons search), gated up front by article_salience, executed by msi_image_render.render_news_image, finished by the msi_image_post_process cream-panel keyer, and written into the article’s frontmatter image: field. Editorial-cartoon images (Hector Rentier) are delegated to the cartoon path; their register is governed by MSI Hector Rentier Editorial Cartoon.


Implementation Status (read this first)

Spec concept (v1.1)Status todayWhere reality lives
Executor at orchestrator/msi_image_render.pyWrong pathora-project/tools/msi_image_render.py
Commons search (Unsplash/Pexels/Wikimedia/PD), Layer 2Not implemented — AI-onlyrender_news_image (commented “Layer 2 intentionally not implemented”)
6-layer structure❌ Collapsed to: budget gate → likeness strip → prompt → 1-retry generate → alt → post-process → patch
Five-screen suitability gate (likeness/PII/defamatory/misframing/register), Layer 4❌ Not implemented as output screenspartial pre-generation analog: negative-prompt vocab bans
Likeness gate on real individuals⚠️ Exists but effectively a no-op (entity list is always empty in the auto path)apply_likeness_gate
Tier-asymmetric AI disclosure (Tier1 discloses; Tier2/3 don’t)❌ Not implemented — every image carries the same disclosure stringrender_news_image image_schema
Placeholder + retry queue (not_before, retry cascade)❌ Does not existnearest analogs: sweeper self-heal; reconcile_dangling_images (strip)
imageSchema = 10 fields, schema-validated⚠️ Astro imageSchema is now {url, alt, source} only; the rest moved to bodysrc/content/config.ts:54
Salience budget (which articles get an image)Live (not in original spec)tools/article_salience.py
Cream-panel post-processing (key/pad/tint)Live (not in original spec)tools/msi_image_post_process.py
Style spec “not yet drafted / v1.0”❌ Stale — exists, cited as v1.6+ in codeReference — MSI Image Style Specification.md

The Real Pipeline

Who gets an image — the salience budget (tools/article_salience.py)

The original spec assumed every article gets an image (placeholder if not ready). In reality, only ~22% of a day’s articles are imaged. should_render_hero() is the single source of truth, shared by the worker-time gate and the batch sweeper:

  • Budget = daily_image_budget() = ceil(IMAGE_RATE × day_cluster_count) clamped to [MIN_BUDGET, MAX_BUDGET]. Defaults: IMAGE_RATE = 0.22 (MSI_IMAGE_RATE), MIN_BUDGET = 3, MAX_BUDGET = 25. The denominator is the day’s cluster count (stable up front), falling back to the published .md count.
  • Per-article decision is rank-based and order-independent: _ranked_top_cluster_ids() picks the day’s top-budget cluster_ids; an article is imaged iff its cluster_id is in that frozenset (cached per (date, budget)).
  • Salience = compute_salience(): a source_tier baseline + +0.40 if AP Top News + small multi-outlet-corroboration and AP-section-breadth bonuses (each capped ~+0.08) + +0.05 for primary-document sourcing, clamped to [0,1]. (There is no entity-richness term — it was removed as dead code.)
  • Source tiers (_DEFAULT_TIERS): a flat WIRE_TIER = 0.50 for wsj/bbc/guardian/npr/upi; AP deliberately low at 0.40 (it earns its score from the AP-Top-News flag, not a prior); DEFAULT_TIER = 0.30. An article must clear SALIENCE_MIN = 0.43.
  • Diversification (diversify_mode(), default on): a priority pass images headliners with salience ≥ PRIORITY_SALIENCE (0.68) regardless of source, then fills the remaining budget round-robin across sources (visit order rotated by a publish_date-derived offset) so no single outlet (historically WSJ) sweeps the budget.
  • Legacy mode (MSI_IMAGE_SALIENCE=legacy) restores the pre-2026-06-01 AP-Top-News-only behavior.

The hero-image gate (tools/backfill_orchestrator.py::_maybe_render_hero_image)

This is the real entry point — not orchestrator/msi_image_render.py. It runs as “Step 4.5” inside msi_engine.produce_article (import + call). Gate order:

  1. Reject if no slug / publish_date.
  2. Pre-2026 cutoff: if publish_date < 2026-01-01 (PAID_IMAGE_GEN_BOUNDARY_DATE), no paid image at all.
  3. Load the cluster JSON for salience signals.
  4. Skip-if-exists idempotency: if public/articles/<slug>.png already exists and is non-empty, don’t regenerate / don’t spend budget.
  5. Call article_salience.should_render_hero(...); bail with the decision reason if render is False.
  6. build_request_from_article(slug, fm) (from article_image_sweeper) builds the NewsImageRequest.
  7. Call render_news_image(request_data).

The render executor (tools/msi_image_render.py::render_news_image)

  1. Parse the NewsImageRequest (article_slug, article_headline, article_lede, visual_register, prompt_seeds, primary_entities, style_spec_version, source_path_strategy).
  2. Reject editorial_cartoon (that routes to render_hector_cartoon).
  3. apply_likeness_gate() — an entity may appear in the prompt only if is_public_figure AND in_public_role; otherwise its name is removed and stripped from the seeds. In the live auto path the entity list is always empty (build_request_from_article hardcodes primary_entities: []), so the gate is a defensive default, effectively a no-op unless a request is hand-authored.
  4. construct_news_prompt() — composes the locked §6 vocabulary (universal_positive + per-register vocab from PHOTOGRAPHIC_VOCAB/ILLUSTRATED_VOCAB/DIAGRAMMATIC_VOCAB) + seeds + negatives, including FORBIDDEN_CARICATURE_NEGATIVE (the antisemitic-trope / core-disgust ban list). These locked tables are the operationalized style spec.
  5. invoke_image_gen()capability_registry.invoke("image_generates", {prompt, aspect_ratio}); one retry on failure with a simplified seeds-only prompt, then fail (article ships text-only — no placeholder).
  6. Alt text = trim_alt_text(f"Illustration accompanying article: {headline}"), hard-capped at 125 chars.
  7. msi_image_post_process.process_png_bytes() (see below).
  8. Write PNG to public/articles/<article_slug>.png.
  9. Assemble the image_schema dict and _patch_article_image_field() rewrites the article frontmatter image: block.

The emitted news image_schema is fixed/templated: url: /articles/<slug>.png, alt, credit: "Main Street Independent (algorithmic)", source: "ai_generated", a constant disclosure string, ai_model (the provider that answered), ai_prompt (prompt[:200]), license (CC0). Note: the Astro imageSchema (src/content/config.ts:54) only requires/validates { url, alt, source }credit/caption/license/ai_prompt were moved inline into the body, so the renderer writes them to frontmatter but Astro neither requires nor validates them. The source enum adds press_release_licensed and commissioned.

Post-processing (tools/msi_image_post_process.py::process_png_bytes)

Runs once at generation (explicitly not idempotent): _square_pad() pads (never crops) non-square frames to 1:1 with transparent margins; _apply_transform() keys only near-pure-white desaturated pixels (brightness ≥ PAPER_THRESHOLD = 250) to alpha 0, preserves all tonal detail below that, and tints every pixel toward the page-cream hue #F4ECD8 (PAGE_CREAM). This is the cream-panel approach (a fixed cream tint, not a dark-mode invert — the invert approach in the file’s historical docstring is retired).

Cartoon path (delegated)

render_hector_image_only() / render_hector_cartoon() use a dedicated image_generates_cartoon capability slot, the same process_png_bytes, write to public/cartoons/<slug>.png, and emit a cartoon schema crediting “Hector Rentier (Main Street Independent, algorithmic)”. The register and visual vocabulary are governed by MSI Hector Rentier Editorial Cartoon; this framework only provides the shared executor. (The legacy SVG/potrace vectorization path still exists in the file but is no longer used.)

Other callers / maintenance

  • tools/article_image_sweeper.py::sweep_once — batch backfill of articles missing image:, using the same salience budget; newest-first; cost-capped (max_renders/daily_cap, default 20/20; optional max_age_days) after a runaway sweep once rendered 1,067 images; self-heals half-completed renders (frontmatter present but PNG missing).
  • tools/reconcile_dangling_images.py — the durable complement: a full-scan, zero-render strip of image: blocks whose url is dangling (hotlink or missing file) across src/content/articles + src/content/analyses. Leaves image: null alone. Idempotent; dry-run by default.
  • tools/msi_engine.py::render_image(request, register=...) — the universal engine seam; delegates news registers (photographic/illustrated/diagrammatic) to render_news_image, editorial_cartoon to the Hector path.

Code Seams (canonical references)

  • Render executor: ora-project/tools/msi_image_render.pyrender_news_image, apply_likeness_gate, construct_news_prompt, invoke_image_gen, trim_alt_text, _patch_article_image_field, _yaml_dump_image; locked vocab + FORBIDDEN_CARICATURE_NEGATIVE.
  • Hero gate (real entry): ora-project/tools/backfill_orchestrator.py_maybe_render_hero_image, _count_images_for_date, _is_ap_top_news, _rehome_prior_cluster_assets, PAID_IMAGE_GEN_BOUNDARY_DATE.
  • Engine seam: ora-project/tools/msi_engine.py::render_image (Step-4.5 call in produce_article).
  • Salience budget: ora-project/tools/article_salience.pyshould_render_hero, compute_salience, daily_image_budget, _ranked_top_cluster_ids, diversify_mode.
  • Post-processing: ora-project/tools/msi_image_post_process.pyprocess_png_bytes, _apply_transform, _square_pad, PAGE_CREAM, PAPER_THRESHOLD.
  • Batch + request derivation: ora-project/tools/article_image_sweeper.pysweep_once, build_request_from_article, _split_frontmatter.
  • Dangling-ref strip: ora-project/tools/reconcile_dangling_images.py::reconcile.
  • Slash commands: ora-project/scripts/news_image_render.py, ora-project/scripts/hector_render.py (registered in ora-project/ora-project.json).
  • Data contract: src/content/config.ts imageSchema ({url, alt, source}).
  • Style spec: Reference — MSI Image Style Specification.md (cited as v1.6+ in code).

Environment flags

MSI_IMAGE_RATE (0.22) · MSI_IMAGE_MIN_BUDGET (3) · MSI_IMAGE_MAX_BUDGET (25) · MSI_IMAGE_SALIENCE_MIN (0.43) · MSI_IMAGE_DEFAULT_TIER (0.30) · MSI_IMAGE_WIRE_TIER (0.50) · MSI_IMAGE_PRIORITY_SALIENCE (0.68) · MSI_IMAGE_SOURCE_TIERS · MSI_IMAGE_SALIENCE (=legacy switch) · MSI_IMAGE_DIVERSIFY (default on). Live values are environment-dependent — verify on the server.


Editorial intent still in force

The original framework’s editorial commitments remain the publication’s standard even where code doesn’t enforce them: complete and accurate attribution; alt text describing the image (not the article); visual bad-faith parity with textual bad-faith (no manipulated imagery, defamatory composite, or contextual misframing); affirmative authorization before any AI likeness of a real individual; and symmetric visual standards across pen names and political alignment. Today these are partially carried by pre-generation mechanisms — the locked positive/negative prompt vocabularies (notably FORBIDDEN_CARICATURE_NEGATIVE) and the (currently dormant) likeness gate — rather than the spec’s post-generation five-screen suitability gate, which is not implemented.


Appendix — Design intent (original v1.1, NOT implemented)

Retained as design intent; most of it does not run. Full text in vault git history (pre-rename, through commit 24ad52a44b, filename Framework — News Image Generator.md). The unbuilt design comprised:

  • A 6-layer per-invocation pipeline: (L1) request validation + visual-register routing via a content_type/section/pen_name decision tree; (L2) commons search across Unsplash → Pexels → Wikimedia Commons → public-domain archives, with license verification against an acceptance table, the preferred path for hard news; (L3) AI generation with a likeness gate; (L4) a five-screen visual-suitability gate (likeness, PII, defamatory-composite, contextual-misframing, style-register) plus alt-text generation; (L5) attribution assembly with tier-asymmetric AI disclosure (Tier 1 newsfeed discloses; Tier 2 Editorial Board and Tier 3 pen-names/Hector do not); (L6) schema validation + article update + retry queue with not_before and a retry-cascade escalation.
  • A placeholder that ships with the article and is replaced on retry.
  • refresh / retry / correction modes invoked by a source-correction-monitor.
  • A 10-field schema-validated imageSchema and likeness-exclusion-list.json.

The visual-register routing tree (photographic for hard news, illustrated for analytical voices, editorial-cartoon for Hector, diagrammatic for white papers) is the intended target; in code today the news auto-path defaults every register to illustrated, and only the cartoon register is set explicitly (by the Hector runner).


Change Log

  • 2026-06-03 (v2.0.0) — Reality-first rewrite + rename to Framework — MSI News Image Generator.md. Documents the real AI-only pipeline (salience budget → hero gate → render_news_image → cream-panel post-process → frontmatter patch) as primary content; demotes the unimplemented commons-first 6-layer design (commons search, five-screen suitability, tier-asymmetric disclosure, placeholder/retry) to a design-intent appendix. Corrects the executor path (ora-project/tools/, not orchestrator/), the imageSchema shape ({url, alt, source}), and the style-spec status (exists, v1.6+). Added Obsidian aliases for the prior filename.
  • 2026-05-06 (v1.1) — Style-spec wiring; tier-asymmetric disclosure design.
  • 2026-05-05 (v1.0) — Initial 6-layer commons-first design. See appendix.