falaw.plan#

Plan / Execute primitives — separate planning (data) from execution (effects).

Today every falaw operation is eager: generate_image(prompt) makes the API call immediately. That means:

  • A budget gate has to predict cost from outside the call and hope the prediction matches what the operation will actually do.

  • Tests need a fake fal_client to exercise any code that touches an operation, even when the test is about composition or cost.

  • A UI can’t show “you’re about to spend $4.12, click confirm” without a separate, parallel cost-prediction code path.

The fix is to give every operation two surfaces:

  1. plan_X(...) -> CallPlan: pure data describing the call that would happen — model_id, arguments, predicted cost, cache status. No API contact.

  2. execute(plan, ...) -> list[Artifact]: turns a Plan (one or more CallPlans) into materialized Artifacts. The eager wrappers (generate_image, etc.) are now thin: execute(plan_X(...))[0].

A higher-level orchestrator (a music-video render, a storyboard generation) builds a Plan by composing CallPlans across multiple operations. The Plan gets a typed total_cost_usd, can be inspected, edited, dry-run, or serialized — all without the network.

Examples

>>> from falaw.plan import CallPlan, Plan
>>> p1 = CallPlan(
...     tool="generate_image",
...     application="fal-ai/flux/dev",
...     arguments={"prompt": "a tiger", "image_size": "landscape_4_3"},
...     output_kind="image",
...     estimated_cost_usd=0.025,
...     cache_status="miss",
... )
>>> p2 = CallPlan(
...     tool="image_to_video",
...     application="fal-ai/minimax/hailuo-02/pro/image-to-video",
...     arguments={"image_url": "<from p1>"},
...     output_kind="video",
...     estimated_cost_usd=0.50,
...     cache_status="miss",
... )
>>> plan = Plan(calls=(p1, p2))
>>> plan.total_cost_usd
0.525
>>> plan.cache_hit_savings_usd
0.0
>>> [c.tool for c in plan.calls]
['generate_image', 'image_to_video']

Plans concatenate, so an orchestrator can build one shot’s Plan and then append it to a scene-level Plan:

>>> shot_plan = Plan(calls=(p1,))
>>> scene_plan = Plan(calls=()) + shot_plan + Plan(calls=(p2,))
>>> len(scene_plan.calls)
2

Module Attributes

CacheStatus

Whether the cache will short-circuit this call.

OutputKind

Coarse class of what this call produces.

PLAN_DICT_SCHEMA

The schema tag plan_to_dict() writes and plan_from_dict() expects.

DFLT_FETCH_BYTES

Whether execute() downloads media results to content-address them.

FETCH_BYTES_ENVVAR

Env var overriding DFLT_FETCH_BYTES process-wide.

DFLT_CONCURRENCY

How many calls of a Plan run at once by default.

CONTENT_REF_PREFIX

Prefix marking a content hash where a cache key would otherwise hold a URL.

Functions

call_plan_from_dict(d)

Rebuild a CallPlan from a call_plan_to_dict() dict.

call_plan_to_dict(call)

Convert a CallPlan to a plain JSON-serializable dict.

cost_basis_from_dict(d)

Rebuild a CostBasis from a cost_basis_to_dict() dict.

cost_basis_to_dict(basis)

Convert a CostBasis to a plain JSON-serializable dict.

execute(plan, *[, on_event, dry_run, ...])

Execute a Plan, returning a list of materialized :class:`lacing.Artifact`s.

execute_isolated(plan, *[, on_event, ...])

Execute a Plan with per-call failure isolation, returning a report.

extract_first_url(raw)

Find the first asset URL in a fal response, regardless of shape.

make_call_plan(*, tool, application, ...[, ...])

Build a CallPlan and (optionally) check the cache.

plan_dependencies(plan)

Per-call set of the call indices it references via "<from N>".

plan_from_dict(d)

Rebuild a Plan from a plan_to_dict() dict.

plan_hash(plan)

Stable, plan-scoped structural idempotency key for a whole Plan.

plan_to_dict(plan)

Convert a Plan to a plain JSON-serializable dict.

Classes

CallPlan(*, tool, application[, backend, ...])

A single planned fal call.

CostBasis(*, pricer, priced[, quantities, ...])

