In questa sezione

Ricerca dei percorsi, costruzione del piano ed esecuzione per trasformazioni in più passi.

Panoramica

Il modulo planner individua automaticamente i percorsi di trasformazione. Dati un tipo di media sorgente e un tipo di media di destinazione, trova una sequenza di cap che trasforma i dati senza che l’utente debba specificare i passi intermedi.

Questo è diverso dall’orchestratore (vedi Orchestratore), che prende una descrizione esplicita del DAG. Il planner costruisce il DAG esplorando il grafo delle capacità.

Sorgente: capdag-rs/src/planner/.

LiveCapFab

graph LR
    PDF["media:document;ext=pdf"] -->|extract_metadata| REC["media:record"]
    PDF -->|generate_thumbnail| IMG["media:ext=png;image"]
    PDF -->|extract_text| TXT["media:enc=utf-8"]
    TXT -->|generate_embeddings| EMB["media:embedding"]
    TXT -->|summarize| TXT2["media:enc=utf-8"]
    IMG -->|describe_image| TXT

Il LiveCapFab è un grafo orientato i cui nodi sono media URN e i cui archi sono capacità. Quando viene registrata una cap con URN di input A e URN di output B, il grafo acquisisce un arco A → B etichettato con quella cap. Anche ogni altro output dichiarato dalla cap è raggiungibile: un output il cui media URN è C aggiunge un arco A → C, etichettato con la cap e con quell’output. Il tipo a runtime dell’output principale segue l’effetto del cap URN (vedi Struttura dei cap URN); ogni altro output dà il proprio tipo. Un output che porta l’input della cap invariato (quello di un router) non ha un tipo proprio, quindi non aggiunge alcun arco.

La ricerca dei percorsi attraversa questo grafo per scoprire catene di trasformazione in più passi: se esistono una cap A → B e una cap B → C, allora il tipo di input A può raggiungere il tipo di output C tramite una catena di due passi.

Sorgente: capdag-rs/src/planner/live_cap_fab.rs.

Destinazioni raggiungibili

get_reachable_targets() restituisce tutti i tipi di media raggiungibili da una data sorgente tramite una o più invocazioni di cap. Esegue una visita in ampiezza del grafo a partire dal nodo sorgente, raccogliendo tutti i nodi raggiungibili.

Viene usata dall’interfaccia per mostrare quali trasformazioni sono disponibili per un dato file di input (“puoi estrarre i metadati, generare una miniatura, creare embedding, …”), senza enumerare ogni percorso possibile.

Ogni risultato è un ReachableTargetInfo che contiene il media URN di destinazione e la macchina necessaria per raggiungerlo.

Ricerca dei percorsi

find_paths_to_exact_target() restituisce uno o più oggetti Strand che collegano una sorgente specifica a una destinazione specifica. Ogni strand è un percorso concreto attraverso il grafo delle capacità.

Quando esistono più percorsi (ad esempio PDF → testo tramite estrazione, oppure PDF → immagine → testo tramite OCR), vengono restituiti tutti i percorsi validi. Il chiamante sceglie quale eseguire in base a preferenze o al contesto.

Sorgente: live_cap_fab.rs.

Strand

Uno Strand è un elenco ordinato di StrandStep che rappresenta un percorso attraverso il grafo delle capacità:

[source] --cap_1--> [intermediate_1] --cap_2--> [intermediate_2] --cap_3--> [target]

StrandStep

Ogni passo ha un token_id, la sua identità stabile, coniata quando il passo viene creato; i tipi di media ai suoi confini, from_spec e to_spec; e uno step_type:

  • Cap: il cap_urn da invocare, il suo title e la sua specificity, se il suo input e l’output che prende sono sequenze (input_is_sequence, output_is_sequence), inputs (ogni argomento che riceve e il produttore di quell’input: l’input dello strand o un altro passo, tramite token), e output_urn: l’altro output che il passo prende, tramite il suo media URN, oppure nessuno per l’output principale.
  • ForEach: una sequenza diventa i suoi elementi; il tipo di media non cambia.
  • Collect: gli elementi diventano una sequenza; il tipo di media non cambia.

Il from_spec del primo passo è la sorgente; il to_spec dell’ultimo passo è la destinazione. Uno strand che prende un altro output diventa una macchina il cui passo prende quell’output tramite il suo nome nella definizione della cap (-> (rest=n)); uno strand che prende un output che la sua cap non dichiara come altro output viene rifiutato (UnknownStrandOutput).

