From ef3280bc1e3ab588f8ad56707de2958025657a98 Mon Sep 17 00:00:00 2001 From: Alan Szmyt Date: Fri, 11 Sep 2026 11:32:56 -0400 Subject: [PATCH] feat: establish provider adapter packs --- .../renderflow-core/data/adapter-packs.yaml | 315 +++++++++++++ .../renderflow-core/data/tool-registry.yaml | 62 ++- .../renderflow-core/src/adapters/catalog.rs | 427 ++++++++++++++++++ crates/renderflow-core/src/adapters/mod.rs | 1 + crates/renderflow-core/src/app.rs | 11 + crates/renderflow-core/src/cli.rs | 16 + crates/renderflow-core/src/commands/tools.rs | 77 ++++ crates/renderflow-core/src/lib.rs | 2 +- docs/user-guide/adapter-ecosystem.md | 75 +++ docs/user-guide/tool-registry.md | 6 + mkdocs.yml | 1 + .../renderflow-adapter-catalog-v1.schema.json | 143 ++++++ 12 files changed, 1134 insertions(+), 2 deletions(-) create mode 100644 crates/renderflow-core/data/adapter-packs.yaml create mode 100644 crates/renderflow-core/src/adapters/catalog.rs create mode 100644 docs/user-guide/adapter-ecosystem.md create mode 100644 schemas/renderflow-adapter-catalog-v1.schema.json diff --git a/crates/renderflow-core/data/adapter-packs.yaml b/crates/renderflow-core/data/adapter-packs.yaml new file mode 100644 index 0000000..e16fab3 --- /dev/null +++ b/crates/renderflow-core/data/adapter-packs.yaml @@ -0,0 +1,315 @@ +schema: renderflow.adapter-catalog/v1 + +providers: + - id: adapter.documents.pandoc + runtime_tool: tool.pandoc + name: Pandoc document adapter + families: [documents, ebooks, office] + maturity: integrated + selection_priority: 10 + capabilities: [document.convert, document.generate] + input_media_types: [text/markdown, text/html, text/x-rst, application/epub+zip, application/x-tex] + output_media_types: [text/html, application/pdf, application/epub+zip, application/x-tex, application/vnd.openxmlformats-officedocument.wordprocessingml.document] + determinism: configuration_dependent + locality: local + fidelity: path_dependent + execution: &bounded_local + service: renderflow.process/v1 + direct_argv: true + bounded_output: true + timeout: true + cancellation: true + network: false + side_effects: [writes_declared_output, reads_local_assets] + configuration_schema: + input_format: format_id + output_format: format_id + template: optional_local_path + variables: string_map + validation: [validator.core.non_empty, format_specific_validator] + provenance: [provider_id, provider_version, argv_digest, input_digest, output_digest] + upstream: https://pandoc.org/ + rationale: Mature cross-platform document provider already integrated through the artifact-native strategy adapter. + + - id: adapter.pdf.tectonic + runtime_tool: tool.tectonic + name: Tectonic PDF typesetting adapter + families: [documents, pdf] + maturity: integrated + selection_priority: 20 + capabilities: [latex.compile, pdf.typeset] + input_media_types: [application/x-tex, text/x-tex] + output_media_types: [application/pdf] + determinism: configuration_dependent + locality: network_optional + fidelity: path_dependent + execution: *bounded_local + configuration_schema: + bundle: optional_local_path + offline: boolean + validation: [validator.core.non_empty, validator.core.pdf] + provenance: [provider_id, provider_version, argv_digest, input_digest, output_digest] + upstream: https://tectonic-typesetting.github.io/ + rationale: Existing PDF typesetter retained behind explicit offline/network policy and toolchain evidence. + + - id: adapter.media.ffmpeg + runtime_tool: tool.ffmpeg + name: FFmpeg media adapter + families: [images, audio_video, subtitles] + maturity: integrated + selection_priority: 10 + capabilities: [audio.convert, image.convert, media.convert, video.convert] + input_media_types: [audio/*, image/*, video/*] + output_media_types: [audio/*, image/*, video/*] + determinism: configuration_dependent + locality: local + fidelity: path_dependent + execution: *bounded_local + configuration_schema: + codec: optional_stable_id + pixel_format: optional_stable_id + sample_format: optional_stable_id + quality: optional_number + validation: [validator.core.non_empty, format_specific_validator] + provenance: [provider_id, provider_version, argv_digest, input_digest, output_digest] + upstream: https://ffmpeg.org/ + rationale: Existing local image/audio adapter; video and subtitle capabilities remain graph-advertised only when an executable edge exists. + + - id: adapter.pdf.wkhtmltopdf + runtime_tool: tool.wkhtmltopdf + name: wkhtmltopdf compatibility adapter + families: [pdf] + maturity: experimental + selection_priority: 80 + capabilities: [html.render.pdf] + input_media_types: [text/html] + output_media_types: [application/pdf] + determinism: configuration_dependent + locality: local + fidelity: partial_loss + execution: *bounded_local + configuration_schema: + page_size: optional_string + validation: [validator.core.pdf] + provenance: [provider_id, provider_version, argv_digest, input_digest, output_digest] + upstream: https://wkhtmltopdf.org/ + rationale: Retained as an experimental compatibility fallback; browser age and rendering variance prevent preferred status. + + - id: adapter.archives.zip + runtime_tool: tool.zip + name: ZIP and CBZ aggregation adapter + families: [archives] + maturity: experimental + selection_priority: 50 + capabilities: [archive.zip.create, comic.cbz.create] + input_media_types: [application/octet-stream, image/*] + output_media_types: [application/zip, application/vnd.comicbook+zip] + determinism: configuration_dependent + locality: local + fidelity: lossless + execution: *bounded_local + configuration_schema: + ordering: explicit_path_list + timestamp_policy: deterministic_or_source + validation: [validator.core.zip] + provenance: [provider_id, provider_version, argv_digest, ordered_input_digests, output_digest] + upstream: https://infozip.sourceforge.net/ + rationale: Useful portable baseline, but deterministic archive metadata requires adapter-owned normalization. + + - id: adapter.pdf.img2pdf + runtime_tool: tool.img2pdf + name: img2pdf aggregation adapter + families: [images, pdf] + maturity: experimental + selection_priority: 30 + capabilities: [image.aggregate.pdf] + input_media_types: [image/*] + output_media_types: [application/pdf] + determinism: deterministic + locality: local + fidelity: lossless + execution: *bounded_local + configuration_schema: + page_size: optional_geometry + fit: optional_enum + validation: [validator.core.pdf] + provenance: [provider_id, provider_version, argv_digest, ordered_input_digests, output_digest] + upstream: https://gitlab.mister-muffin.de/josch/img2pdf + rationale: Strong lossless image-to-PDF candidate; collection execution remains experimental until Transform v2 aggregation coverage lands. + + - id: adapter.pdf.ghostscript + runtime_tool: tool.ghostscript + name: Ghostscript PDF processing adapter + families: [pdf, images] + maturity: experimental + selection_priority: 60 + capabilities: [pdf.process, tiff.aggregate.press_pdf] + input_media_types: [application/pdf, image/tiff] + output_media_types: [application/pdf] + determinism: configuration_dependent + locality: local + fidelity: path_dependent + execution: *bounded_local + configuration_schema: + device: allowlisted_enum + compatibility_level: optional_version + validation: [validator.core.pdf] + provenance: [provider_id, provider_version, argv_digest, input_digest, output_digest] + upstream: https://www.ghostscript.com/ + rationale: Valuable for PDF repair/preflight workflows, but licensing and lossy presets require explicit policy. + + - id: adapter.images.upscayl + runtime_tool: tool.upscayl-ncnn + name: Upscayl local super-resolution adapter + families: [images] + maturity: experimental + selection_priority: 90 + capabilities: [image.super_resolution] + input_media_types: [image/*] + output_media_types: [image/*] + determinism: configuration_dependent + locality: local + fidelity: lossy + execution: *bounded_local + configuration_schema: + model: registered_variant_id + scale: bounded_integer + validation: [validator.core.non_empty, format_specific_validator] + provenance: [provider_id, provider_version, model_digest, input_digest, output_digest] + upstream: https://github.com/upscayl/upscayl-ncnn + rationale: Existing opt-in local AI adapter; model material and license evidence remain mandatory. + + - id: adapter.data.jq + runtime_tool: tool.jq + name: jq typed JSON adapter + families: [data] + maturity: experimental + selection_priority: 50 + capabilities: [data.json.transform] + input_media_types: [application/json] + output_media_types: [application/json] + determinism: deterministic + locality: local + fidelity: path_dependent + execution: *bounded_local + configuration_schema: + filter: reviewed_expression + output_shape: optional_json_schema + validation: [validator.core.json] + provenance: [provider_id, provider_version, filter_digest, input_digest, output_digest] + upstream: https://jqlang.github.io/jq/ + rationale: Experimental typed JSON provider; graph registration waits for constrained filter and output-schema contracts. + + - id: adapter.images.imagemagick + runtime_tool: tool.imagemagick + name: ImageMagick constrained image adapter + families: [images] + maturity: experimental + selection_priority: 70 + capabilities: [image.convert] + input_media_types: [image/*] + output_media_types: [image/*] + determinism: configuration_dependent + locality: local + fidelity: path_dependent + execution: *bounded_local + configuration_schema: + operation: allowlisted_enum + geometry: optional_geometry + quality: optional_number + validation: [validator.core.non_empty, format_specific_validator] + provenance: [provider_id, provider_version, delegate_inventory, argv_digest, input_digest, output_digest] + upstream: https://imagemagick.org/ + rationale: Experimental fallback behind allowlisted operations because delegate availability and policy alter behavior. + + - id: adapter.ocr.tesseract + runtime_tool: tool.tesseract + name: Tesseract OCR extraction adapter + families: [ocr] + maturity: experimental + selection_priority: 50 + capabilities: [ocr.extract.text] + input_media_types: [image/*] + output_media_types: [text/plain] + determinism: configuration_dependent + locality: local + fidelity: lossy + execution: *bounded_local + configuration_schema: + languages: registered_language_pack_ids + page_segmentation: optional_allowlisted_enum + validation: [validator.core.utf8] + provenance: [provider_id, provider_version, language_pack_versions, input_digest, output_digest] + upstream: https://github.com/tesseract-ocr/tesseract + rationale: Experimental local OCR provider pending language-pack discovery and confidence-aware extraction evidence. + +evaluations: + - candidate: Pandoc + families: [documents, ebooks, office] + decision: adopt + upstream: https://pandoc.org/ + rationale: Broad mature conversion coverage with an existing artifact-native Renderflow integration. + - candidate: Tectonic + families: [documents, pdf] + decision: adopt + upstream: https://tectonic-typesetting.github.io/ + rationale: Reproducible-oriented typesetting with an explicit offline/network boundary. + - candidate: FFmpeg and ffprobe + families: [images, audio_video, subtitles] + decision: adapt + upstream: https://ffmpeg.org/ + rationale: Keep FFmpeg conversion integrated and add ffprobe inspection through the same bounded provider contract. + - candidate: qpdf and Poppler + families: [pdf] + decision: adapt + upstream: https://qpdf.sourceforge.io/ + rationale: Preferred future structural PDF inspection and repair pack; implementation requires validator and mutation boundaries. + follow_up: Add qpdf/pdfinfo providers with synthetic PDF fixtures. + - candidate: ImageMagick + families: [images] + decision: adapt + upstream: https://imagemagick.org/ + rationale: Useful breadth, but delegates and policy.xml make availability and security build-dependent. + follow_up: Add a constrained allowlisted operation surface rather than arbitrary convert arguments. + - candidate: LibreOffice + families: [office, documents] + decision: adapt + upstream: https://www.libreoffice.org/ + rationale: Valuable office fallback when isolated profiles, bounded temporary directories, and nondeterminism evidence are explicit. + - candidate: Calibre + families: [ebooks] + decision: defer + upstream: https://calibre-ebook.com/ + rationale: "Coordinate with EPUB and KEPUB issue #344 so ebook semantics are not duplicated in this foundation." + follow_up: "#344" + - candidate: 7-Zip and libarchive + families: [archives] + decision: adapt + upstream: https://www.libarchive.org/ + rationale: Strong archive breadth, but extraction must reuse universal-intake path and expansion safety budgets. + - candidate: jq + families: [data] + decision: adapt + upstream: https://jqlang.github.io/jq/ + rationale: Keep an experimental typed JSON provider while constrained filter and output-schema contracts mature. + - candidate: SQLite + families: [data] + decision: defer + upstream: https://sqlite.org/ + rationale: Relational transforms need typed schema and query contracts before a command adapter can claim meaningful fidelity. + - candidate: Tesseract and OCRmyPDF + families: [ocr, pdf] + decision: adapt + upstream: https://github.com/tesseract-ocr/tesseract + rationale: High-value local OCR path requiring language-pack discovery, confidence evidence, and searchable-PDF validation. + - candidate: Subtitle Edit + families: [subtitles] + decision: reject + upstream: https://github.com/SubtitleEdit/subtitleedit + rationale: GUI-centric cross-platform footprint is a poor core dependency; prefer bounded FFmpeg and text-native subtitle adapters. + - candidate: HandBrakeCLI + families: [audio_video] + decision: defer + upstream: https://handbrake.fr/ + rationale: "Keep video transcode ownership coordinated with Aniflow through issue #345." + follow_up: "#345" diff --git a/crates/renderflow-core/data/tool-registry.yaml b/crates/renderflow-core/data/tool-registry.yaml index 2a3e7b4..535ff7b 100644 --- a/crates/renderflow-core/data/tool-registry.yaml +++ b/crates/renderflow-core/data/tool-registry.yaml @@ -59,7 +59,7 @@ tools: discovery: kind: executable candidates: [ffmpeg] - version_args: [--version] + version_args: [-version] version: min_inclusive: "4.0.0" operating_systems: [linux, macos, windows] @@ -160,6 +160,66 @@ tools: license_notes: "Ghostscript is available under AGPL/commercial licensing; review distribution obligations." distribution_notes: "Used by press-oriented TIFF/PDF command adapters when configured." + - id: tool.jq + name: jq + discovery: + kind: executable + candidates: [jq] + version_args: [--version] + version: + min_inclusive: "1.6.0" + operating_systems: [linux, macos, windows] + capabilities: + - data.json.transform + input_media_types: [application/json] + output_media_types: [application/json] + determinism: deterministic + locality: local + fidelity: path_dependent + support_tier: experimental + license_notes: "See the jq upstream license for redistribution terms." + distribution_notes: "Experimental typed JSON transform provider; arbitrary filters are not registered as graph edges." + + - id: tool.imagemagick + name: ImageMagick + discovery: + kind: executable + candidates: [magick] + version_args: [--version] + version: + min_inclusive: "7.0.0" + operating_systems: [linux, macos, windows] + capabilities: + - image.convert + input_media_types: [image/*] + output_media_types: [image/*] + determinism: configuration_dependent + locality: local + fidelity: path_dependent + support_tier: experimental + license_notes: "See the ImageMagick upstream license and the exact build's delegate licenses." + distribution_notes: "Experimental constrained image fallback; arbitrary operations and delegates are not graph-enabled." + + - id: tool.tesseract + name: Tesseract OCR + discovery: + kind: executable + candidates: [tesseract] + version_args: [--version] + version: + min_inclusive: "5.0.0" + operating_systems: [linux, macos, windows] + capabilities: + - ocr.extract.text + input_media_types: [image/*] + output_media_types: [text/plain] + determinism: configuration_dependent + locality: local + fidelity: lossy + support_tier: experimental + license_notes: "See the Tesseract upstream Apache-2.0 license and language-data licenses." + distribution_notes: "Experimental OCR provider; language-pack identity and confidence evidence are required before graph integration." + - id: tool.upscayl-ncnn name: Upscayl NCNN diff --git a/crates/renderflow-core/src/adapters/catalog.rs b/crates/renderflow-core/src/adapters/catalog.rs new file mode 100644 index 0000000..5a75e78 --- /dev/null +++ b/crates/renderflow-core/src/adapters/catalog.rs @@ -0,0 +1,427 @@ +//! Data-defined provider packs and adopt/adapt/reject evidence. + +use std::collections::{BTreeMap, BTreeSet, HashSet}; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +use crate::toolchain::{ + CapabilityId, ToolAvailabilityStatus, ToolDeterminism, ToolFidelity, ToolId, ToolInventory, + ToolLocality, ToolRegistry, ToolSupportTier, +}; + +pub const ADAPTER_CATALOG_SCHEMA_V1: &str = "renderflow.adapter-catalog/v1"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AdapterFamily { + Documents, + Pdf, + Images, + AudioVideo, + Ebooks, + Office, + Archives, + Data, + Ocr, + Subtitles, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AdapterMaturity { + Integrated, + Experimental, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AdoptionDecision { + Adopt, + Adapt, + Reject, + Defer, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AdapterExecutionContract { + pub service: String, + #[serde(default)] + pub direct_argv: bool, + #[serde(default)] + pub bounded_output: bool, + #[serde(default)] + pub timeout: bool, + #[serde(default)] + pub cancellation: bool, + #[serde(default)] + pub network: bool, + #[serde(default)] + pub side_effects: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AdapterProviderContract { + pub id: String, + pub runtime_tool: ToolId, + pub name: String, + pub families: Vec, + pub maturity: AdapterMaturity, + pub selection_priority: u16, + pub capabilities: Vec, + #[serde(default)] + pub input_media_types: Vec, + #[serde(default)] + pub output_media_types: Vec, + pub determinism: ToolDeterminism, + pub locality: ToolLocality, + pub fidelity: ToolFidelity, + pub execution: AdapterExecutionContract, + #[serde(default)] + pub configuration_schema: BTreeMap, + #[serde(default)] + pub validation: Vec, + #[serde(default)] + pub provenance: Vec, + pub upstream: String, + pub rationale: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AdapterEvaluation { + pub candidate: String, + pub families: Vec, + pub decision: AdoptionDecision, + pub upstream: String, + pub rationale: String, + #[serde(default)] + pub follow_up: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct AdapterCatalogDocument { + schema: String, + providers: Vec, + evaluations: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AdapterCatalog { + pub schema: String, + pub providers: Vec, + pub evaluations: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AdapterProviderStatus { + pub contract: AdapterProviderContract, + pub availability: ToolAvailabilityStatus, + pub selected_executable: Option, + pub version: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AdapterCatalogReport { + pub schema: String, + pub providers: Vec, + pub evaluations: Vec, + pub capabilities: BTreeMap>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub selection: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AdapterSelectionDecision { + pub adapter_id: String, + pub runtime_tool: ToolId, + pub availability: ToolAvailabilityStatus, + pub reason: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AdapterSelection { + pub capability: String, + pub preferred: Vec, + pub selected: Option, + pub decisions: Vec, +} + +impl AdapterCatalog { + pub fn builtins(registry: &ToolRegistry) -> Result { + Self::from_yaml(include_str!("../../data/adapter-packs.yaml"), registry) + } + + pub fn from_yaml(yaml: &str, registry: &ToolRegistry) -> Result { + let mut document: AdapterCatalogDocument = + serde_yaml_ng::from_str(yaml).context("failed to parse adapter catalog YAML")?; + if document.schema != ADAPTER_CATALOG_SCHEMA_V1 { + anyhow::bail!( + "unsupported adapter catalog schema '{}'; expected '{}'", + document.schema, + ADAPTER_CATALOG_SCHEMA_V1 + ); + } + validate_catalog(&document, registry)?; + for provider in &mut document.providers { + provider.families.sort(); + provider.families.dedup(); + provider.capabilities.sort(); + provider.capabilities.dedup(); + provider.validation.sort(); + provider.validation.dedup(); + provider.provenance.sort(); + provider.provenance.dedup(); + } + document + .providers + .sort_by(|left, right| left.id.cmp(&right.id)); + document + .evaluations + .sort_by(|left, right| left.candidate.cmp(&right.candidate)); + Ok(Self { + schema: document.schema, + providers: document.providers, + evaluations: document.evaluations, + }) + } + + pub fn providers_for(&self, capability: &str) -> Vec<&AdapterProviderContract> { + let mut providers = self + .providers + .iter() + .filter(|provider| { + provider + .capabilities + .iter() + .any(|candidate| candidate.as_str() == capability) + }) + .collect::>(); + providers.sort_by(|left, right| { + maturity_rank(left.maturity) + .cmp(&maturity_rank(right.maturity)) + .then_with(|| left.selection_priority.cmp(&right.selection_priority)) + .then_with(|| left.id.cmp(&right.id)) + }); + providers + } + + /// Select an available provider deterministically without hiding fallbacks. + /// + /// Explicit preferences win, followed by maturity, declared priority, and + /// stable adapter id. Every rejected candidate remains visible in evidence. + pub fn select_for( + &self, + capability: &str, + preferred: &[String], + inventory: &ToolInventory, + ) -> AdapterSelection { + let preference = preferred + .iter() + .enumerate() + .map(|(index, id)| (id.as_str(), index)) + .collect::>(); + let mut providers = self.providers_for(capability); + providers.sort_by(|left, right| { + preference + .get(left.id.as_str()) + .copied() + .unwrap_or(usize::MAX) + .cmp( + &preference + .get(right.id.as_str()) + .copied() + .unwrap_or(usize::MAX), + ) + .then_with(|| maturity_rank(left.maturity).cmp(&maturity_rank(right.maturity))) + .then_with(|| left.selection_priority.cmp(&right.selection_priority)) + .then_with(|| left.id.cmp(&right.id)) + }); + let mut selected = None; + let mut decisions = Vec::new(); + for provider in providers { + let availability = inventory + .get(provider.runtime_tool.as_str()) + .map(|tool| tool.status) + .unwrap_or(ToolAvailabilityStatus::UnknownProvider); + let eligible = selected.is_none() && availability == ToolAvailabilityStatus::Available; + if eligible { + selected = Some(provider.id.clone()); + } + decisions.push(AdapterSelectionDecision { + adapter_id: provider.id.clone(), + runtime_tool: provider.runtime_tool.clone(), + availability, + reason: if eligible { + "selected by explicit preference/maturity/priority order".to_string() + } else if availability != ToolAvailabilityStatus::Available { + format!("provider unavailable: {}", availability.as_str()) + } else { + "available fallback ranked after selected provider".to_string() + }, + }); + } + AdapterSelection { + capability: capability.to_string(), + preferred: preferred.to_vec(), + selected, + decisions, + } + } + + pub fn report( + &self, + inventory: &ToolInventory, + capability: Option<&str>, + preferred: &[String], + available_only: bool, + ) -> AdapterCatalogReport { + let mut providers = self + .providers + .iter() + .filter(|provider| { + capability.is_none_or(|capability| { + provider + .capabilities + .iter() + .any(|candidate| candidate.as_str() == capability) + }) + }) + .filter_map(|contract| { + let availability = inventory.get(contract.runtime_tool.as_str())?; + if available_only && !availability.is_available() { + return None; + } + Some(AdapterProviderStatus { + contract: contract.clone(), + availability: availability.status, + selected_executable: availability.selected_executable.clone(), + version: availability + .normalized_version + .clone() + .or_else(|| availability.version_line.clone()), + }) + }) + .collect::>(); + providers.sort_by(|left, right| { + maturity_rank(left.contract.maturity) + .cmp(&maturity_rank(right.contract.maturity)) + .then_with(|| { + left.contract + .selection_priority + .cmp(&right.contract.selection_priority) + }) + .then_with(|| left.contract.id.cmp(&right.contract.id)) + }); + let mut capabilities = BTreeMap::>::new(); + for provider in &providers { + for capability in &provider.contract.capabilities { + capabilities + .entry(capability.to_string()) + .or_default() + .push(provider.contract.id.clone()); + } + } + for ids in capabilities.values_mut() { + ids.sort(); + } + AdapterCatalogReport { + schema: self.schema.clone(), + providers, + evaluations: self.evaluations.clone(), + capabilities, + selection: capability + .map(|capability| self.select_for(capability, preferred, inventory)), + } + } +} + +fn maturity_rank(maturity: AdapterMaturity) -> u8 { + match maturity { + AdapterMaturity::Integrated => 0, + AdapterMaturity::Experimental => 1, + } +} + +fn validate_catalog(document: &AdapterCatalogDocument, registry: &ToolRegistry) -> Result<()> { + let mut provider_ids = HashSet::new(); + for provider in &document.providers { + if provider.id.trim().is_empty() || !provider.id.starts_with("adapter.") { + anyhow::bail!("adapter id '{}' must begin with 'adapter.'", provider.id); + } + if !provider_ids.insert(provider.id.clone()) { + anyhow::bail!("duplicate adapter id '{}'", provider.id); + } + let tool = registry + .get(provider.runtime_tool.as_str()) + .with_context(|| { + format!( + "adapter '{}' references unknown runtime tool '{}'", + provider.id, provider.runtime_tool + ) + })?; + if provider.families.is_empty() || provider.capabilities.is_empty() { + anyhow::bail!( + "adapter '{}' must declare families and capabilities", + provider.id + ); + } + if provider.execution.service != "renderflow.process/v1" + || !provider.execution.direct_argv + || !provider.execution.bounded_output + || !provider.execution.timeout + || !provider.execution.cancellation + { + anyhow::bail!( + "adapter '{}' must use the complete renderflow.process/v1 bounded direct-argv contract", + provider.id + ); + } + if provider.validation.is_empty() && provider.maturity == AdapterMaturity::Integrated { + anyhow::bail!( + "integrated adapter '{}' requires validation evidence", + provider.id + ); + } + if provider.maturity == AdapterMaturity::Integrated + && tool.support_tier == ToolSupportTier::Experimental + { + anyhow::bail!( + "integrated adapter '{}' cannot use experimental tool '{}'", + provider.id, + tool.id + ); + } + let tool_capabilities = tool + .capabilities + .iter() + .map(|capability| capability.as_str()) + .collect::>(); + for capability in &provider.capabilities { + if !tool_capabilities.contains(capability.as_str()) { + anyhow::bail!( + "adapter '{}' declares capability '{}' absent from tool '{}'", + provider.id, + capability, + tool.id + ); + } + } + } + let mut evaluated = HashSet::new(); + for evaluation in &document.evaluations { + if evaluation.candidate.trim().is_empty() || !evaluated.insert(&evaluation.candidate) { + anyhow::bail!("adapter evaluations require unique non-empty candidate names"); + } + if evaluation.families.is_empty() || evaluation.rationale.trim().is_empty() { + anyhow::bail!( + "adapter evaluation '{}' requires a family and rationale", + evaluation.candidate + ); + } + } + Ok(()) +} diff --git a/crates/renderflow-core/src/adapters/mod.rs b/crates/renderflow-core/src/adapters/mod.rs index 735e676..fae2e44 100644 --- a/crates/renderflow-core/src/adapters/mod.rs +++ b/crates/renderflow-core/src/adapters/mod.rs @@ -1,2 +1,3 @@ +pub mod catalog; pub mod command; pub mod strategy; diff --git a/crates/renderflow-core/src/app.rs b/crates/renderflow-core/src/app.rs index 97aa552..5cd15bb 100644 --- a/crates/renderflow-core/src/app.rs +++ b/crates/renderflow-core/src/app.rs @@ -165,6 +165,17 @@ pub fn run_cli(cli: Cli) -> Result<()> { } => commands::graph::run_stats(&config, target.as_deref(), optimization)?, }, Some(Commands::Tools { subcommand }) => match subcommand { + ToolCommands::Ecosystem { + format, + capability, + preferred, + available_only, + } => commands::tools::run_ecosystem( + &format, + capability.as_deref(), + &preferred, + available_only, + )?, ToolCommands::List { format, transforms } => { commands::tools::run_list(transforms.as_deref(), &format)? } diff --git a/crates/renderflow-core/src/cli.rs b/crates/renderflow-core/src/cli.rs index 9ac4a20..60127a5 100644 --- a/crates/renderflow-core/src/cli.rs +++ b/crates/renderflow-core/src/cli.rs @@ -355,6 +355,22 @@ pub enum PluginCommands { /// Subcommands for `renderflow tools`. #[derive(Subcommand)] pub enum ToolCommands { + /// Inspect the versioned adapter-pack catalog and adopt/adapt/reject matrix. + Ecosystem { + /// Output format: text (default), json, or yaml. + #[arg(long, default_value = "text", value_name = "FORMAT")] + format: String, + /// Limit output to providers for one stable capability ID. + #[arg(long, value_name = "CAPABILITY")] + capability: Option, + /// Prefer an adapter ID for capability selection; repeat for fallback order. + #[arg(long, value_name = "ADAPTER", requires = "capability")] + preferred: Vec, + /// Only include providers available on this host. + #[arg(long)] + available_only: bool, + }, + /// List registered providers and live availability/version state. List { /// Output format: text (default), json, or yaml. diff --git a/crates/renderflow-core/src/commands/tools.rs b/crates/renderflow-core/src/commands/tools.rs index 89eba9e..881d57f 100644 --- a/crates/renderflow-core/src/commands/tools.rs +++ b/crates/renderflow-core/src/commands/tools.rs @@ -3,6 +3,7 @@ use std::collections::BTreeMap; use anyhow::{Context, Result}; use serde::Serialize; +use crate::adapters::catalog::AdapterCatalog; use crate::super_resolution::{UpscaylModelCatalog, UPSCAYL_TOOL_ID}; use crate::toolchain::{ToolAvailability, ToolDescriptor, ToolRegistry}; use crate::transforms::yaml_loader::load_tool_registry_from_yaml; @@ -49,6 +50,82 @@ fn emit_serialized(value: &T, format: StructuredFormat) -> Result< Ok(()) } +pub fn run_ecosystem( + format: &str, + capability: Option<&str>, + preferred: &[String], + available_only: bool, +) -> Result<()> { + let registry = ToolRegistry::builtins(); + let catalog = AdapterCatalog::builtins(®istry)?; + let provider_ids = match capability { + Some(capability) => catalog + .providers_for(capability) + .into_iter() + .map(|provider| provider.runtime_tool.as_str()) + .collect::>(), + None => catalog + .providers + .iter() + .map(|provider| provider.runtime_tool.as_str()) + .collect::>(), + }; + let inventory = registry.assess_ids_current(provider_ids); + let report = catalog.report(&inventory, capability, preferred, available_only); + let format = StructuredFormat::parse(format)?; + if format != StructuredFormat::Text { + return emit_serialized(&report, format); + } + + println!("Renderflow Adapter Ecosystem"); + println!("============================"); + println!("catalog: {}", report.schema); + println!(); + println!( + "{:<34} {:<15} {:<14} {:<9} Capability", + "Adapter", "Availability", "Maturity", "Priority" + ); + for provider in &report.providers { + println!( + "{:<34} {:<15} {:<14} {:<9} {}", + provider.contract.id, + provider.availability.as_str(), + format!("{:?}", provider.contract.maturity).to_ascii_lowercase(), + provider.contract.selection_priority, + provider + .contract + .capabilities + .iter() + .map(ToString::to_string) + .collect::>() + .join(", ") + ); + } + if let Some(selection) = &report.selection { + println!(); + println!( + "Selected for {}: {}", + selection.capability, + selection.selected.as_deref().unwrap_or("unavailable") + ); + for decision in &selection.decisions { + println!(" - {}: {}", decision.adapter_id, decision.reason); + } + } + println!(); + println!("Adopt / adapt / reject matrix"); + println!("-----------------------------"); + for evaluation in &report.evaluations { + println!( + "{:<28} {:<8} {}", + evaluation.candidate, + format!("{:?}", evaluation.decision).to_ascii_lowercase(), + evaluation.rationale + ); + } + Ok(()) +} + pub fn run_list(transforms: Option<&str>, format: &str) -> Result<()> { let registry = load_registry(transforms)?; let inventory = registry.assess_all_current(); diff --git a/crates/renderflow-core/src/lib.rs b/crates/renderflow-core/src/lib.rs index 90e2d0a..85487c1 100644 --- a/crates/renderflow-core/src/lib.rs +++ b/crates/renderflow-core/src/lib.rs @@ -5,7 +5,7 @@ #![recursion_limit = "256"] -mod adapters; +pub mod adapters; pub mod ai; pub mod app; pub mod artifact; diff --git a/docs/user-guide/adapter-ecosystem.md b/docs/user-guide/adapter-ecosystem.md new file mode 100644 index 0000000..f474a46 --- /dev/null +++ b/docs/user-guide/adapter-ecosystem.md @@ -0,0 +1,75 @@ +# Adapter Ecosystem + +Renderflow adapter packs bind provider-neutral capabilities to replaceable tools. The versioned +catalog is stored in `crates/renderflow-core/data/adapter-packs.yaml`; the tool registry remains +the authority for executable discovery, version compatibility, platform support, and live +availability. + +An adapter contract records its capability families, accepted and produced media types, +determinism, locality, fidelity, bounded execution policy, configuration shape, validation, +provenance contribution, maturity, selection priority, and upstream rationale. Provider names do +not become formats or domain concepts. + +## Inspecting the Catalog + +```bash +renderflow tools ecosystem +renderflow tools ecosystem --format json +renderflow tools ecosystem --capability document.convert +renderflow tools ecosystem --capability image.convert --preferred adapter.images.imagemagick +renderflow tools ecosystem --available-only +``` + +Structured output uses `renderflow.adapter-catalog/v1` and is validated by +`schemas/renderflow-adapter-catalog-v1.schema.json`. It includes live availability/version data, +the capability-to-provider projection, and the complete adopt/adapt/reject/defer evaluation +matrix. + +## Provider Selection + +The public catalog API ranks candidates deterministically: + +1. explicitly preferred adapter IDs in caller order; +2. integrated before experimental adapters; +3. lower declared `selection_priority`; +4. stable adapter ID as the final tie-breaker. + +Unavailable candidates remain in `AdapterSelection.decisions`; provider fallback is never hidden +after planning. Experimental catalog entries are discoverable but do not add execution-graph +edges by themselves. A capability becomes executable only when an artifact-native adapter +registers an edge and executor. + +## Current Packs + +| Family | Current provider path | Status | Direction | +| --- | --- | --- | --- | +| Documents/office | Pandoc | Integrated | Keep behind the artifact-native strategy adapter | +| PDF typesetting | Tectonic | Integrated | Enforce explicit offline/network policy | +| Images/audio | FFmpeg; ImageMagick fallback | Integrated/experimental | Keep choices typed and delegates provenance-visible | +| Video/subtitles | FFmpeg | Partial | Register only concrete implemented graph edges | +| Archives | ZIP | Experimental | Normalize ordering and timestamps before promotion | +| Image-to-PDF | img2pdf | Experimental | Promote with collection/aggregation fixtures | +| PDF processing | Ghostscript | Experimental | Require explicit licensing and fidelity policy | +| Local image AI | Upscayl NCNN | Experimental | Require model identity, license evidence, and AI opt-in | +| E-books | Calibre evaluation | Deferred to #344 | Integrate as ordinary providers | +| Video transcode | HandBrakeCLI evaluation | Deferred to #345 | Preserve the Aniflow ownership boundary | +| Searchable PDF OCR | OCRmyPDF evaluation | Adapt | Add searchable-PDF validation after the Tesseract base pack | +| Structured data | jq | Experimental | Constrain filters and validate declared output schemas | +| OCR | Tesseract | Experimental | Discover language packs and preserve confidence evidence | + +The catalog also documents rejected candidates and why. Rejection prevents accidental dependency +growth while leaving the decision inspectable and revisable. + +## Adding an Adapter + +1. Add or reuse a stable `tool.*` entry in `tool-registry.yaml`. +2. Add an `adapter.*` contract with complete execution, validation, and provenance declarations. +3. Execute commands only through `renderflow.process/v1` using direct argv. +4. Register capability graph edges only for implemented artifact-native transforms. +5. Add a redistribution-safe fixture and validator before promoting to `integrated`. +6. Confirm `renderflow tools ecosystem --format json` and the conformance matrix describe the + capability honestly. + +The base engine does not require the maximal tool suite. Missing optional tools are represented as +availability decisions and pruned from maximal artifact forests without pretending outputs were +produced. diff --git a/docs/user-guide/tool-registry.md b/docs/user-guide/tool-registry.md index 5053b9b..41a610f 100644 --- a/docs/user-guide/tool-registry.md +++ b/docs/user-guide/tool-registry.md @@ -19,9 +19,12 @@ without editing this built-in catalog. | --- | --- | --- | --- | --- | --- | | `tool.ffmpeg` | FFmpeg | executable: `ffmpeg` | optional | configuration_dependent | local | | `tool.ghostscript` | Ghostscript | executable: `gs` | experimental | configuration_dependent | local | +| `tool.imagemagick` | ImageMagick | executable: `magick` | experimental | configuration_dependent | local | | `tool.img2pdf` | img2pdf | executable: `img2pdf` | experimental | deterministic | local | +| `tool.jq` | jq | executable: `jq` | experimental | deterministic | local | | `tool.pandoc` | Pandoc | executable: `pandoc` | required | configuration_dependent | local | | `tool.tectonic` | Tectonic | executable: `tectonic` | optional | configuration_dependent | network_optional | +| `tool.tesseract` | Tesseract OCR | executable: `tesseract` | experimental | configuration_dependent | local | | `tool.upscayl-ncnn` | Upscayl NCNN | executable: `upscayl-ncnn`, `upscayl-bin` | experimental | configuration_dependent | local | | `tool.wkhtmltopdf` | wkhtmltopdf | executable: `wkhtmltopdf` | experimental | configuration_dependent | local | | `tool.zip` | Info-ZIP compatible zip | executable: `zip` | experimental | configuration_dependent | local | @@ -36,11 +39,14 @@ without editing this built-in catalog. | `video.convert` | `tool.ffmpeg` | | `pdf.process` | `tool.ghostscript` | | `tiff.aggregate.press_pdf` | `tool.ghostscript` | +| `image.convert` | `tool.imagemagick` | | `image.aggregate.pdf` | `tool.img2pdf` | +| `data.json.transform` | `tool.jq` | | `document.convert` | `tool.pandoc` | | `document.generate` | `tool.pandoc` | | `latex.compile` | `tool.tectonic` | | `pdf.typeset` | `tool.tectonic` | +| `ocr.extract.text` | `tool.tesseract` | | `image.super_resolution` | `tool.upscayl-ncnn` | | `html.render.pdf` | `tool.wkhtmltopdf` | | `archive.zip.create` | `tool.zip` | diff --git a/mkdocs.yml b/mkdocs.yml index 1dd5660..1f1b8d1 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -69,6 +69,7 @@ nav: - Spec v2 Reference: user-guide/spec-v2-reference.md - Supported Formats: user-guide/supported-formats.md - Tool Registry: user-guide/tool-registry.md + - Adapter Ecosystem: user-guide/adapter-ecosystem.md - Super Resolution: user-guide/super-resolution.md - Upscayl Models: user-guide/upscayl-models.md - Pipelines: user-guide/pipelines.md diff --git a/schemas/renderflow-adapter-catalog-v1.schema.json b/schemas/renderflow-adapter-catalog-v1.schema.json new file mode 100644 index 0000000..e13d854 --- /dev/null +++ b/schemas/renderflow-adapter-catalog-v1.schema.json @@ -0,0 +1,143 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://egohygiene.github.io/renderflow/schemas/renderflow-adapter-catalog-v1.schema.json", + "title": "Renderflow adapter catalog v1", + "type": "object", + "additionalProperties": false, + "required": ["schema", "providers", "evaluations"], + "properties": { + "schema": { "const": "renderflow.adapter-catalog/v1" }, + "providers": { + "type": "array", + "items": { "$ref": "#/$defs/provider" } + }, + "evaluations": { + "type": "array", + "items": { "$ref": "#/$defs/evaluation" } + } + }, + "$defs": { + "stableId": { + "type": "string", + "minLength": 1, + "pattern": "^[A-Za-z0-9._-]+$" + }, + "family": { + "enum": [ + "documents", + "pdf", + "images", + "audio_video", + "ebooks", + "office", + "archives", + "data", + "ocr", + "subtitles" + ] + }, + "execution": { + "type": "object", + "additionalProperties": false, + "required": [ + "service", + "direct_argv", + "bounded_output", + "timeout", + "cancellation", + "network" + ], + "properties": { + "service": { "const": "renderflow.process/v1" }, + "direct_argv": { "const": true }, + "bounded_output": { "const": true }, + "timeout": { "const": true }, + "cancellation": { "const": true }, + "network": { "type": "boolean" }, + "side_effects": { + "type": "array", + "items": { "$ref": "#/$defs/stableId" }, + "uniqueItems": true + } + } + }, + "provider": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "runtime_tool", + "name", + "families", + "maturity", + "selection_priority", + "capabilities", + "determinism", + "locality", + "fidelity", + "execution", + "upstream", + "rationale" + ], + "properties": { + "id": { "type": "string", "pattern": "^adapter\\.[A-Za-z0-9._-]+$" }, + "runtime_tool": { "type": "string", "pattern": "^tool\\.[A-Za-z0-9._-]+$" }, + "name": { "type": "string", "minLength": 1 }, + "families": { + "type": "array", + "items": { "$ref": "#/$defs/family" }, + "minItems": 1, + "uniqueItems": true + }, + "maturity": { "enum": ["integrated", "experimental"] }, + "selection_priority": { "type": "integer", "minimum": 0, "maximum": 65535 }, + "capabilities": { + "type": "array", + "items": { "$ref": "#/$defs/stableId" }, + "minItems": 1, + "uniqueItems": true + }, + "input_media_types": { "type": "array", "items": { "type": "string" } }, + "output_media_types": { "type": "array", "items": { "type": "string" } }, + "determinism": { "enum": ["deterministic", "configuration_dependent", "nondeterministic"] }, + "locality": { "enum": ["local", "local_service", "network_optional", "network_required"] }, + "fidelity": { "enum": ["lossless", "partial_loss", "lossy", "path_dependent"] }, + "execution": { "$ref": "#/$defs/execution" }, + "configuration_schema": { + "type": "object", + "additionalProperties": { "type": "string", "minLength": 1 } + }, + "validation": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + }, + "provenance": { + "type": "array", + "items": { "$ref": "#/$defs/stableId" }, + "uniqueItems": true + }, + "upstream": { "type": "string", "format": "uri" }, + "rationale": { "type": "string", "minLength": 1 } + } + }, + "evaluation": { + "type": "object", + "additionalProperties": false, + "required": ["candidate", "families", "decision", "upstream", "rationale"], + "properties": { + "candidate": { "type": "string", "minLength": 1 }, + "families": { + "type": "array", + "items": { "$ref": "#/$defs/family" }, + "minItems": 1, + "uniqueItems": true + }, + "decision": { "enum": ["adopt", "adapt", "reject", "defer"] }, + "upstream": { "type": "string", "format": "uri" }, + "rationale": { "type": "string", "minLength": 1 }, + "follow_up": { "type": ["string", "null"] } + } + } + } +}