How a CallPlan.estimated_cost_usd was arrived at (falaw#60).

Plan([calls])

An ordered sequence of CallPlan — a render plan, in essence.

falaw.plan.CONTENT_REF_PREFIX = 'sha256:'#

Prefix marking a content hash where a cache key would otherwise hold a URL.

Self-describing on purpose: a reader of a cache manifest can tell at a glance that an argument was keyed on what the upstream produced rather than *where it was served from*, and the prefixed form can never be confused with a literal URL argument.

falaw.plan.CacheStatus#

Whether the cache will short-circuit this call.

  • hit: A cached response exists and will be returned without an API call.

  • miss: No cache entry; the call will hit fal.

  • stale: An entry exists but is expected to be invalidated (e.g. a force=True re-render asked for it). Today we don’t distinguish stale from miss for cost — both are billed.

  • unknown: Plan was built without consulting the cache.

alias of Literal[‘hit’, ‘miss’, ‘stale’, ‘unknown’]

class falaw.plan.CallPlan(*, tool, application, backend='fal', arguments, output_kind, estimated_cost_usd=None, cache_status='unknown', expected_duration_s=None, metadata=<factory>, key_extra=<factory>, cost_basis=None)[source]#

Bases: object

A single planned fal call. Pure data — no API contact yet.

application and arguments are the exact tuple cached_call_fal(application, arguments) would take, so a Plan can be cache-checked, executed, or replayed without ambiguity.

application: str#

The fal model id that will be invoked (e.g. "fal-ai/flux/dev"). Backend-scoped: what it names depends on backend.

arguments: dict#

Keyword arguments to pass to fal. Will be JSON-canonicalized for cache key computation; should be JSON-serializable.

backend: str#

Which execution backend execute_plan() dispatches this call to (falaw#15) — see falaw.backends. Defaults to falaw.canonical.DFLT_BACKEND ("fal"), the only backend until a second one lands. Enters the per-call cache key and plan_hash only when it is not the default, so every call falaw has ever planned or cached keeps its exact pre-#15 digest — see falaw.canonical.

property billable_cost_usd: float#

Cost that will actually be billed (0 on cache hit, estimate otherwise).

Returns 0.0 (not None) on cache hit or unknown estimate so sums are well-defined; use estimated_cost_usd is None to check unknown status explicitly.

cache_status: Literal['hit', 'miss', 'stale', 'unknown']#

Whether the cache will short-circuit this call. "hit" means execute won’t bill, so Plan.total_cost_usd and Plan.cache_hit_savings_usd reflect that.

cost_basis: CostBasis | None#

How estimated_cost_usd was computed, or None when nothing recorded it (falaw#60). Present, the quote can be re-run against today’s rate table by falaw.reprice_plan(); absent, that function reports the call as "no_basis" and clears its cost to unknown rather than passing a stale figure off as current. Purely descriptive — it stays out of plan_hash() and the per-call cache key, and out of the serialized dict entirely when unset, so every plan falaw has ever hashed or persisted is unmoved.

estimated_cost_usd: float | None#

Predicted cost in USD. None when the model has no cost_estimate populated (callers can distinguish “free” from “unknown”).

expected_duration_s: tuple[float, float] | None#

(min, max) duration the model can produce, or None if no duration contract is known. Plan-level validators can check that the requested duration fits this range and raise FalDurationOutOfRange before the call instead of letting it silently truncate.

key_extra: dict#

Identity beyond the wire arguments — entries that change what the call produces without appearing in arguments. Participates in the per-call cache key AND plan_hash, under omit-if-empty (an empty dict leaves every existing key byte-identical). Unlike metadata, which is deliberately identity-free labelling, putting something here says “a cached result minted without this value must not be reused”. First customer: nw’s Transform impl_version (nw#27) — “same interface, changed behaviour” must miss the cache without renaming anything.

metadata: dict#

Free-form labels for downstream consumers. Conventional keys: shot_id, beat_id, character_name, strategy.

output_kind: Literal['image', 'video', 'audio', 'json', 'text', 'binary']#

What kind of Artifact this call will produce.

tool: str#

High-level tool name — "generate_image", "image_to_video", etc. Distinct from application because one tool may dispatch to several fal models depending on quality tier.

class falaw.plan.CostBasis(*, pricer, priced, quantities=<factory>, table='', table_version='')[source]#

Bases: object

How a CallPlan.estimated_cost_usd was arrived at (falaw#60).

A frozen quote goes stale the moment the rate table moves — 0.0.46 moved falaw’s LLM prices tenfold — and a persisted plan cannot be re-quoted from application and arguments alone: the quantity hints that priced it (the clip’s duration, the prompt’s token bound) are estimator-only and never enter the wire arguments. This records them, plus which table priced them, so a saved plan can be re-quoted faithfully (falaw.reprice_plan()) and audited after the fact.

Pure data. Nothing here reaches the network, and nothing here enters plan_hash() or the per-call cache key: a basis says what a call cost, never what it produces, so stamping one leaves every existing digest byte-identical.

priced: str#

a falaw.ModelRecord.id for the catalogue pricer, the routed model argument for the LLM pricer. Distinct from CallPlan.application, which for fal-ai/any-llm names the router rather than the model that sets the price.

Type:

The entity that was priced

pricer: str#

Which pricing rule produced the figure — a key into falaw.reprice.DFLT_PRICERS. "model_catalogue" for a record priced out of models.json, "llm_rates" for a routed LLM priced out of llm_rates.json. An unrecognized name re-prices as unknown, never as the old number.

quantities: dict#

The estimator-only shape hints fed to the pricer, by keyword — {"seconds": 6.0}, {"input_tokens": 20000, "max_output_tokens": 4000}. Absent keys mean the hint was not supplied, which is what made a quantity-priced call unpriceable in the first place. Omit-when-unset: a hint that was None at plan time is left out rather than written as null.

table: str#

Identity of the rate table consulted, e.g. "falaw/data/llm_rates.json". Free-form so a caller’s own reconciled table can name itself.

table_version: str#

The version of that table at plan time — falaw stamps a short content digest, so a table whose numbers moved gets a different value even when its declared version did not. Empty means unversioned/unknown.

falaw.plan.DFLT_CONCURRENCY = 1#

How many calls of a Plan run at once by default.

One — a Plan executes sequentially unless the caller asks otherwise. Every call is a paid vendor request, so parallelism is opt-in: it is the caller who knows their rate limit, their budget, and whether they want 200 renders started at once. (falaw.render_scene() makes the same choice for the same reason.)

falaw.plan.DFLT_FETCH_BYTES = True#

Whether execute() downloads media results to content-address them.

True — an artifact whose asset_id is not the SHA-256 of its bytes breaks lacing.Artifact’s contract and puts an expiring location into every downstream cache key.

falaw.plan.FETCH_BYTES_ENVVAR = 'FALAW_FETCH_ARTIFACT_BYTES'#

Env var overriding DFLT_FETCH_BYTES process-wide.

Not a production setting — see the fetch_bytes argument of execute() for what opting out costs. Read at call time, so it can be set after import.

Do not reach for this to make a test suite offline. It works, and that is the trap: it silences the network by turning content addressing off, so the suite becomes hermetic and simultaneously stops exercising the feature. Every asset_id becomes a digest of the response rather than the SHA-256 of the bytes, chained calls key on URLs, and no test then covers the path production actually takes. Install a fake transport instead — falaw.testing.fake_assets() is one line in a conftest.py — and the suite stays offline with content addressing under test, on bytes it controls. Both consumers that hit this chose the fake for exactly that reason (thorwhalen/falaw#27).

falaw.plan.OutputKind#

Coarse class of what this call produces. Mirrors lacing.Artifact.kind so the producer knows what shape of Artifact to materialize.

alias of Literal[‘image’, ‘video’, ‘audio’, ‘json’, ‘text’, ‘binary’]

falaw.plan.PLAN_DICT_SCHEMA = 'falaw.plan/v1'#

The schema tag plan_to_dict() writes and plan_from_dict() expects. Bumped only on a breaking change to the dict shape.

class falaw.plan.Plan(calls=())[source]#

Bases: object

An ordered sequence of CallPlan — a render plan, in essence.

Plans compose: a + b returns a new Plan with a.calls followed by b.calls. Plan(calls=()) is the identity. Plans are frozen, so edits return new Plans (use with_call_replaced() for in-place-feel).

property cache_hit_savings_usd: float#

USD that would have been spent without the cache.

Equal to sum(c.estimated_cost_usd for c in calls if c.cache_status == "hit" and c.estimated_cost_usd is not None).

property has_unknown_costs: bool#

True if any non-cache-hit call has no cost estimate.

Use this to refuse to gate on a budget when the estimate is incomplete.

property known_cost_usd: float#

The priced part of total_cost_usd — same number today, but honest by construction.

total_cost_usd coerces unpriced calls to $0.00 so sums stay well-defined; a cumulative budget gate that reads it alone will under-quote (400 unpriceable video calls total $0.00). Read this together with unknown_call_count: the true cost is known_cost_usd plus an unknown amount spread over that many calls, and a correct gate refuses when the count is nonzero rather than pretending the unknown part is free (falaw#18).

property total_cost_usd: float#

Sum of CallPlan.billable_cost_usd across all calls.

property unknown_call_count: int#

How many billable calls carry no price at all.

The countable form of has_unknown_costs — see known_cost_usd for the budget-gate arithmetic it enables.

with_call_replaced(index, new_call)[source]#

Return a new Plan with calls[index] replaced.

Return type:

Plan

falaw.plan.call_plan_from_dict(d)[source]#

Rebuild a CallPlan from a call_plan_to_dict() dict.

arguments / metadata are copied (a deserialized plan owns its own data); expected_duration_s is re-tupled. backend defaults to DFLT_BACKEND when absent — a dict written before falaw#15 names no backend, and it always meant "fal".

cost_basis is absent on any plan written before falaw#60 and on any call nothing stamped; it comes back as None, which falaw.reprice_plan() reports as "no_basis" — never as a quote that is still current.

Return type:

CallPlan

falaw.plan.call_plan_to_dict(call)[source]#

Convert a CallPlan to a plain JSON-serializable dict.

The inverse of call_plan_from_dict(). expected_duration_s (a tuple) becomes a 2-element list since JSON has no tuple type; everything else is already JSON-native.

backend (falaw#15) is a tolerated-default addition, not a PLAN_DICT_SCHEMA bump: it is always written, but call_plan_from_dict() defaults it to DFLT_BACKEND when absent, so a dict from before this field existed still parses, and a dict written by this version still parses under older falaw (the extra key is simply never read there). No migration needed either direction.

cost_basis (falaw#60) is the same kind of addition under a stricter rule — omit-when-unset: the key is written only when a basis exists, so a plan built without one serializes to the exact bytes it did before the field existed, and every stored plan, fixture and cassette is unmoved.

Return type:

dict

falaw.plan.cost_basis_from_dict(d)[source]#

Rebuild a CostBasis from a cost_basis_to_dict() dict.

Return type:

CostBasis

falaw.plan.cost_basis_to_dict(basis)[source]#

Convert a CostBasis to a plain JSON-serializable dict.

table / table_version are omitted when empty, matching the omit-when-unset discipline the rest of the wire shape follows — a basis that names no table writes no key rather than a pair of "".

Return type:

dict

falaw.plan.execute(plan, *, on_event=None, dry_run=False, use_cache=True, refresh=False, artifact_converter=None, content_store=None, fetch_bytes=None, asset_fetcher=None, concurrency=1)[source]#

Execute a Plan, returning a list of materialized :class:`lacing.Artifact`s.

This is the halt policy: the first call that raises ends the run, and its exception propagates unchanged (falaw’s typed hierarchy — see falaw.errors — is what a caller classifies on, so it is never wrapped). Use execute_isolated() when one bad call must not discard the rest of a fan-out; it returns an ExecutionReport with one outcome per call instead of raising.

Parameters:
  • plan (Plan) – The Plan to execute.

  • on_event (Optional[Callable]) – Optional per-call event subscriber (passed to call_fal).

  • dry_run (bool) – When True, no fal calls are made; synthetic Artifacts are returned with placeholder asset_id and url=None. Useful for exercising downstream composition without an API key.

  • use_cache (bool) – When True (default), executes via cached_call_fal so cache hits skip the network. When False, the cache is not touched at all — neither read nor written. That is the historical meaning and it is unchanged: a caller who genuinely wants no cache interaction still has it. To re-run a plan and keep what the re-run paid for, leave use_cache=True and pass refresh.

  • refresh (bool) – When True, skip the cache read and keep the cache write — the third mode (falaw#49). Every call executes fresh, and every fresh result is stored under the key it would have been read from, so the next run (or a downstream consumer) hits it. Requires use_cache=True, since there is no key to write under when the cache is off; the contradictory combination raises rather than silently picking one. This is the mode to reach for behind a force / “re-verify this” switch: without it, forcing a re-run discards the result it just paid for and guarantees the next consumer re-bills.

  • artifact_converter (Optional[ResultToArtifact]) – Per-CallPlan converter from raw fal response to lacing.Artifact. When None (default), a built-in converter handles the common shapes ({images: [{url}]}, {video: {url}}, {audio: {url}}). Mutually exclusive with content_store / fetch_bytes / asset_fetcher, which configure the built-in converter only — passing both raises, rather than silently ignoring the ones a custom converter cannot honour. Converters do not own cost_usd: the executor stamps it from the observed run outcome after conversion, overwriting whatever the converter set (falaw#26).

  • content_store – Injected lacing.ArtifactStore that media bytes are materialized into. Defaults to falaw.content.default_content_store() (a directory store rooted in the falaw cache). Point this at an S3-backed store to share content — and therefore cache hits — across machines.

  • fetch_bytes (Optional[bool]) – Whether to download each media result so its asset_id is the SHA-256 of its bytes. Defaults to DFLT_FETCH_BYTES (true), overridable process-wide via FETCH_BYTES_ENVVAR. Opting out forfeits caching for chained calls: without bytes there is no content hash, so downstream calls fall back to keying on the upstream URL — which fal mints fresh per upload, so the downstream entry can never be reused across runs or machines. It also means asset_id is not a content hash, in violation of lacing.Artifact’s contract. Use it only when you genuinely want URL-only artifacts and no reuse.

  • asset_fetcher – Injected byte source (url -> Iterable[bytes]) used to read media results; defaults to falaw.content.default_url_fetcher(). This is the per-call transport seam. A hermetic test suite usually wants the process-wide one instead — falaw.testing.fake_assets(), built on falaw.content.using_url_fetcher() — because a suite reaching falaw through its own public API has no execute call site to pass this to. Passing it here still wins over any installed default. ($FALAW_FETCH_ARTIFACT_BYTES=0 also silences the network, but by turning content addressing off — see FETCH_BYTES_ENVVAR.)

  • concurrency (int) – How many calls may be in flight at once. 1 (DFLT_CONCURRENCY) runs the Plan sequentially, on the calling thread, exactly as it always has. Above 1, independent calls run on a thread pool bounded by this number — a Plan is I/O-bound (an HTTP request that fal takes tens of seconds to answer), so threads are the right tool and the bound is what keeps a 200-call fan-out from becoming 200 simultaneous paid requests. **Chained calls are never parallelised with their producers**: a call holding a "<from N>" placeholder waits for call N. Two things to weigh before raising it: the vendor’s rate limit, and memory — materializing a media result peaks at roughly twice the asset’s size (thorwhalen/lacing#25), so concurrency multiplies the peak.

Return type:

list

Failure handling — a paid result is never discarded#

Two different things can go wrong when reading a result’s bytes, and they get two different answers:

  • A fresh call whose bytes cannot be fetched. fal has already run — and billed — the generation. Raising would throw away a result we paid for, typically over a transient network failure. So the artifact degrades: url is kept, bytes_size stays 0, asset_id is a digest of the response and is not claimed to be a content hash, and a UserWarning is emitted. Downstream key resolution reads

    bytes_size == 0 and falls back to the URL — a guaranteed cache miss, never a wrong hit.

  • A cache hit that cannot be materialized — fal deleted the URL and the bytes are not in the content store. The entry is unusable, so it is treated as a miss: it is invalidated and the call re-executed once. A cache must never become a trap whose only escape is re-billing the whole plan with use_cache=False.

Placeholder resolution — the wire/key split#

Any string argument equal to "<from N>" (for an integer N) is rewritten just before the call is made — so a multi-step plan (e.g. generate_image → image_to_video) can reference the upstream output without the planner needing to know its URL. The rewrite happens after the upstream call has executed; planning itself is unaffected.

It happens twice, into two different argument sets, because the same value cannot serve both jobs:

  • the wire arguments get artifacts[N].url — what fal needs in order to fetch the input;

  • the key arguments get sha256:<artifacts[N].asset_id> — the upstream’s content hash, so a byte-identical upstream regeneration produces a downstream cache hit instead of re-billing the expensive call. Keying on the URL instead is the defect this split exists to fix (falaw#14): fal mints a unique URL per upload, so a URL-keyed downstream entry is unreachable the moment the upstream genuinely re-runs.

An upstream artifact with no materialized bytes has no content hash, so its key ref falls back to the URL — a guaranteed miss, never a wrong hit.

rtype:

list

returns:

One lacing.Artifact per CallPlan in plan.calls, in the same order.

falaw.plan.execute_isolated(plan, *, on_event=None, dry_run=False, use_cache=True, refresh=False, artifact_converter=None, content_store=None, fetch_bytes=None, asset_fetcher=None, concurrency=1, halt_on_failure=False)[source]#

Execute a Plan with per-call failure isolation, returning a report.

The fan-out counterpart of execute(). Where execute raises on the first failure — discarding every artifact produced before it, each of which fal has already billed — this returns an ExecutionReport carrying one CallOutcome per call: the successes with their artifacts, the failures with their exceptions, and the calls that never ran with the reason why.

len(report.outcomes) == len(plan.calls) always, so a caller that built something per call can zip against report.outcomes and stay aligned.

Parameters:

halt_on_failure (bool) – When True, stop submitting work as soon as any call fails; everything not yet started is reported blocked with a run-level reason. This is what execute() uses, and at concurrency=1 it reproduces the historical sequential behaviour exactly. Note that at concurrency > 1 calls already

Return type:

ExecutionReport in flight are not cancelled — a fal request cannot be recalled once made, and pretending otherwise would discard results that were billed anyway.

:param All other arguments are as execute().:

Three outcome states, not two#

failed and blocked are different questions for the caller. A failed call can be retried verbatim. A blocked one cannot: its input does not exist, so it has to be re-planned after its producer succeeds. Any call holding a "<from N>" placeholder whose call N did not succeed is blocked, transitively.

Examples

>>> from falaw import CallPlan, Plan, execute_plan_isolated
>>> plan = Plan(calls=(CallPlan(tool="t", application="m",
...                             arguments={}, output_kind="image"),))
>>> report = execute_plan_isolated(plan, dry_run=True)
>>> report.is_complete, len(report.outcomes)
(True, 1)
falaw.plan.extract_first_url(raw)[source]#

Find the first asset URL in a fal response, regardless of shape.

Public (no underscore) because it is now used from falaw.prune as well: deciding whether pruning a blob makes a cache entry unmaterializable is the same question this answers for execute, and the two must not drift into two opinions about what a response’s asset is.

Return type:

Optional[str]

falaw.plan.make_call_plan(*, tool, application, arguments, output_kind, backend='fal', estimated_cost_usd=None, expected_duration_s=None, metadata=None, consult_cache=True, cost_basis=None)[source]#

Build a CallPlan and (optionally) check the cache.

When consult_cache=True (the default), the cache is peeked using the same key the eventual call would produce; cache_status is set to "hit" if a cached entry exists, "miss" otherwise. This makes Plan.total_cost_usd honest: a fully-cached Plan reports $0.

When consult_cache=False (e.g. for unit tests or “what would a fresh run cost?” reporting), cache_status is "unknown".

A chained call — arguments still holding a "<from N>" placeholder, because the upstream call has not executed yet — is never peeked (falaw#15, D2): its resolved key is not knowable at plan time, and the unresolved key is never written by anything (execute() always keys on the resolved form), so peeking it can only ever report a false "miss" — never a real "hit". That silently over-quotes cost and under-reports the plan’s cache_hit_savings_usd on every chained call, which under prepaid billing (a quote that may be deducted) is a billing bug. cache_status is "unknown" here instead, exactly the case CacheStatus documents it for.

cost_basis records how estimated_cost_usd was arrived at so the quote can be re-run later (falaw#60). It is descriptive only: it never reaches the cache key or plan_hash(), and a plan built without one is byte-identical to one built before the field existed.

Raises:

FalNonCanonicalArgument – an argument cannot be hashed faithfully (non-JSON object, non-finite float, non-string mapping key). Raised here — while planning is still free — rather than at key-composition time on the way to the network (falaw#17).

Return type:

CallPlan

falaw.plan.plan_dependencies(plan)[source]#

Per-call set of the call indices it references via "<from N>".

The Plan’s dependency DAG, read straight off the placeholders — one frozenset per call, in plan order, so deps[3] == {1} means call 3 consumes call 1’s output. An empty set means the call is independent and may run concurrently with any other independent call, which is what execute_isolated() schedules on.

Also the plan’s structural validator, and it runs **before a cent is spent**: a malformed reference used to surface only when execution reached the offending call, i.e. after every call before it had been billed.

Raises:

ValueError – a placeholder that is not "<from N>" for an integer N; an N outside the plan; or an N that does not run before the referencing call (including a self-reference) — the output would not exist yet, so it can only ever be a bug.

Return type:

tuple[frozenset[int], …]

>>> a = CallPlan(tool="t", application="m", arguments={}, output_kind="image")
>>> b = CallPlan(tool="t", application="m",
...              arguments={"image_url": "<from 0>"}, output_kind="video")
>>> plan_dependencies(Plan(calls=(a, b)))
(frozenset(), frozenset({0}))
>>> plan_dependencies(Plan(calls=(b,)))
Traceback (most recent call last):
    ...
ValueError: Placeholder '<from 0>' in call 0 references call 0, which does not run before it. ...
Return type:

tuple[frozenset[int], ...]

falaw.plan.plan_from_dict(d)[source]#

Rebuild a Plan from a plan_to_dict() dict.

Raises ValueError if d carries an unrecognized schema tag — a plan written by an incompatible future version should fail loudly, not silently lose calls. A missing schema is tolerated (treated as v1) so hand-written plans stay easy.

Return type:

Plan

falaw.plan.plan_hash(plan)[source]#

Stable, plan-scoped structural idempotency key for a whole Plan.

Answers “does this whole plan match one I already ran?” — the handle a job manager (its first customer, nw.jobs) uses to dedup double-submits and to replay a resumed render for free. It is computed before execution and with <from N> placeholders intact, so it is stable across re-plans of the same structural request.

The digest canonicalizes each call over {app, args, tool} (falaw.canonical.plan_identity_payload()) — matching _synthetic_artifact()’s canonicalization, and deliberately not the per-call content-addressed cache key (falaw.cache._key(), which keys on {app, args} with no tool). plan_hash and the per-call cache key therefore key on different bytes and must not be assumed to agree call-for-call. Both projections live side by side in falaw.canonical with one shared byte-form (falaw.canonical.canonical_blob() — sorted keys, no default=str fallback, no NaN), so an argument the form cannot represent faithfully raises falaw.errors.FalNonCanonicalArgument instead of colliding, and a new identity-bearing field is an explicit decision about both hashes.

Two structurally-identical plans hash equal; changing any call’s app, args, or tool — or the order of calls — changes the hash.

Return type:

str

>>> a = CallPlan(tool="generate_image", application="fal-ai/flux/dev",
...              arguments={"prompt": "a tiger"}, output_kind="image")
>>> b = CallPlan(tool="image_to_video", application="fal-ai/svd",
...              arguments={"image_url": "<from 0>"}, output_kind="video")
>>> plan_hash(Plan(calls=(a, b))) == plan_hash(Plan(calls=(a, b)))
True
>>> plan_hash(Plan(calls=(a, b))) == plan_hash(Plan(calls=(b, a)))
False

backend (falaw#15) joins the hashed payload too, so a plan built for one backend never dedups against the structurally-identical plan for another — and, since it is included only when non-default, every plan made of today’s (all-"fal") calls hashes exactly as it did before:

>>> comfy = CallPlan(tool="generate_image", application="fal-ai/flux/dev",
...                   arguments={"prompt": "a tiger"}, output_kind="image",
...                   backend="comfyui")
>>> plan_hash(Plan(calls=(a,))) == plan_hash(Plan(calls=(comfy,)))
False
falaw.plan.plan_to_dict(plan)[source]#

Convert a Plan to a plain JSON-serializable dict.

The result round-trips through plan_from_dict(). This is the substrate primitive a consumer (a persistence layer, an MCP transport, a plan-diff tool) builds on — falaw owns the wire shape of its own Plan so every consumer agrees on it. Carries a schema tag (PLAN_DICT_SCHEMA) so a future breaking change is detectable.

Return type:

dict