Sorgente: live_cap_fab.rs, machine/resolve.rs.

Analisi della cardinalità

Il modulo della cardinalità determina la forma dei dati che scorrono attraverso il piano:

InputCardinality: i dati sono un singolo elemento o un elenco?

  • Single: un solo valore (ad esempio media:document;ext=pdf, un documento PDF).
  • Sequence: più valori (ad esempio media:document;ext=pdf;list, un elenco di documenti PDF). Lo segnala il tag marcatore list nel media URN.

InputStructure: i dati sono opachi o strutturati?

  • Opaque: byte grezzi senza struttura interna (ad esempio media:binary).
  • Record: un record strutturato con campi con nome (ad esempio media:record). Lo segnala il tag marcatore record.

MediaShape: combina cardinalità e struttura in un’unica classificazione.

ShapeCompatibility: determina se due forme sono compatibili per essere collegate. Una sorgente Single può alimentare un consumatore Single. Una sorgente Sequence può alimentare un nodo ForEach che elabora gli elementi singolarmente.

Sorgente: capdag-rs/src/planner/cardinality.rs.

Associazione degli argomenti

Il sistema di associazione degli argomenti registra, per ogni argomento di un’invocazione di cap, da dove proviene il suo valore:

ArgumentBinding Valore
InputFile { index }, InputFilePath, InputMediaUrn uno dei file di input dell’esecuzione: il suo contenuto, il suo percorso o il suo media URN
PreviousOutput { node_id } ciò che un nodo precedente ha prodotto
Literal { value } una costante scritta dalla macchina (name = constant), un valore JSON codificato da json_value_to_bytes
Slot { name, schema } un valore che l’esecuzione deve fornire per questo passo
CapSetting { setting_urn } l’impostazione per cap
CapDefault il default_value della definizione
PlanMetadata { key } un valore portato dal piano stesso

ArgumentBindings è la raccolta per un’invocazione, indicizzata per media URN dell’argomento. ResolvedArgument è il valore finale: byte pronti per essere inviati, con l’ArgumentSource da cui provengono.

Sorgente: capdag-rs/src/planner/argument_binding.rs.

MachinePlan

Un MachinePlan è un DAG di MachineNode collegati da MachinePlanEdge. È il formato di output del planner: un piano di esecuzione completamente specificato.

Sorgente: capdag-rs/src/planner/plan.rs.

Tipi di nodo

pub enum ExecutionNodeType {
    Cap { token_id, cap_urn, arg_bindings, preferred_cap, policy },  // un passo: un'invocazione di una cap
    CapOutput { cap_node, output_media_urn, output_name, carries_input },  // un output di un passo diverso da quello principale
    ForEach { token_id, sources, body },   // una mappa: il suo corpo viene eseguito una volta per elemento delle sue sorgenti, accoppiate
    Item { foreach, source, media_urn },   // ciò che un'esecuzione del corpo di una mappa riceve di una sorgente
    Collect { input_nodes, output_media_urn },  // una raccolta: più valori, in ordine, come una sola sequenza
    InputSlot { slot_name, expected_media_urn, cardinality },  // qualcosa che l'esecuzione fornisce
    Output { output_name, source_node },   // qualcosa che l'esecuzione restituisce
}
  • Un nodo Cap è un passo, e rappresenta il suo output principale: ciò che legge l’output principale si collega dal nodo del passo. Porta la politica del passo (retry, on_error) e i suoi binding degli argomenti, costanti comprese.
  • Ogni altro output di un passo che qualcosa prende è un nodo CapOutput; il suo valore in un’esecuzione viene memorizzato sotto quel nodo. carries_input contrassegna un output che porta l’input del passo, con il tipo invariato.
  • Un ForEach elenca le sequenze che accoppia (sources, ciascuna con il nodo Item come cui il corpo riceve il suo elemento) e il suo body: ogni nodo che viene eseguito una volta per elemento. Una mappa aperta dentro un corpo è un membro di quel corpo; il suo corpo è il suo.
  • Un Collect è una raccolta in un argomento sequenza. Un membro che contiene un solo valore contribuisce con un elemento, un membro che contiene una sequenza con i suoi elementi, un membro assente con nessuno. Un solo valore legato a un argomento sequenza è una raccolta di un solo membro.
  • InputSlot e Output sono il confine del piano. Una macchina che parte da una sorgente non ha InputSlot; una che termina in un pozzo non ha Output.

