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:
plan_X(...) -> CallPlan: pure data describing the call that would happen — model_id, arguments, predicted cost, cache status. No API contact.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
Whether the cache will short-circuit this call. |
|
Coarse class of what this call produces. |
|
The |
|
Whether |
|
Env var overriding |
|
How many calls of a Plan run at once by default. |
|
Prefix marking a content hash where a cache key would otherwise hold a URL. |
Functions
Rebuild a |
|
|
Convert a |
Rebuild a |
|
|
Convert a |
|
Execute a Plan, returning a list of materialized :class:`lacing.Artifact`s. |
|
Execute a Plan with per-call failure isolation, returning a report. |
|
Find the first asset URL in a fal response, regardless of shape. |
|
Build a |
|
Per-call set of the call indices it references via |
Rebuild a |
|
|
Stable, plan-scoped structural idempotency key for a whole |
|
Convert a |
Classes
|
A single planned fal call. |
|
How a |
|
An ordered sequence of |
- 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. aforce=Truere-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:
objectA single planned fal call. Pure data — no API contact yet.
applicationandargumentsare the exact tuplecached_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 onbackend.
- 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) — seefalaw.backends. Defaults tofalaw.canonical.DFLT_BACKEND("fal"), the only backend until a second one lands. Enters the per-call cache key andplan_hashonly when it is not the default, so every call falaw has ever planned or cached keeps its exact pre-#15 digest — seefalaw.canonical.
- property billable_cost_usd: float#
Cost that will actually be billed (0 on cache hit, estimate otherwise).
Returns
0.0(notNone) on cache hit or unknown estimate so sums are well-defined; useestimated_cost_usdis Noneto check unknown status explicitly.
- cache_status: Literal['hit', 'miss', 'stale', 'unknown']#
Whether the cache will short-circuit this call.
"hit"meansexecutewon’t bill, soPlan.total_cost_usdandPlan.cache_hit_savings_usdreflect that.
- cost_basis: CostBasis | None#
How
estimated_cost_usdwas computed, orNonewhen nothing recorded it (falaw#60). Present, the quote can be re-run against today’s rate table byfalaw.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 ofplan_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.
Nonewhen the model has nocost_estimatepopulated (callers can distinguish “free” from “unknown”).
- expected_duration_s: tuple[float, float] | None#
(min, max)duration the model can produce, orNoneif no duration contract is known. Plan-level validators can check that the requested duration fits this range and raiseFalDurationOutOfRangebefore 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 ANDplan_hash, under omit-if-empty (an empty dict leaves every existing key byte-identical). Unlikemetadata, 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 Transformimpl_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.
- class falaw.plan.CostBasis(*, pricer, priced, quantities=<factory>, table='', table_version='')[source]#
Bases:
objectHow a
CallPlan.estimated_cost_usdwas 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
applicationandargumentsalone: 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.idfor the catalogue pricer, the routedmodelargument for the LLM pricer. Distinct fromCallPlan.application, which forfal-ai/any-llmnames 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 ofmodels.json,"llm_rates"for a routed LLM priced out ofllm_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 wasNoneat plan time is left out rather than written asnull.
- 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_idis not the SHA-256 of its bytes breakslacing.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_BYTESprocess-wide.Not a production setting — see the
fetch_bytesargument ofexecute()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_idbecomes 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 aconftest.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.kindso 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
schematagplan_to_dict()writes andplan_from_dict()expects. Bumped only on a breaking change to the dict shape.
- class falaw.plan.Plan(calls=())[source]#
Bases:
objectAn ordered sequence of
CallPlan— a render plan, in essence.Plans compose:
a + breturns a new Plan witha.callsfollowed byb.calls.Plan(calls=())is the identity. Plans are frozen, so edits return new Plans (usewith_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_usdcoerces unpriced calls to$0.00so 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 withunknown_call_count: the true cost isknown_cost_usdplus 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_usdacross all calls.
- property unknown_call_count: int#
How many billable calls carry no price at all.
The countable form of
has_unknown_costs— seeknown_cost_usdfor the budget-gate arithmetic it enables.
- falaw.plan.call_plan_from_dict(d)[source]#
Rebuild a
CallPlanfrom acall_plan_to_dict()dict.arguments/metadataare copied (a deserialized plan owns its own data);expected_duration_sis re-tupled.backenddefaults toDFLT_BACKENDwhen absent — a dict written before falaw#15 names no backend, and it always meant"fal".cost_basisis absent on any plan written before falaw#60 and on any call nothing stamped; it comes back asNone, whichfalaw.reprice_plan()reports as"no_basis"— never as a quote that is still current.- Return type:
- falaw.plan.call_plan_to_dict(call)[source]#
Convert a
CallPlanto a plain JSON-serializable dict.The inverse of
call_plan_from_dict().expected_duration_s(atuple) 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 aPLAN_DICT_SCHEMAbump: it is always written, butcall_plan_from_dict()defaults it toDFLT_BACKENDwhen 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:
- falaw.plan.cost_basis_from_dict(d)[source]#
Rebuild a
CostBasisfrom acost_basis_to_dict()dict.- Return type:
- falaw.plan.cost_basis_to_dict(basis)[source]#
Convert a
CostBasisto a plain JSON-serializable dict.table/table_versionare 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:
- 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). Useexecute_isolated()when one bad call must not discard the rest of a fan-out; it returns anExecutionReportwith 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_idandurl=None. Useful for exercising downstream composition without an API key.use_cache (bool) – When True (default), executes via
cached_call_falso 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, leaveuse_cache=Trueand passrefresh.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 aforce/ “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. WhenNone(default), a built-in converter handles the common shapes ({images: [{url}]},{video: {url}},{audio: {url}}). Mutually exclusive withcontent_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 owncost_usd: the executor stamps it from the observed run outcome after conversion, overwriting whatever the converter set (falaw#26).content_store – Injected
lacing.ArtifactStorethat media bytes are materialized into. Defaults tofalaw.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_idis the SHA-256 of its bytes. Defaults toDFLT_FETCH_BYTES(true), overridable process-wide viaFETCH_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 meansasset_idis not a content hash, in violation oflacing.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 tofalaw.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 onfalaw.content.using_url_fetcher()— because a suite reaching falaw through its own public API has noexecutecall site to pass this to. Passing it here still wins over any installed default. ($FALAW_FETCH_ARTIFACT_BYTES=0also silences the network, but by turning content addressing off — seeFETCH_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 callN. 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), soconcurrencymultiplies the peak.
- Return type:
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:
urlis kept,bytes_sizestays 0,asset_idis a digest of the response and is not claimed to be a content hash, and aUserWarningis emitted. Downstream key resolution readsbytes_size == 0and 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 integerN) 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.ArtifactperCallPlaninplan.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(). Whereexecuteraises on the first failure — discarding every artifact produced before it, each of which fal has already billed — this returns anExecutionReportcarrying oneCallOutcomeper 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 againstreport.outcomesand stay aligned.- Parameters:
halt_on_failure (bool) – When True, stop submitting work as soon as any call fails; everything not yet started is reported
blockedwith a run-level reason. This is whatexecute()uses, and atconcurrency=1it reproduces the historical sequential behaviour exactly. Note that atconcurrency > 1calls 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#
failedandblockedare 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 callNdid 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.pruneas well: deciding whether pruning a blob makes a cache entry unmaterializable is the same question this answers forexecute, and the two must not drift into two opinions about what a response’s asset is.
- 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
CallPlanand (optionally) check the cache.When
consult_cache=True(the default), the cache is peeked using the same key the eventual call would produce;cache_statusis set to"hit"if a cached entry exists,"miss"otherwise. This makesPlan.total_cost_usdhonest: a fully-cached Plan reports $0.When
consult_cache=False(e.g. for unit tests or “what would a fresh run cost?” reporting),cache_statusis"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’scache_hit_savings_usdon every chained call, which under prepaid billing (a quote that may be deducted) is a billing bug.cache_statusis"unknown"here instead, exactly the caseCacheStatusdocuments it for.cost_basisrecords howestimated_cost_usdwas arrived at so the quote can be re-run later (falaw#60). It is descriptive only: it never reaches the cache key orplan_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:
- 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
frozensetper call, in plan order, sodeps[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 whatexecute_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 integerN; anNoutside the plan; or anNthat 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:
>>> 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. ...
- falaw.plan.plan_from_dict(d)[source]#
Rebuild a
Planfrom aplan_to_dict()dict.Raises
ValueErrorifdcarries an unrecognizedschematag — a plan written by an incompatible future version should fail loudly, not silently lose calls. A missingschemais tolerated (treated as v1) so hand-written plans stay easy.- Return type:
- 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 notool).plan_hashand 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 infalaw.canonicalwith one shared byte-form (falaw.canonical.canonical_blob()— sorted keys, nodefault=strfallback, no NaN), so an argument the form cannot represent faithfully raisesfalaw.errors.FalNonCanonicalArgumentinstead 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, ortool— or the order of calls — changes the hash.- Return type:
>>> 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
Planto 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 aschematag (PLAN_DICT_SCHEMA) so a future breaking change is detectable.- Return type: