diffusers-workflow 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- diffusers_workflow-0.4.0.dist-info/METADATA +318 -0
- diffusers_workflow-0.4.0.dist-info/RECORD +260 -0
- diffusers_workflow-0.4.0.dist-info/WHEEL +5 -0
- diffusers_workflow-0.4.0.dist-info/entry_points.txt +7 -0
- diffusers_workflow-0.4.0.dist-info/licenses/LICENSE +201 -0
- diffusers_workflow-0.4.0.dist-info/top_level.txt +2 -0
- dw/__init__.py +440 -0
- dw/adapter_compatibility.py +226 -0
- dw/arguments.py +1231 -0
- dw/assessment_rules.py +159 -0
- dw/assets.py +130 -0
- dw/cache_blocks.json +16 -0
- dw/cache_blocks.py +146 -0
- dw/community_pipelines/pipeline_flux_rf_inversion.py +1184 -0
- dw/content_types.py +150 -0
- dw/dissolve_frame_errors.py +121 -0
- dw/docs/ACCELERATION.md +352 -0
- dw/docs/AGENT_LOOP.md +95 -0
- dw/docs/DEPENDENCIES.md +91 -0
- dw/docs/IP_ADAPTER.md +109 -0
- dw/docs/LORAS.md +131 -0
- dw/docs/MCP.md +517 -0
- dw/docs/PROMPT_WEIGHTING.md +78 -0
- dw/docs/QUANTIZATION.md +230 -0
- dw/docs/RECIPES_24GB.md +201 -0
- dw/docs/RELEASING.md +195 -0
- dw/docs/REMOTE.md +140 -0
- dw/docs/REPL_COMMANDS.md +121 -0
- dw/docs/REPL_WORKER_GUIDE.md +51 -0
- dw/docs/SECURITY.md +272 -0
- dw/docs/SECURITY_QUICKREF.md +112 -0
- dw/docs/SERVER.md +679 -0
- dw/docs/TASKS.md +1741 -0
- dw/docs/TESTING.md +71 -0
- dw/docs/WORKFLOW_GUIDE.md +2038 -0
- dw/docs/WORKSPACES.md +316 -0
- dw/download_watch.py +335 -0
- dw/elision.py +306 -0
- dw/events.py +275 -0
- dw/for_each.py +409 -0
- dw/host_memory.py +258 -0
- dw/host_memory_projection.py +230 -0
- dw/hub_cache.py +432 -0
- dw/introspection.py +1228 -0
- dw/kernel_availability.py +208 -0
- dw/locations.py +599 -0
- dw/log_setup.py +45 -0
- dw/loudness.py +82 -0
- dw/media_audio.py +217 -0
- dw/media_frames.py +367 -0
- dw/media_info.py +297 -0
- dw/pipeline_processors/chain.py +821 -0
- dw/pipeline_processors/config_objects.py +237 -0
- dw/pipeline_processors/pipeline.py +2297 -0
- dw/pipeline_processors/remote.py +46 -0
- dw/plan.py +920 -0
- dw/previous_results.py +411 -0
- dw/probe_paths.py +59 -0
- dw/prompt_schema.json +48 -0
- dw/prompt_weighting.py +378 -0
- dw/prompts.py +159 -0
- dw/realize.py +250 -0
- dw/reference_limits.py +215 -0
- dw/reference_names.py +125 -0
- dw/repl.py +338 -0
- dw/repl_commands.py +836 -0
- dw/repl_worker.py +159 -0
- dw/result.py +1720 -0
- dw/result_fps.py +82 -0
- dw/run.py +162 -0
- dw/runs.py +768 -0
- dw/scalar_result_validation.py +97 -0
- dw/schema.py +283 -0
- dw/security.py +1038 -0
- dw/select_validation.py +115 -0
- dw/serve.py +277 -0
- dw/server/__init__.py +2 -0
- dw/server/app.py +4586 -0
- dw/server/assess.py +132 -0
- dw/server/catalog_shape.py +487 -0
- dw/server/enhancers.py +129 -0
- dw/server/exports.py +480 -0
- dw/server/guides.py +257 -0
- dw/server/jobs.py +1561 -0
- dw/server/mcp_mount.py +95 -0
- dw/server/netinfo.py +124 -0
- dw/server/observed_cost.py +379 -0
- dw/server/sysinfo.py +71 -0
- dw/server/ui/assets/abap-08VXUWAP.js +1 -0
- dw/server/ui/assets/apex-BWPQTe0t.js +1 -0
- dw/server/ui/assets/azcli-Bc_sGQ0U.js +1 -0
- dw/server/ui/assets/bat-i0X4ZdIN.js +1 -0
- dw/server/ui/assets/bicep-B5-_aFwp.js +2 -0
- dw/server/ui/assets/cameligo-DMUM7wLl.js +1 -0
- dw/server/ui/assets/clojure-Cm7r79vr.js +1 -0
- dw/server/ui/assets/codicon-Brq4_Ui5.ttf +0 -0
- dw/server/ui/assets/coffee-Ba7i2nA0.js +1 -0
- dw/server/ui/assets/cpp-C7h46wYY.js +1 -0
- dw/server/ui/assets/csharp-BKxtCVv1.js +1 -0
- dw/server/ui/assets/csp-bTuwJoIa.js +1 -0
- dw/server/ui/assets/css-DIMkf-bt.js +3 -0
- dw/server/ui/assets/css.worker-B3ciXF_0.js +93 -0
- dw/server/ui/assets/cssMode-CPznxfY8.js +1 -0
- dw/server/ui/assets/cypher-CVaqCwHa.js +1 -0
- dw/server/ui/assets/dart-onAF5SnQ.js +1 -0
- dw/server/ui/assets/dockerfile-DZFCIeNp.js +1 -0
- dw/server/ui/assets/ecl-D05T4iGw.js +1 -0
- dw/server/ui/assets/editor-jjEx9u7D.css +1 -0
- dw/server/ui/assets/editor.api-CpWcotrd.js +847 -0
- dw/server/ui/assets/editor.worker-q-txB4vs.js +30 -0
- dw/server/ui/assets/elixir-6RTg0lbw.js +1 -0
- dw/server/ui/assets/flow9-C5_-GSwl.js +1 -0
- dw/server/ui/assets/freemarker2-CXtRM8N4.js +3 -0
- dw/server/ui/assets/fsharp-C8Ef5oNN.js +1 -0
- dw/server/ui/assets/go-C-y9NEjX.js +1 -0
- dw/server/ui/assets/graphql-fmXr3nnJ.js +1 -0
- dw/server/ui/assets/handlebars-N7x-6NMY.js +1 -0
- dw/server/ui/assets/hcl-CpzslTdj.js +1 -0
- dw/server/ui/assets/html-PhsdjHSr.js +1 -0
- dw/server/ui/assets/html.worker-C93Ht9o9.js +506 -0
- dw/server/ui/assets/htmlMode-Dgj0SEok.js +1 -0
- dw/server/ui/assets/index-3Vw6WAPW.css +1 -0
- dw/server/ui/assets/index-DgrYhQd9.js +43 -0
- dw/server/ui/assets/ini-sBoK_t0W.js +1 -0
- dw/server/ui/assets/java-BEtHBSE6.js +1 -0
- dw/server/ui/assets/javascript-BJqN9Qhv.js +1 -0
- dw/server/ui/assets/json.worker-B2V3pomh.js +62 -0
- dw/server/ui/assets/jsonMode-DbM4SWSv.js +7 -0
- dw/server/ui/assets/julia-Bri6UV-V.js +1 -0
- dw/server/ui/assets/kotlin-BOotOW0E.js +1 -0
- dw/server/ui/assets/less-B9JPFI3C.js +2 -0
- dw/server/ui/assets/lexon-CfSJPG6W.js +1 -0
- dw/server/ui/assets/liquid-BWr8lEc4.js +1 -0
- dw/server/ui/assets/lspLanguageFeatures-C1iGuDyZ.js +4 -0
- dw/server/ui/assets/lua-CsQS60Ue.js +1 -0
- dw/server/ui/assets/m3-D-oSqn_W.js +1 -0
- dw/server/ui/assets/markdown-Cimd5fb3.js +1 -0
- dw/server/ui/assets/mdx-DAdMi_0p.js +1 -0
- dw/server/ui/assets/mips-CIPQ_RoX.js +1 -0
- dw/server/ui/assets/monaco--ixms01u.css +1 -0
- dw/server/ui/assets/monaco-BGCeEqaw.js +56 -0
- dw/server/ui/assets/msdax-DauUninz.js +1 -0
- dw/server/ui/assets/mysql-SOo6toE5.js +1 -0
- dw/server/ui/assets/objective-c-FvmIjYaQ.js +1 -0
- dw/server/ui/assets/pascal-DrH0SRf2.js +1 -0
- dw/server/ui/assets/pascaligo-D-ptJ9y-.js +1 -0
- dw/server/ui/assets/perl-oz_6vUea.js +1 -0
- dw/server/ui/assets/pgsql-DTj74zXo.js +1 -0
- dw/server/ui/assets/php-nr791fC2.js +1 -0
- dw/server/ui/assets/pla-CopQ2nXW.js +1 -0
- dw/server/ui/assets/postiats-43DmfD33.js +1 -0
- dw/server/ui/assets/powerquery-D3hlyOfw.js +1 -0
- dw/server/ui/assets/powershell-DmHpPYUd.js +1 -0
- dw/server/ui/assets/protobuf-C531GsRP.js +2 -0
- dw/server/ui/assets/pug-Z5eAx3Zn.js +1 -0
- dw/server/ui/assets/python-Bcn70HdC.js +1 -0
- dw/server/ui/assets/qsharp-DkqhCAOL.js +1 -0
- dw/server/ui/assets/r-BwWrilGY.js +1 -0
- dw/server/ui/assets/razor-D1HmNnby.js +1 -0
- dw/server/ui/assets/redis-ClamHrr6.js +1 -0
- dw/server/ui/assets/redshift-DT7zqm-g.js +1 -0
- dw/server/ui/assets/restructuredtext-BYgofb2h.js +1 -0
- dw/server/ui/assets/ruby-DezsRK8O.js +1 -0
- dw/server/ui/assets/rust-DdL9SqIa.js +1 -0
- dw/server/ui/assets/sb-CcwsVR0C.js +1 -0
- dw/server/ui/assets/scala-DHpiXF5c.js +1 -0
- dw/server/ui/assets/scheme-BeGwcela.js +1 -0
- dw/server/ui/assets/scss-gp-XZpBa.js +3 -0
- dw/server/ui/assets/shell-CC2rA5mh.js +1 -0
- dw/server/ui/assets/solidity-BEEn4gHE.js +1 -0
- dw/server/ui/assets/sophia-CRfGWb83.js +1 -0
- dw/server/ui/assets/sparql-D_Lu-MrJ.js +1 -0
- dw/server/ui/assets/sql-NEE52Syq.js +1 -0
- dw/server/ui/assets/st-DbInun42.js +1 -0
- dw/server/ui/assets/swift-Bxkupp3x.js +1 -0
- dw/server/ui/assets/systemverilog-Bz4Y3fRF.js +1 -0
- dw/server/ui/assets/tcl-DISqw1ZD.js +1 -0
- dw/server/ui/assets/ts.worker-D7T1-Ig5.js +67738 -0
- dw/server/ui/assets/tsMode-D6u0XmOW.js +11 -0
- dw/server/ui/assets/twig-De2hgUGE.js +1 -0
- dw/server/ui/assets/typescript-BU6v-LMV.js +1 -0
- dw/server/ui/assets/typespec-B8J7ngcE.js +1 -0
- dw/server/ui/assets/vb-DV3o63ZY.js +1 -0
- dw/server/ui/assets/wgsl-DpFanUEy.js +298 -0
- dw/server/ui/assets/workers-Cn7cTUKr.js +1 -0
- dw/server/ui/assets/xml--0LP2Lwk.js +1 -0
- dw/server/ui/assets/yaml-mpBg9jnt.js +1 -0
- dw/server/ui/index.html +17 -0
- dw/server/updater.py +192 -0
- dw/settings.py +98 -0
- dw/shot_span_preflight.py +116 -0
- dw/shots.py +359 -0
- dw/slice_preflight.py +148 -0
- dw/step.py +187 -0
- dw/step_cache.py +442 -0
- dw/subfolders.py +107 -0
- dw/task_domains.py +307 -0
- dw/tasks/assess.py +826 -0
- dw/tasks/audio_transcription.py +88 -0
- dw/tasks/audio_utils.py +1862 -0
- dw/tasks/background_remover.py +43 -0
- dw/tasks/borders.py +113 -0
- dw/tasks/compose_text.py +74 -0
- dw/tasks/concat_videos.py +300 -0
- dw/tasks/depth_estimator.py +54 -0
- dw/tasks/diffusion_upscale.py +109 -0
- dw/tasks/dissolve_videos.py +342 -0
- dw/tasks/format_messages.py +24 -0
- dw/tasks/gather.py +173 -0
- dw/tasks/grade.py +97 -0
- dw/tasks/image_to_text.py +43 -0
- dw/tasks/image_utils.py +764 -0
- dw/tasks/interpolate_frames.py +252 -0
- dw/tasks/judge.py +68 -0
- dw/tasks/model_cache.py +55 -0
- dw/tasks/pair_audio.py +268 -0
- dw/tasks/qr_code.py +19 -0
- dw/tasks/restore_faces.py +175 -0
- dw/tasks/rife_model.py +192 -0
- dw/tasks/segment.py +121 -0
- dw/tasks/select.py +111 -0
- dw/tasks/speech_generation.py +228 -0
- dw/tasks/stabilize.py +129 -0
- dw/tasks/task.py +920 -0
- dw/tasks/tensor_image.py +57 -0
- dw/tasks/text_generation.py +169 -0
- dw/tasks/text_sections.py +80 -0
- dw/tasks/upscale.py +203 -0
- dw/tasks/video_utils.py +624 -0
- dw/tasks/zoe_depth.py +71 -0
- dw/teacache.py +381 -0
- dw/teacache_models.json +99 -0
- dw/test.py +29 -0
- dw/type_helpers.py +231 -0
- dw/validate.py +68 -0
- dw/variable_constraints.py +444 -0
- dw/variables.py +443 -0
- dw/video_extensions.py +141 -0
- dw/vram_estimate.py +116 -0
- dw/worker.py +764 -0
- dw/workflow.py +2007 -0
- dw/workflow_schema.json +1346 -0
- dw/workflow_sources.py +383 -0
- dw/workflows/h3_context_ir.json +57 -0
- dw/workflows/test.json +31 -0
- dw/workspace.py +730 -0
- dw_mcp/__init__.py +6 -0
- dw_mcp/__main__.py +133 -0
- dw_mcp/assets.py +336 -0
- dw_mcp/authoring.py +114 -0
- dw_mcp/catalog.py +360 -0
- dw_mcp/client.py +486 -0
- dw_mcp/diagnose.py +371 -0
- dw_mcp/exports.py +84 -0
- dw_mcp/guides.py +35 -0
- dw_mcp/media.py +638 -0
- dw_mcp/models.py +97 -0
- dw_mcp/prompts.py +104 -0
- dw_mcp/server.py +1343 -0
- dw_mcp/workspaces.py +212 -0
dw/plan.py
ADDED
|
@@ -0,0 +1,920 @@
|
|
|
1
|
+
"""The plan a validate call answers with: what a run of a workflow with a
|
|
2
|
+
caller's arguments will actually execute, what it will have to download
|
|
3
|
+
first, and what the workflow's own cost block says it will take - with a
|
|
4
|
+
fingerprint over the work, so an acknowledgement can be bound to it and a
|
|
5
|
+
run whose shape changed after consent refused (#85, stage 2).
|
|
6
|
+
|
|
7
|
+
Everything here is derived from the same resolvers the run uses -
|
|
8
|
+
`realize_workflow` folds the arguments and inlines the prompts, and
|
|
9
|
+
`Workflow.expanded_definition` substitutes and expands `for_each` - so the
|
|
10
|
+
plan describes the run and not an approximation of it. Nothing here knows a
|
|
11
|
+
model: every minute comes from a `cost` block and every repo name from a
|
|
12
|
+
`from_pretrained_arguments`.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import copy
|
|
16
|
+
import hashlib
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
import os
|
|
20
|
+
|
|
21
|
+
from huggingface_hub import model_info
|
|
22
|
+
from huggingface_hub import get_hf_file_metadata, hf_hub_url
|
|
23
|
+
from huggingface_hub.utils import GatedRepoError, HFValidationError, validate_repo_id
|
|
24
|
+
|
|
25
|
+
from .elision import elide_definition
|
|
26
|
+
from .hub_cache import repo_download_incomplete, scan_models
|
|
27
|
+
from .realize import (
|
|
28
|
+
BUILTIN_PREFIX,
|
|
29
|
+
VARIABLE_PREFIX,
|
|
30
|
+
read_sub_workflow,
|
|
31
|
+
realize_workflow,
|
|
32
|
+
)
|
|
33
|
+
from .security import validate_url
|
|
34
|
+
from .workflow import Workflow
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger("dw")
|
|
37
|
+
|
|
38
|
+
FINGERPRINT_PREFIX = "sha256:"
|
|
39
|
+
# Top-level keys that document a workflow rather than shape its work
|
|
40
|
+
DOCUMENTATION_KEYS = ("cost", "description", "summary", "configures")
|
|
41
|
+
FOR_EACH_KEY = "for_each"
|
|
42
|
+
SIZE_LOOKUP_TIMEOUT = 5.0
|
|
43
|
+
GIB = 1024**3
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def build_plan(
|
|
47
|
+
candidate,
|
|
48
|
+
arguments,
|
|
49
|
+
*,
|
|
50
|
+
device,
|
|
51
|
+
prompt_dir=None,
|
|
52
|
+
cache_dir=None,
|
|
53
|
+
lookup_sizes=True,
|
|
54
|
+
cache_probe=None,
|
|
55
|
+
observed=None,
|
|
56
|
+
observed_for_child=None,
|
|
57
|
+
):
|
|
58
|
+
"""What a run of `candidate` with `arguments` will execute and cost.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
candidate: The Workflow the route built - it carries the file spec
|
|
62
|
+
(so base_dir), the output root and the confinement a run has.
|
|
63
|
+
arguments: The caller's arguments, already past `argument_errors`;
|
|
64
|
+
an undeclared name or an uncoercible value raises here.
|
|
65
|
+
device: The backend that is serving - 'cuda', 'mps' or 'cpu'.
|
|
66
|
+
prompt_dir: The prompt library, for inlining.
|
|
67
|
+
cache_dir: The hub cache to check downloads against; None for the
|
|
68
|
+
default.
|
|
69
|
+
lookup_sizes: Whether to ask the hub how large a missing repo is.
|
|
70
|
+
cache_probe: A callable taking the run's arguments and answering the
|
|
71
|
+
step names the worker's cache would serve, or None when it
|
|
72
|
+
cannot say; without one `cached_steps` is None.
|
|
73
|
+
observed: This box's own history for this workflow, as an `observed`
|
|
74
|
+
block (dw/server/observed_cost.py) or a callable taking the
|
|
75
|
+
run's arguments and answering one - what the estimate quotes in
|
|
76
|
+
preference to a curated figure (#154). None on a caller that has
|
|
77
|
+
no history to offer, which is every caller but the server.
|
|
78
|
+
observed_for_child: A callable taking a composed child's local path,
|
|
79
|
+
its parsed definition, and the composing step's own `arguments`,
|
|
80
|
+
answering that child's own `observed` block or None - so a
|
|
81
|
+
composing workflow's estimate can roll up a child's history
|
|
82
|
+
instead of resetting to `unknown` when the parent has no figure
|
|
83
|
+
of its own (#268), bucketed against the value the composing step
|
|
84
|
+
actually passes rather than always the child's stored defaults
|
|
85
|
+
(#341). None on a caller that cannot resolve a child's catalog
|
|
86
|
+
name to look history up by.
|
|
87
|
+
"""
|
|
88
|
+
definition = candidate.workflow_definition
|
|
89
|
+
base_dir = (
|
|
90
|
+
os.path.dirname(os.path.abspath(candidate.file_spec))
|
|
91
|
+
if candidate.file_spec
|
|
92
|
+
else None
|
|
93
|
+
)
|
|
94
|
+
realized, annotations = realize_workflow(
|
|
95
|
+
definition,
|
|
96
|
+
arguments,
|
|
97
|
+
seed=0,
|
|
98
|
+
base_dir=base_dir,
|
|
99
|
+
prompt_dir=prompt_dir,
|
|
100
|
+
output_root=candidate.output_dir,
|
|
101
|
+
workflow_dir=candidate.workflow_dir,
|
|
102
|
+
pin_outputs=False,
|
|
103
|
+
)
|
|
104
|
+
# Arguments are already folded into the realized variables, so the
|
|
105
|
+
# expansion takes none; it substitutes and expands exactly as the run
|
|
106
|
+
expanded = Workflow(
|
|
107
|
+
realized, candidate.output_dir, candidate.file_spec, candidate.workflow_dir
|
|
108
|
+
).expanded_definition()
|
|
109
|
+
# The plan is what the run does, and a run does not execute a step
|
|
110
|
+
# nothing reads (dw/elision.py, #122) - so the step count, the downloads
|
|
111
|
+
# and the fingerprint are all taken after elision, and the acknowledged
|
|
112
|
+
# cost is the cost of the work that happens
|
|
113
|
+
elided = elide_definition(expanded, definition)
|
|
114
|
+
entries = list_entries(definition, realized)
|
|
115
|
+
measured_entries = list_entries(definition, definition)
|
|
116
|
+
step_count = len(expanded.get("steps") or [])
|
|
117
|
+
cache_hits = cached_steps(definition, realized, arguments, cache_probe)
|
|
118
|
+
return {
|
|
119
|
+
"fingerprint": fingerprint(expanded, definition, annotations),
|
|
120
|
+
"steps": step_count,
|
|
121
|
+
"elided_steps": elided,
|
|
122
|
+
"list_entries": entries,
|
|
123
|
+
"cached_steps": cache_hits,
|
|
124
|
+
"downloads_required": downloads_required(
|
|
125
|
+
expanded, base_dir, candidate.workflow_dir, cache_dir, lookup_sizes
|
|
126
|
+
),
|
|
127
|
+
"estimate": estimate(
|
|
128
|
+
definition,
|
|
129
|
+
expanded,
|
|
130
|
+
entries,
|
|
131
|
+
device,
|
|
132
|
+
base_dir,
|
|
133
|
+
candidate.workflow_dir,
|
|
134
|
+
measured_entries=measured_entries,
|
|
135
|
+
observed=_observed_block(observed, arguments),
|
|
136
|
+
cached_steps=cache_hits,
|
|
137
|
+
total_steps=step_count,
|
|
138
|
+
observed_for_child=observed_for_child,
|
|
139
|
+
),
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _observed_block(observed, arguments):
|
|
144
|
+
"""The `observed` block for this run, from a block or from a callable
|
|
145
|
+
the caller passed - best effort, since a figure is a nicety and a plan
|
|
146
|
+
that raised because history could not be read would cost the caller the
|
|
147
|
+
whole pre-flight."""
|
|
148
|
+
if not callable(observed):
|
|
149
|
+
return observed if isinstance(observed, dict) else None
|
|
150
|
+
try:
|
|
151
|
+
block = observed(arguments or {})
|
|
152
|
+
except Exception:
|
|
153
|
+
logger.debug("No observed history for the plan", exc_info=True)
|
|
154
|
+
return None
|
|
155
|
+
return block if isinstance(block, dict) else None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def list_entries(definition, realized):
|
|
159
|
+
"""{variable: length} for every `for_each` that names a list variable,
|
|
160
|
+
read from `realized`'s folded variables - a literal list is not an
|
|
161
|
+
argument and is not listed.
|
|
162
|
+
|
|
163
|
+
Passing `definition` as both arguments answers the lengths the
|
|
164
|
+
workflow's *stored defaults* carry, which is the list a catalog `cost`
|
|
165
|
+
figure was measured against (`estimate`).
|
|
166
|
+
"""
|
|
167
|
+
variables = realized.get("variables") or {}
|
|
168
|
+
entries = {}
|
|
169
|
+
for step in definition.get("steps") or []:
|
|
170
|
+
if not isinstance(step, dict):
|
|
171
|
+
continue
|
|
172
|
+
reference = step.get(FOR_EACH_KEY)
|
|
173
|
+
if isinstance(reference, str) and reference.startswith(VARIABLE_PREFIX):
|
|
174
|
+
name = reference.removeprefix(VARIABLE_PREFIX)
|
|
175
|
+
value = variables.get(name)
|
|
176
|
+
if isinstance(value, list):
|
|
177
|
+
entries[name] = len(value)
|
|
178
|
+
return entries
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# `estimate`'s own `list_entries` parameter (the parent's) shadows this
|
|
182
|
+
# function's name in its scope - a child's entries are computed under this
|
|
183
|
+
# alias instead (#341)
|
|
184
|
+
_list_entries = list_entries
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def cached_steps(definition, realized, arguments, cache_probe):
|
|
188
|
+
"""How many steps the step cache would answer for this run: 0 without
|
|
189
|
+
asking when the workflow is unseeded (the cache is off then), None
|
|
190
|
+
when there is no probe or the probe cannot answer, else the count."""
|
|
191
|
+
if not _is_seeded(definition, arguments):
|
|
192
|
+
return 0
|
|
193
|
+
if cache_probe is None:
|
|
194
|
+
return None
|
|
195
|
+
answer = cache_probe(arguments or {})
|
|
196
|
+
return len(answer) if isinstance(answer, list) else None
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def unseeded_cache_warnings(definition, arguments=None):
|
|
200
|
+
"""Say once, where a caller is already looking, that an unseeded workflow
|
|
201
|
+
gets no step cache at all.
|
|
202
|
+
|
|
203
|
+
`cached_steps: 0` is indistinguishable from 'probed, nothing hit' out
|
|
204
|
+
there, and the difference is the one that matters: without a `seed` the
|
|
205
|
+
cache is off, so nothing is ever reused however many times the same
|
|
206
|
+
workflow runs (#107).
|
|
207
|
+
|
|
208
|
+
Silent for a workflow with no `pipeline`/`pipeline_reference`/`workflow`
|
|
209
|
+
step: a task-only utility has no generative randomness a `seed` would
|
|
210
|
+
pin down in the first place, and each of its steps is a pure function of
|
|
211
|
+
its inputs - a repeat run is already free without one (#247)
|
|
212
|
+
"""
|
|
213
|
+
if _is_seeded(definition, arguments) or not _has_seedable_step(definition):
|
|
214
|
+
return []
|
|
215
|
+
return [
|
|
216
|
+
"This workflow sets no 'seed', so the step cache is disabled and "
|
|
217
|
+
"'cached_steps' is 0 without being probed - every step regenerates "
|
|
218
|
+
"on every run. Set a top-level 'seed': 'variable:seed' with a "
|
|
219
|
+
"declared default in 'variables' to make a repeat run reuse what it "
|
|
220
|
+
"already produced"
|
|
221
|
+
]
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _has_seedable_step(definition):
|
|
225
|
+
"""Whether any step could consume a seed: a pipeline (inline or
|
|
226
|
+
referenced) or a sub-workflow, which may hold one in turn. A workflow
|
|
227
|
+
built entirely of `task` steps has nothing a seed would affect."""
|
|
228
|
+
for step in definition.get("steps") or []:
|
|
229
|
+
if not isinstance(step, dict):
|
|
230
|
+
continue
|
|
231
|
+
if "pipeline" in step or "pipeline_reference" in step or "workflow" in step:
|
|
232
|
+
return True
|
|
233
|
+
return False
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _is_seeded(definition, arguments):
|
|
237
|
+
"""Whether a run of this workflow has a seed before it draws one - read
|
|
238
|
+
from the definition as written and the caller's arguments, since
|
|
239
|
+
realization pins a seed of its own into the copy."""
|
|
240
|
+
seed = definition.get("seed")
|
|
241
|
+
if isinstance(seed, str) and seed.startswith(VARIABLE_PREFIX):
|
|
242
|
+
name = seed.removeprefix(VARIABLE_PREFIX)
|
|
243
|
+
if name in (arguments or {}):
|
|
244
|
+
return arguments[name] is not None
|
|
245
|
+
return (definition.get("variables") or {}).get(name) is not None
|
|
246
|
+
return seed is not None
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def fingerprint(expanded, definition, annotations=None):
|
|
250
|
+
"""SHA-256 over the expanded definition with everything that is not
|
|
251
|
+
work removed: the seed wherever it sits, and the documentation keys.
|
|
252
|
+
|
|
253
|
+
`definition` is the workflow as written, consulted for whether the
|
|
254
|
+
top-level seed named a variable - if it did, that variable's folded
|
|
255
|
+
value is the seed too and is blanked at its source. `annotations` is
|
|
256
|
+
what realization recorded beside the copy; its sub-workflow digests go
|
|
257
|
+
into the hash, since a composed child edited between the quote and the
|
|
258
|
+
call is different work the parent's text cannot show.
|
|
259
|
+
"""
|
|
260
|
+
doc = copy.deepcopy(expanded)
|
|
261
|
+
if annotations and annotations.get("sub_workflows"):
|
|
262
|
+
doc["__sub_workflows__"] = dict(annotations["sub_workflows"])
|
|
263
|
+
doc.pop("seed", None)
|
|
264
|
+
for key in DOCUMENTATION_KEYS:
|
|
265
|
+
doc.pop(key, None)
|
|
266
|
+
written_seed = definition.get("seed")
|
|
267
|
+
if isinstance(written_seed, str) and written_seed.startswith(VARIABLE_PREFIX):
|
|
268
|
+
name = written_seed.removeprefix(VARIABLE_PREFIX)
|
|
269
|
+
variables = doc.get("variables")
|
|
270
|
+
if isinstance(variables, dict) and name in variables:
|
|
271
|
+
variables[name] = None
|
|
272
|
+
for step in doc.get("steps") or []:
|
|
273
|
+
if isinstance(step, dict):
|
|
274
|
+
step.pop("seed", None)
|
|
275
|
+
pipeline = step.get("pipeline")
|
|
276
|
+
if isinstance(pipeline, dict):
|
|
277
|
+
pipeline.pop("seed", None)
|
|
278
|
+
# default=repr: a realized 'constant:' can be any Python value, and the
|
|
279
|
+
# fingerprint only needs it to be stable, not round-trippable
|
|
280
|
+
serialized = json.dumps(
|
|
281
|
+
doc, sort_keys=True, separators=(",", ":"), ensure_ascii=False, default=repr
|
|
282
|
+
)
|
|
283
|
+
return FINGERPRINT_PREFIX + hashlib.sha256(serialized.encode("utf-8")).hexdigest()
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
UNKNOWN = "unknown"
|
|
287
|
+
CATALOG = "catalog"
|
|
288
|
+
PER_ENTRY = "per_entry"
|
|
289
|
+
DERIVED = "derived"
|
|
290
|
+
OTHER_DEVICE = "other_device"
|
|
291
|
+
OBSERVED = "observed"
|
|
292
|
+
|
|
293
|
+
# #301: a single run is not the same statistical basis as a dozen. Below
|
|
294
|
+
# this many observed runs, an "observed" figure is tempered rather than
|
|
295
|
+
# quoted at full authority - see `_tempered`.
|
|
296
|
+
SMALL_N_THRESHOLD = 3
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _tempered(block, curated_minutes):
|
|
300
|
+
"""An `observed` estimate below `SMALL_N_THRESHOLD` runs, corrected
|
|
301
|
+
toward the curated figure it might be papering over rather than
|
|
302
|
+
presented as if it carried the same authority as a dozen runs (#301).
|
|
303
|
+
|
|
304
|
+
With a curated minutes figure to blend toward, the point estimate is
|
|
305
|
+
pulled toward it in proportion to how thin the history is - one run
|
|
306
|
+
counts for a third of the blend, two for two thirds, three or more not
|
|
307
|
+
at all. The blend is marked `tempered: true` with `observed_minutes`
|
|
308
|
+
(the raw point figure, the same number `list_workflows`' own
|
|
309
|
+
`observed_minutes` reports) and `curated_minutes` (what it blended
|
|
310
|
+
toward) alongside it, so a caller can reconcile the returned `minutes`
|
|
311
|
+
against either without the two disagreeing silently under the same
|
|
312
|
+
`basis: "observed"` label (#319). With none to blend toward there is
|
|
313
|
+
nothing to correct with, so the number is left alone and a
|
|
314
|
+
`low_confidence` flag is added instead: machine-checkable without
|
|
315
|
+
requiring a caller to know to inspect `runs` itself. No new
|
|
316
|
+
range/uncertainty math (rejected as more surface than the problem
|
|
317
|
+
needs) - just these two, approved shapes.
|
|
318
|
+
"""
|
|
319
|
+
runs = block.get("runs")
|
|
320
|
+
if (
|
|
321
|
+
not isinstance(runs, int)
|
|
322
|
+
or runs >= SMALL_N_THRESHOLD
|
|
323
|
+
or block["minutes"] is None
|
|
324
|
+
):
|
|
325
|
+
return block
|
|
326
|
+
if curated_minutes is None:
|
|
327
|
+
return {**block, "low_confidence": True}
|
|
328
|
+
observed_minutes = block["minutes"]
|
|
329
|
+
weight = runs / SMALL_N_THRESHOLD
|
|
330
|
+
blended = curated_minutes * (1 - weight) + observed_minutes * weight
|
|
331
|
+
return {
|
|
332
|
+
**block,
|
|
333
|
+
"minutes": round(blended, 1),
|
|
334
|
+
"tempered": True,
|
|
335
|
+
"observed_minutes": observed_minutes,
|
|
336
|
+
"curated_minutes": curated_minutes,
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _cached_minutes(minutes, cached_steps, total_steps):
|
|
341
|
+
"""The share of `minutes` a caller would actually wait for, once the
|
|
342
|
+
steps already in the step cache are subtracted (#255).
|
|
343
|
+
|
|
344
|
+
None when there is nothing to subtract from (`minutes`), no probe
|
|
345
|
+
(`cached_steps` is None) or no step count to take a share of; equal to
|
|
346
|
+
`minutes` itself when the probe found nothing cached, and 0.0 when it
|
|
347
|
+
found the whole run cached.
|
|
348
|
+
"""
|
|
349
|
+
if minutes is None or cached_steps is None or not total_steps:
|
|
350
|
+
return None
|
|
351
|
+
remaining = max(0, total_steps - cached_steps)
|
|
352
|
+
return round(minutes * remaining / total_steps, 1)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
COST_DRIVERS_KEY = "cost_drivers"
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _declared_drivers(definition):
|
|
359
|
+
"""The variables the author says move this workflow's cost - the same
|
|
360
|
+
rule `dw/server/observed_cost.py`'s `declared_drivers` applies, kept
|
|
361
|
+
local so this module does not import the server package (#267)."""
|
|
362
|
+
drivers = definition.get(COST_DRIVERS_KEY)
|
|
363
|
+
if not isinstance(drivers, list):
|
|
364
|
+
return []
|
|
365
|
+
variables = definition.get("variables") or {}
|
|
366
|
+
return [name for name in drivers if isinstance(name, str) and name in variables]
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _driver_comparable(value):
|
|
370
|
+
"""A driver value as something hashable and stable across JSON round
|
|
371
|
+
trips, mirroring `dw/server/observed_cost.py`'s `_comparable`."""
|
|
372
|
+
if isinstance(value, bool) or value is None:
|
|
373
|
+
return value
|
|
374
|
+
if isinstance(value, (int, float)):
|
|
375
|
+
return float(value)
|
|
376
|
+
if isinstance(value, str):
|
|
377
|
+
try:
|
|
378
|
+
return float(value)
|
|
379
|
+
except ValueError:
|
|
380
|
+
return value
|
|
381
|
+
return json.dumps(value, sort_keys=True, default=str)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _scalar_driver_shifted(definition, expanded, list_entries):
|
|
385
|
+
"""Whether a declared, non-list `cost_driver` was overridden away from
|
|
386
|
+
the default value the curated `cost` was measured against (#267).
|
|
387
|
+
|
|
388
|
+
A list driver is `_repriced`'s to catch - it changes the plan's list
|
|
389
|
+
count, not a bare variable's value. This only asks about a scalar one,
|
|
390
|
+
which `_repriced` never looks at, so a `catalog` figure measured for one
|
|
391
|
+
`num_frames` silently priced a run at a different one.
|
|
392
|
+
"""
|
|
393
|
+
defaults = definition.get("variables") or {}
|
|
394
|
+
effective = expanded.get("variables") or {}
|
|
395
|
+
for name in _declared_drivers(definition):
|
|
396
|
+
if name in list_entries:
|
|
397
|
+
continue
|
|
398
|
+
default_value = defaults.get(name)
|
|
399
|
+
if isinstance(default_value, list):
|
|
400
|
+
continue
|
|
401
|
+
if _driver_comparable(effective.get(name)) != _driver_comparable(default_value):
|
|
402
|
+
return True
|
|
403
|
+
return False
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def estimate(
|
|
407
|
+
definition,
|
|
408
|
+
expanded,
|
|
409
|
+
list_entries,
|
|
410
|
+
device,
|
|
411
|
+
base_dir,
|
|
412
|
+
workflow_dir,
|
|
413
|
+
measured_entries=None,
|
|
414
|
+
observed=None,
|
|
415
|
+
cached_steps=None,
|
|
416
|
+
total_steps=None,
|
|
417
|
+
observed_for_child=None,
|
|
418
|
+
):
|
|
419
|
+
"""Minutes from this box's own history when it has one, else from the
|
|
420
|
+
workflow's own cost block, re-priced for the caller's list, plus each
|
|
421
|
+
composed child's.
|
|
422
|
+
|
|
423
|
+
`basis` names where the figure came from - the honesty is in the field,
|
|
424
|
+
not in a fabricated number: 'unknown' is no cost block at all (or a
|
|
425
|
+
list this figure cannot honestly be re-priced for), 'other_device' a
|
|
426
|
+
figure measured on a backend other than the one serving (reported so
|
|
427
|
+
the agent has something to scale, flagged so it is not quoted as a
|
|
428
|
+
measurement), 'catalog' the stored total for a run whose lists are the
|
|
429
|
+
ones it was measured with, 'per_entry' that total re-priced from a
|
|
430
|
+
measured per-entry rate, and 'derived' re-priced by extrapolating the
|
|
431
|
+
stored total linearly over a list whose length the caller changed
|
|
432
|
+
(#85) - an estimate, not a measurement, and the only alternative to
|
|
433
|
+
quoting a 5-shot figure for a 10-shot run.
|
|
434
|
+
|
|
435
|
+
`measured_entries` is what the stored defaults carry, which is the
|
|
436
|
+
list the catalog figure was measured against; without it a catalog
|
|
437
|
+
figure is taken at face value.
|
|
438
|
+
|
|
439
|
+
`observed` is this box's own history for the run being planned
|
|
440
|
+
(dw/server/observed_cost.py), and it wins: `list_workflows` already
|
|
441
|
+
tells a consumer to "prefer [observed] when quoting a price for this
|
|
442
|
+
machine, fall back to `cost`", and `basis: "observed"` says which one
|
|
443
|
+
was used, so the two claims stay distinguishable. It is quoted only
|
|
444
|
+
from the *cold* median - the one figure comparable to a curated `cost`,
|
|
445
|
+
wall clock including the model load - and only for the bucket the
|
|
446
|
+
caller's own arguments fall in, so a resized list falls back to the
|
|
447
|
+
curated figure rather than quoting the default list's minutes (#154).
|
|
448
|
+
A composed child is not added to it: an observed run is the whole run,
|
|
449
|
+
children included, already measured.
|
|
450
|
+
"""
|
|
451
|
+
measured = _observed(observed, device)
|
|
452
|
+
own = _price(definition.get("cost"), device, list_entries, measured_entries or {})
|
|
453
|
+
if own["basis"] == CATALOG and _scalar_driver_shifted(
|
|
454
|
+
definition, expanded, list_entries
|
|
455
|
+
):
|
|
456
|
+
# A scalar cost_driver (H3's num_frames, say) moved away from the
|
|
457
|
+
# value the curated cost was measured against, and _repriced only
|
|
458
|
+
# re-prices a for_each list's length - so the catalog figure would
|
|
459
|
+
# otherwise be quoted for a run it was never measured for (#267)
|
|
460
|
+
own = {"minutes": None, "basis": UNKNOWN, "measured_on": None}
|
|
461
|
+
if measured is not None:
|
|
462
|
+
measured = _tempered(measured, own["minutes"])
|
|
463
|
+
measured["cached_minutes"] = _cached_minutes(
|
|
464
|
+
measured["minutes"], cached_steps, total_steps
|
|
465
|
+
)
|
|
466
|
+
return measured
|
|
467
|
+
minutes = own["minutes"]
|
|
468
|
+
# An unpriced parent (own["minutes"] is None) whose total ends up coming
|
|
469
|
+
# only from a priced child is not a complete figure - the parent's own
|
|
470
|
+
# steps (a for_each step with no cost block, say) contributed nothing to
|
|
471
|
+
# it, so the sum understates the run rather than merely omitting a piece
|
|
472
|
+
# of it (#242). `unpriced` names each contributor that landed here, so a
|
|
473
|
+
# caller can tell a trivial utility step from an unpriced 12-shot loop
|
|
474
|
+
# apart rather than just seeing `partial: true` (#252)
|
|
475
|
+
partial = minutes is None and not _only_composes_children(definition)
|
|
476
|
+
unpriced = [definition.get("id", "workflow")] if partial else []
|
|
477
|
+
had_child = False
|
|
478
|
+
children_all_observed = True
|
|
479
|
+
child_runs = []
|
|
480
|
+
child_measured_on = set()
|
|
481
|
+
for path, step_arguments in _sub_workflow_paths(expanded):
|
|
482
|
+
had_child = True
|
|
483
|
+
# A builtin is the parent's to price; a local child prices itself
|
|
484
|
+
raw = read_sub_workflow(path, base_dir, workflow_dir)
|
|
485
|
+
child_definition = None
|
|
486
|
+
child_cost = None
|
|
487
|
+
if raw is not None:
|
|
488
|
+
try:
|
|
489
|
+
child_definition = json.loads(raw)
|
|
490
|
+
child_cost = child_definition.get("cost")
|
|
491
|
+
except (ValueError, AttributeError):
|
|
492
|
+
child_definition = None
|
|
493
|
+
child_cost = None
|
|
494
|
+
child_observed = None
|
|
495
|
+
# A child's observed figure only ever feeds a total that can
|
|
496
|
+
# honestly end up basis: observed (own["minutes"] is None, below) -
|
|
497
|
+
# a priced parent's own basis is 'catalog', and summing an observed
|
|
498
|
+
# child into it produced a total that did not match either figure
|
|
499
|
+
# while still claiming 'catalog' (#315)
|
|
500
|
+
if (
|
|
501
|
+
own["minutes"] is None
|
|
502
|
+
and observed_for_child is not None
|
|
503
|
+
and child_definition is not None
|
|
504
|
+
):
|
|
505
|
+
try:
|
|
506
|
+
# `step_arguments` are the composing step's own overrides -
|
|
507
|
+
# passed through so a child observed lookup buckets against
|
|
508
|
+
# the value this step actually runs with rather than always
|
|
509
|
+
# the child's stored defaults (#341)
|
|
510
|
+
child_observed = observed_for_child(
|
|
511
|
+
path, child_definition, step_arguments
|
|
512
|
+
)
|
|
513
|
+
except Exception:
|
|
514
|
+
child_observed = None
|
|
515
|
+
child = _observed(child_observed, device)
|
|
516
|
+
if child is None:
|
|
517
|
+
children_all_observed = False
|
|
518
|
+
child_list_entries = {}
|
|
519
|
+
child_measured_entries = {}
|
|
520
|
+
child_expanded = {"variables": {}}
|
|
521
|
+
if child_definition is not None:
|
|
522
|
+
child_measured_entries = _list_entries(
|
|
523
|
+
child_definition, child_definition
|
|
524
|
+
)
|
|
525
|
+
# The composing step's own `arguments` are what the child
|
|
526
|
+
# actually runs with - folded over its declared defaults the
|
|
527
|
+
# same way a caller's arguments are, since `expanded` has
|
|
528
|
+
# already substituted them to concrete values (#341)
|
|
529
|
+
effective_variables = dict(child_definition.get("variables") or {})
|
|
530
|
+
effective_variables.update(step_arguments)
|
|
531
|
+
child_expanded = {"variables": effective_variables}
|
|
532
|
+
child_list_entries = _list_entries(child_definition, child_expanded)
|
|
533
|
+
child = _price(
|
|
534
|
+
child_cost, device, child_list_entries, child_measured_entries
|
|
535
|
+
)
|
|
536
|
+
if (
|
|
537
|
+
child["basis"] == CATALOG
|
|
538
|
+
and child_definition is not None
|
|
539
|
+
and (
|
|
540
|
+
_scalar_driver_shifted(
|
|
541
|
+
child_definition, child_expanded, child_list_entries
|
|
542
|
+
)
|
|
543
|
+
)
|
|
544
|
+
):
|
|
545
|
+
# A scalar cost_driver the composing step overrode (H3's
|
|
546
|
+
# num_frames at 345 against a default of 124, say) is the
|
|
547
|
+
# same #267 failure one level down - the child's own
|
|
548
|
+
# catalog figure was never measured for the value this
|
|
549
|
+
# step actually passes it (#341)
|
|
550
|
+
child = {"minutes": None, "basis": UNKNOWN, "measured_on": None}
|
|
551
|
+
else:
|
|
552
|
+
child_runs.append(child["runs"])
|
|
553
|
+
child_measured_on.add(child["measured_on"])
|
|
554
|
+
if child["minutes"] is None:
|
|
555
|
+
partial = True
|
|
556
|
+
unpriced.append(path)
|
|
557
|
+
elif minutes is not None:
|
|
558
|
+
minutes += child["minutes"]
|
|
559
|
+
else:
|
|
560
|
+
minutes = child["minutes"]
|
|
561
|
+
if minutes is None:
|
|
562
|
+
partial = False
|
|
563
|
+
unpriced = []
|
|
564
|
+
top_basis = own["basis"]
|
|
565
|
+
top_measured_on = own["measured_on"]
|
|
566
|
+
top_runs = None
|
|
567
|
+
if own["minutes"] is None and had_child and children_all_observed and not partial:
|
|
568
|
+
# Every composing child's share of the total was this box's own
|
|
569
|
+
# history rather than a static figure, and the parent contributed
|
|
570
|
+
# nothing of its own to disagree with that - so the whole total is
|
|
571
|
+
# as good as observed rather than "unknown" (#268), mirroring the
|
|
572
|
+
# existing rule that a child's catalog cost is skipped once the
|
|
573
|
+
# *parent* has an observed figure, to avoid double-counting.
|
|
574
|
+
# The children's own `runs`/`measured_on` come along with the
|
|
575
|
+
# inherited basis (#275) - an "observed" estimate with `runs: null`
|
|
576
|
+
# says it was measured but not how many times, which is the number
|
|
577
|
+
# a caller uses to decide how much to trust the figure. `runs` is
|
|
578
|
+
# the weakest history across children (the min), and `measured_on`
|
|
579
|
+
# is named only when every child agrees on the device.
|
|
580
|
+
top_basis = OBSERVED
|
|
581
|
+
top_runs = min(child_runs) if child_runs else None
|
|
582
|
+
top_measured_on = (
|
|
583
|
+
next(iter(child_measured_on)) if len(child_measured_on) == 1 else None
|
|
584
|
+
)
|
|
585
|
+
result = {
|
|
586
|
+
"minutes": round(minutes, 1) if minutes is not None else None,
|
|
587
|
+
"basis": top_basis,
|
|
588
|
+
"device": device,
|
|
589
|
+
"measured_on": top_measured_on,
|
|
590
|
+
"partial": partial,
|
|
591
|
+
"unpriced": unpriced,
|
|
592
|
+
"runs": top_runs,
|
|
593
|
+
"cached_minutes": _cached_minutes(
|
|
594
|
+
round(minutes, 1) if minutes is not None else None,
|
|
595
|
+
cached_steps,
|
|
596
|
+
total_steps,
|
|
597
|
+
),
|
|
598
|
+
}
|
|
599
|
+
if top_basis == OBSERVED:
|
|
600
|
+
# The rolled-up-from-children case (#268): no curated figure of the
|
|
601
|
+
# parent's own exists to blend toward (that is this branch's own
|
|
602
|
+
# precondition, above), so a thin roll-up gets the low_confidence
|
|
603
|
+
# flag rather than a blend. Never changes `minutes`, so
|
|
604
|
+
# `cached_minutes` above already reflects it.
|
|
605
|
+
result = _tempered(result, None)
|
|
606
|
+
return result
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def _observed(observed, device):
|
|
610
|
+
"""This box's history as an estimate, or None when it has nothing to
|
|
611
|
+
quote from.
|
|
612
|
+
|
|
613
|
+
Withheld rather than quoted when the history is a different backend's
|
|
614
|
+
(a card swapped under the same jobs table), or when every comparable run
|
|
615
|
+
was warm: a warm run had the weights already resident and quoting it as
|
|
616
|
+
the cost of a run that has to load them would under-quote by the minutes
|
|
617
|
+
this estimate exists to name.
|
|
618
|
+
"""
|
|
619
|
+
if not isinstance(observed, dict):
|
|
620
|
+
return None
|
|
621
|
+
minutes = observed.get("cold_minutes")
|
|
622
|
+
runs = observed.get("cold_runs")
|
|
623
|
+
if not isinstance(minutes, (int, float)) or not runs:
|
|
624
|
+
return None
|
|
625
|
+
if observed.get("device") not in (None, device):
|
|
626
|
+
return None
|
|
627
|
+
return {
|
|
628
|
+
"minutes": round(float(minutes), 1),
|
|
629
|
+
"basis": OBSERVED,
|
|
630
|
+
"device": device,
|
|
631
|
+
"measured_on": observed.get("name"),
|
|
632
|
+
"partial": False,
|
|
633
|
+
"unpriced": [],
|
|
634
|
+
"runs": runs,
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _sub_workflow_paths(expanded):
|
|
639
|
+
"""The local (non-builtin) sub-workflow path and composing arguments of
|
|
640
|
+
every composing step, as (path, arguments) - `expanded` has already
|
|
641
|
+
substituted and expanded `for_each`, so each occurrence carries the
|
|
642
|
+
concrete arguments that step actually passes the child (#341)."""
|
|
643
|
+
for step in expanded.get("steps") or []:
|
|
644
|
+
reference = step.get("workflow") if isinstance(step, dict) else None
|
|
645
|
+
path = reference.get("path") if isinstance(reference, dict) else None
|
|
646
|
+
if isinstance(path, str) and not path.startswith(BUILTIN_PREFIX):
|
|
647
|
+
arguments = reference.get("arguments")
|
|
648
|
+
yield path, arguments if isinstance(arguments, dict) else {}
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def _only_composes_children(definition):
|
|
652
|
+
"""Whether every step the workflow *declares* is a `workflow` step -
|
|
653
|
+
i.e. the parent does no work of its own beyond assembling children.
|
|
654
|
+
|
|
655
|
+
Distinguishes an uncosted parent that is pure composition (#268: its
|
|
656
|
+
children's figures are the whole story) from one with real uncosted work
|
|
657
|
+
of its own, such as a `for_each` task step with no `cost` block (#242:
|
|
658
|
+
a priced child there still leaves a genuine gap). Reads the *written*
|
|
659
|
+
definition rather than `expanded`: a step that saves nothing and that
|
|
660
|
+
nothing reads is elided from the run (#122), and an elided own step is
|
|
661
|
+
still work the author declared - it must not be mistaken for pure
|
|
662
|
+
composition just because it would not execute.
|
|
663
|
+
"""
|
|
664
|
+
steps = definition.get("steps") or []
|
|
665
|
+
if not steps:
|
|
666
|
+
return False
|
|
667
|
+
for step in steps:
|
|
668
|
+
reference = step.get("workflow") if isinstance(step, dict) else None
|
|
669
|
+
if not isinstance(reference, dict) or not isinstance(
|
|
670
|
+
reference.get("path"), str
|
|
671
|
+
):
|
|
672
|
+
return False
|
|
673
|
+
return True
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def _price(cost, device, list_entries, measured_entries):
|
|
677
|
+
"""One cost list priced for `device` and `list_entries`, as
|
|
678
|
+
{minutes, basis, measured_on}.
|
|
679
|
+
|
|
680
|
+
`measured_entries` is the list length the figure was measured with -
|
|
681
|
+
the workflow's stored defaults. When the caller's list differs and the
|
|
682
|
+
entry carries no measured `per_entry` rate, the total is extrapolated
|
|
683
|
+
linearly over it and reported as 'derived'; when more than one list
|
|
684
|
+
changed there is nothing honest to extrapolate along, so the figure is
|
|
685
|
+
withheld rather than quoted for the wrong list.
|
|
686
|
+
"""
|
|
687
|
+
entries = [entry for entry in (cost or []) if isinstance(entry, dict)]
|
|
688
|
+
if not entries:
|
|
689
|
+
return {"minutes": None, "basis": UNKNOWN, "measured_on": None}
|
|
690
|
+
chosen = next((entry for entry in entries if entry.get("device") == device), None)
|
|
691
|
+
basis = CATALOG
|
|
692
|
+
if chosen is None:
|
|
693
|
+
chosen = entries[0]
|
|
694
|
+
basis = OTHER_DEVICE
|
|
695
|
+
minutes = float(chosen.get("minutes", 0))
|
|
696
|
+
per = chosen.get("per_entry")
|
|
697
|
+
if (
|
|
698
|
+
basis == CATALOG
|
|
699
|
+
and isinstance(per, dict)
|
|
700
|
+
and per.get("variable") in list_entries
|
|
701
|
+
):
|
|
702
|
+
count = list_entries[per["variable"]]
|
|
703
|
+
each = float(per.get("minutes", 0))
|
|
704
|
+
measured_with = int(per.get("entries", 0))
|
|
705
|
+
minutes = max(0.0, (minutes - each * measured_with) + each * count)
|
|
706
|
+
basis = PER_ENTRY
|
|
707
|
+
elif basis == CATALOG:
|
|
708
|
+
minutes, basis = _repriced(minutes, list_entries, measured_entries)
|
|
709
|
+
return {"minutes": minutes, "basis": basis, "measured_on": chosen.get("name")}
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def _repriced(minutes, list_entries, measured_entries):
|
|
713
|
+
"""A catalog total re-priced for a list the caller lengthened or
|
|
714
|
+
shortened, as (minutes, basis).
|
|
715
|
+
|
|
716
|
+
Linear in the entry count: the figure was measured over
|
|
717
|
+
`measured_entries` entries and the run does `list_entries` of them, so
|
|
718
|
+
a 42-minute 5-shot figure quotes 84 for 10 shots rather than 42. It
|
|
719
|
+
over-counts fixed setup at the long end and under-counts it at the
|
|
720
|
+
short end - which is why it is labelled `derived` rather than
|
|
721
|
+
`catalog`, and why a template that measures a real `per_entry` rate
|
|
722
|
+
beats it.
|
|
723
|
+
"""
|
|
724
|
+
changed = [
|
|
725
|
+
(count, measured_entries[name])
|
|
726
|
+
for name, count in list_entries.items()
|
|
727
|
+
if name in measured_entries and count != measured_entries[name]
|
|
728
|
+
]
|
|
729
|
+
if not changed:
|
|
730
|
+
return minutes, CATALOG
|
|
731
|
+
if len(changed) > 1:
|
|
732
|
+
return None, UNKNOWN
|
|
733
|
+
count, measured_with = changed[0]
|
|
734
|
+
if measured_with <= 0:
|
|
735
|
+
return None, UNKNOWN
|
|
736
|
+
return minutes * count / measured_with, DERIVED
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
FROM_PRETRAINED_KEY = "from_pretrained_arguments"
|
|
740
|
+
MODEL_NAME_KEY = "model_name"
|
|
741
|
+
# An adapter names its repo directly, not through from_pretrained_arguments
|
|
742
|
+
LORAS_KEY = "loras"
|
|
743
|
+
SINGLE_FILE_KEY = "from_single_file"
|
|
744
|
+
VARIANT_KEY = "variant"
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def downloads_required(expanded, base_dir, workflow_dir, cache_dir, lookup_sizes):
|
|
748
|
+
"""The hub repos and checkpoint URLs the run would fetch before its
|
|
749
|
+
first step: every `model_name` in the expanded definition (and in each
|
|
750
|
+
composed child) that `scan_models` does not find, plus every one it does
|
|
751
|
+
find but whose cached copy is left over from an interrupted pull
|
|
752
|
+
(#382) - a component the load needs is missing, or a blob is still
|
|
753
|
+
`.incomplete` - plus every `from_single_file` that is a URL. Sizes come
|
|
754
|
+
from the hub when asked and are None whenever it does not answer - an
|
|
755
|
+
offline box is a state, not an error, so nothing here raises or logs
|
|
756
|
+
above debug.
|
|
757
|
+
"""
|
|
758
|
+
names = []
|
|
759
|
+
variants = {}
|
|
760
|
+
urls = []
|
|
761
|
+
_collect_sources(expanded, names, variants, urls)
|
|
762
|
+
for path, _arguments in _sub_workflow_paths(expanded):
|
|
763
|
+
raw = read_sub_workflow(path, base_dir, workflow_dir)
|
|
764
|
+
if raw is None:
|
|
765
|
+
continue
|
|
766
|
+
try:
|
|
767
|
+
_collect_sources(json.loads(raw), names, variants, urls)
|
|
768
|
+
except ValueError:
|
|
769
|
+
continue
|
|
770
|
+
present = {repo.get("repo_id") for repo in scan_models(cache_dir).get("repos", [])}
|
|
771
|
+
required = []
|
|
772
|
+
for name in names:
|
|
773
|
+
# A name not shaped like a hub id is a local checkout - decided by
|
|
774
|
+
# shape, never by touching the disk: the name came from the request
|
|
775
|
+
# body, and a free pre-flight must not be a directory-existence oracle
|
|
776
|
+
if not _is_repo_id(name):
|
|
777
|
+
continue
|
|
778
|
+
if name in present and not repo_download_incomplete(
|
|
779
|
+
name, cache_dir, variant=variants.get(name)
|
|
780
|
+
):
|
|
781
|
+
continue
|
|
782
|
+
entry = {"repo": name, "gb": None, "gated": None, "access_blocked": None}
|
|
783
|
+
if lookup_sizes:
|
|
784
|
+
entry["gb"], entry["gated"], entry["access_blocked"] = _model_info(name)
|
|
785
|
+
required.append(entry)
|
|
786
|
+
for url in urls:
|
|
787
|
+
required.append(
|
|
788
|
+
{
|
|
789
|
+
"repo": None,
|
|
790
|
+
"url": url,
|
|
791
|
+
"gb": None,
|
|
792
|
+
"gated": None,
|
|
793
|
+
"access_blocked": None,
|
|
794
|
+
}
|
|
795
|
+
)
|
|
796
|
+
return required
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def _collect_sources(tree, names, variants, urls):
|
|
800
|
+
"""Every from_pretrained source in a tree, first-seen order, deduplicated.
|
|
801
|
+
`variants` collects each name's requested `variant` (first-seen), used to
|
|
802
|
+
tell a repo that is merely missing its fp16 files from one that is fully
|
|
803
|
+
cached without them.
|
|
804
|
+
|
|
805
|
+
A `loras` entry counts too. It carries its repo under `model_name`
|
|
806
|
+
directly rather than inside a `from_pretrained_arguments` block, so the
|
|
807
|
+
walk missed it: `templates/ltx2/reference-sheet` on a box that had
|
|
808
|
+
every base weight but not the IC-LoRA answered `downloads_required:
|
|
809
|
+
[]` and then pulled it mid-run, which is the one question the field
|
|
810
|
+
exists to answer (found verifying #151).
|
|
811
|
+
"""
|
|
812
|
+
if isinstance(tree, dict):
|
|
813
|
+
for lora in tree.get(LORAS_KEY) or []:
|
|
814
|
+
name = lora.get(MODEL_NAME_KEY) if isinstance(lora, dict) else None
|
|
815
|
+
if isinstance(name, str) and name not in names:
|
|
816
|
+
names.append(name)
|
|
817
|
+
source = tree.get(FROM_PRETRAINED_KEY)
|
|
818
|
+
if isinstance(source, dict):
|
|
819
|
+
name = source.get(MODEL_NAME_KEY)
|
|
820
|
+
if isinstance(name, str) and name not in names:
|
|
821
|
+
names.append(name)
|
|
822
|
+
variant = source.get(VARIANT_KEY)
|
|
823
|
+
if isinstance(variant, str):
|
|
824
|
+
variants[name] = variant
|
|
825
|
+
single = source.get(SINGLE_FILE_KEY)
|
|
826
|
+
if isinstance(single, str) and _is_url(single) and single not in urls:
|
|
827
|
+
urls.append(single)
|
|
828
|
+
for value in tree.values():
|
|
829
|
+
_collect_sources(value, names, variants, urls)
|
|
830
|
+
elif isinstance(tree, list):
|
|
831
|
+
for value in tree:
|
|
832
|
+
_collect_sources(value, names, variants, urls)
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
def gate_warnings(downloads_required):
|
|
836
|
+
"""One line per required download this box's token is blocked from -
|
|
837
|
+
the pre-flight signal #186 asked for, so a gated repo is a
|
|
838
|
+
`validate_workflow`-visible condition rather than a 403 the run only
|
|
839
|
+
discovers after it has loaded everything ahead of that step."""
|
|
840
|
+
return [
|
|
841
|
+
f"{entry['repo']} is gated and not accessible with this box's "
|
|
842
|
+
"Hugging Face token - accept its license at "
|
|
843
|
+
f"https://huggingface.co/{entry['repo']} before running this workflow"
|
|
844
|
+
for entry in downloads_required
|
|
845
|
+
if entry.get("access_blocked")
|
|
846
|
+
]
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def _is_repo_id(name):
|
|
850
|
+
try:
|
|
851
|
+
validate_repo_id(name)
|
|
852
|
+
return True
|
|
853
|
+
except HFValidationError:
|
|
854
|
+
return False
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def _is_url(value):
|
|
858
|
+
if not value.startswith(("http://", "https://")):
|
|
859
|
+
return False
|
|
860
|
+
try:
|
|
861
|
+
validate_url(value)
|
|
862
|
+
return True
|
|
863
|
+
except Exception:
|
|
864
|
+
return False
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
def _model_info(name):
|
|
868
|
+
"""A repo's size in GiB, gate status, and whether this box's token is
|
|
869
|
+
blocked from it - a `(gb, gated, access_blocked)` triple.
|
|
870
|
+
|
|
871
|
+
`gated` is `model_info`'s own field (`False` / `"auto"` / `"manual"`),
|
|
872
|
+
readable only when the call succeeds - and the hub answers `model_info`
|
|
873
|
+
for a gated repo regardless of whether this token has been granted
|
|
874
|
+
access, since that call serves metadata rather than file bytes (found
|
|
875
|
+
verifying #186: `access_blocked` came back `false` for repos this box's
|
|
876
|
+
token was actually refused on). A `GatedRepoError` straight out of
|
|
877
|
+
`model_info` is still a real signal - some other endpoint behind it
|
|
878
|
+
checked and refused - but its absence proves nothing, so a `gated` repo
|
|
879
|
+
that got this far is checked for real with a HEAD request against one of
|
|
880
|
+
its own files (`_probe_gate_blocked`), the same request class a run's
|
|
881
|
+
actual load would make and the one place the hub's 403 for "gated, token
|
|
882
|
+
not accepted" actually appears.
|
|
883
|
+
"""
|
|
884
|
+
try:
|
|
885
|
+
info = model_info(name, files_metadata=True, timeout=SIZE_LOOKUP_TIMEOUT)
|
|
886
|
+
except GatedRepoError as e:
|
|
887
|
+
logger.debug(f"Gate not accepted for {name}: {e}")
|
|
888
|
+
return None, True, True
|
|
889
|
+
except Exception as e:
|
|
890
|
+
logger.debug(f"No size for {name}: {e}")
|
|
891
|
+
return None, None, None
|
|
892
|
+
total = sum(s.size for s in (info.siblings or []) if getattr(s, "size", None))
|
|
893
|
+
gb = round(total / GIB, 1) if total else None
|
|
894
|
+
gated = getattr(info, "gated", False) or False
|
|
895
|
+
access_blocked = False
|
|
896
|
+
if gated:
|
|
897
|
+
access_blocked = _probe_gate_blocked(name, info.siblings or [])
|
|
898
|
+
return gb, gated, access_blocked
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
def _probe_gate_blocked(name, siblings):
|
|
902
|
+
"""HEAD one real file of a gated repo to see whether this box's token is
|
|
903
|
+
actually accepted - `model_info` succeeding says nothing either way
|
|
904
|
+
(#186). `None` when there is no file to probe or the probe fails for a
|
|
905
|
+
reason other than the gate, since that is "unknown", not "not blocked".
|
|
906
|
+
"""
|
|
907
|
+
filename = next(
|
|
908
|
+
(s.rfilename for s in siblings if getattr(s, "rfilename", None)), None
|
|
909
|
+
)
|
|
910
|
+
if filename is None:
|
|
911
|
+
return None
|
|
912
|
+
try:
|
|
913
|
+
get_hf_file_metadata(hf_hub_url(name, filename), timeout=SIZE_LOOKUP_TIMEOUT)
|
|
914
|
+
return False
|
|
915
|
+
except GatedRepoError as e:
|
|
916
|
+
logger.debug(f"Gate not accepted for {name}: {e}")
|
|
917
|
+
return True
|
|
918
|
+
except Exception as e:
|
|
919
|
+
logger.debug(f"Gate probe inconclusive for {name}: {e}")
|
|
920
|
+
return None
|