ForEach, Item e Collect realizzano ciò che la notazione lascia alla cardinalità (Notazione delle macchine): nessuno di essi viene scritto.

Tipi di arco

pub enum EdgeType {
    Direct,                // In un passo: il suo input principale. In ogni altro nodo: ciò di cui quel nodo è
    Arg { arg_urn },       // In un argomento non principale di un passo, consegnato con quell'URN
    Iteration,             // Da una mappa a uno dei suoi nodi elemento
    Collection,            // Da un nodo raccolto alla sua raccolta
}

Un arco dice che cosa viene consegnato, non quante volte. Un nodo dentro una mappa, letto dall’esterno, è la sequenza dei suoi valori sulle esecuzioni, nell’ordine degli elementi; letto dall’interno della stessa esecuzione, è il valore di quell’esecuzione. L’esecutore ricava quale dei due da dove si trovano i due nodi. Da quale output di un passo parte un arco è dato dal nodo da cui parte: il nodo stesso del passo per l’output principale, un nodo CapOutput per ogni altro. Un valore che deve essere scomposto viene scomposto da una cap; non esistono archi che estraggono campi.

MachinePlan::validate vincola un piano a questa forma: una mappa itera almeno una sequenza, ogni elemento è un elemento della propria mappa e un membro del suo corpo, nessun nodo sta in due corpi, un CapOutput nomina un passo, e ogni raccolta e ogni output nomina un nodo del piano.

MachinePlanBuilder

flowchart LR
    S["MachineStrand<br/>(passi risolti)"] --> PB["MachinePlanBuilder"]
    SH["Input dell'esecuzione<br/>(quanti valori, di quale tipo)"] --> PB
    PB --> IS["Nodi InputSlot"]
    PB --> CAP["Nodi Cap, CapOutput"]
    PB --> MAP["ForEach, Item, Collect"]
    PB --> OUT["Nodi Output"]
    IS --> MP["MachinePlan"]
    CAP --> MP
    MAP --> MP
    OUT --> MP

Il costruttore di piani converte uno strand risolto di una Machine, e ciò che l’esecuzione lega a ciascuno dei suoi input (AnchorInput: un valore o una sequenza, e il tipo di ciò che viene legato), in un MachinePlan.

build_plan_from_machine_strand_for_run() è il punto d’ingresso (build_plan_from_machine_strand() lega ogni input allo stesso modo, al suo tipo dichiarato):

  1. Crea un InputSlot per ogni ancora di input dello strand, che contiene il tipo che l’esecuzione vi lega. Ogni input deve essere legato, e il tipo legato deve essere conforme al tipo dichiarato dell’input.
  2. Decide dove viene eseguito ogni passo: la regola di iterazione di Notazione delle macchine, applicata allo strand con la forma dell’esecuzione. È la stessa regola che il resolver applica alla sola notazione, quindi una macchina mappa in un’esecuzione esattamente dove la sua notazione dice che lo farebbe.
  3. Crea un ForEach per ogni mappa, con un nodo Item per sorgente, ed elenca nel suo corpo ogni nodo che viene eseguito una volta per elemento.
  4. Crea un nodo Cap per ogni passo e un nodo CapOutput per ogni altro output che fornisce. Collega ogni binding: l’elemento, dove il passo sta dentro la mappa che itera la sua sorgente e l’argomento prende un solo valore, altrimenti il nodo stesso; un Collect dove più nodi, o un solo valore, alimentano un argomento sequenza. Una costante diventa un binding Literal e non viene collegata da nulla.
  5. Crea un Output per ogni ancora di output.

Un percorso pianificato (uno Strand dal capfab) diventa un piano allo stesso modo: viene prima risolto in uno MachineStrand.

Sorgente: capdag-rs/src/planner/plan_builder.rs, capdag-rs/src/machine/iteration.rs.

Priorità di risoluzione degli slot

flowchart TD
    Q{"slot_values<br/>[token_id:slot_name]?"} -->|Trovato| V1["Usa il valore esplicito<br/>per passo"]
    Q -->|Non trovato| Q2{"cap_settings<br/>[cap_urn][slot_name]?"}
    Q2 -->|Trovato| V2["Usa l'impostazione<br/>per cap"]
    Q2 -->|Non trovato| Q3{"default_value<br/>nella definizione della cap?"}
    Q3 -->|Trovato| V3["Usa il valore predefinito"]
    Q3 -->|Non trovato| V4["Nessun valore<br/>(argomento omesso)"]

Un argomento che la macchina lega, a un altro nodo o a una costante, non è uno slot: il suo valore appartiene alla macchina, e una voce di slot_values o un aggiornamento a metà esecuzione indirizzato a esso viene rifiutato, nominando il passo e l’argomento. Per un argomento che la macchina lascia non legato, il costruttore risolve il suo valore in tre passi:

  1. slot_values["{token_id}:{slot_name}"]: valori espliciti per passo, indirizzati dallo StepToken del passo (lo StrandStep::token_id coniato dal planner, che è anche l’id del nodo cap del piano). Un token viene dal piano oppure non esiste: non viene mai derivato da una posizione, perché uno strand è un DAG: rami paralleli che confluiscono a valle non hanno un ordinale, e due cap identiche su rami separati differiscono solo per il token.
  2. cap_settings[cap_urn][slot_name]: impostazioni per cap. Si applicano a ogni istanza di una cap, indipendentemente dal passo in cui compare.
  3. default_value dalla definizione della cap: il ripiego.

Questo ordine di priorità consente agli utenti di sovrascrivere passi specifici pur mantenendo valori predefiniti sensati.

Sorgente: plan_builder.rs.

Che cosa viene chiesto a un’esecuzione di un piano

MachinePlanBuilder::analyze_plan_arguments() legge un piano e dice, per ogni passo (indirizzato dal suo token) e ogni argomento della sua cap, da dove proviene il valore dell’argomento:

ArgumentResolution L’argomento
FromInput è collegato da un input della macchina (attraverso l’elemento di una mappa, o una raccolta di input)
FromStep è collegato da ciò che produce un altro passo
Constant è legato a una costante scritta dalla macchina
HasDefault è lasciato non legato; la sua definizione dà un valore predefinito
RequiresUserInput è lasciato non legato e non ha un valore predefinito

Gli ultimi due sono gli slot del passo: gli argomenti a cui un’esecuzione può dare un valore. Un’esecuzione può fare a meno dell’utente quando nessuno slot è obbligatorio e privo di valore predefinito. L’analisi legge gli stessi collegamenti e binding creati dal costruttore di piani, quindi un argomento legato da una costante o da una sorgente con nome non viene mai chiesto. I mirror portano i tipi del risultato; i piani vengono costruiti, e analizzati, solo dall’implementazione di riferimento.

Sorgente: plan_builder.rs.

Eseguire un piano

Un piano viene eseguito da execute_plan (Esecuzione). Non esiste un altro esecutore: il planner produce piani e non li esegue.

cap_step_count

Il cap_step_count di un percorso pianificato conta solo i suoi passi Cap: le transizioni di cardinalità non sono passi che un utente vede, quindi i candidati vengono classificati e mostrati in base a esso.

Sorgente: capdag-rs/src/planner/live_cap_fab.rs.

I feed live non richiedono supporto dal planner

Una sorgente di feed live (13.2 §Media di riferimento) non richiede nulla al planner. La dichiarazione is_sequence dell’argomento consumatore prende ogni decisione di wiring: una cap che consuma sequenze (concat) si collega direttamente al produttore della sequenza e riceve lo stream intero; su un feed illimitato questo è un piano valido a emissione differita, poiché l’arresto dell’esecuzione consegna END e il ripiegamento emette (un concat su un microfono è “l’intera registrazione, una volta fermata”); una cap che consuma elementi (un filtro per fotogramma) viene mappata, esattamente come per qualsiasi sequenza limitata. La tempistica dell’emissione è una politica dell’operazione, non una struttura di tipo, e non esiste alcuna restrizione “illimitato” al momento della pianificazione, né l’illimitatezza compare nel piano: un piano è un piano sia che la sua sorgente sia un file sia un microfono.

La limitatezza è invece una questione dell’esecutore, decisa dalle dichiarazioni streaming delle definizioni delle cap (15.2 §Contratti di streaming): un passaggio da un produttore che può emettere senza una promessa di lunghezza a un consumatore che non consuma senza di essa è un confine di catena: il produttore viene eseguito fino al completamento, il suo output viene accumulato su disco, e al consumatore viene fornito l’intero limitato dopo END. Il piano non cambia mai per questo; cambia solo il modo in cui la catena viene eseguita. La memoria per le operazioni che richiedono il completamento su feed lunghi è una questione operativa dell’operazione (buffer, riversamento, finestra), resa visibile tramite i normali meccanismi di pressione della memoria, mai un errore del planner.