From 672da97bc6f215cc123703e3763123dfa0121af5 Mon Sep 17 00:00:00 2001 From: Jesse Archer Date: Thu, 30 Jul 2026 21:25:10 +0000 Subject: [PATCH 01/30] Add inference runtime API design proposal --- docs/inference_runtime_api_design.md | 640 +++++++++++++++++++++++++++ 1 file changed, 640 insertions(+) create mode 100644 docs/inference_runtime_api_design.md diff --git a/docs/inference_runtime_api_design.md b/docs/inference_runtime_api_design.md new file mode 100644 index 000000000..6a4eea9dc --- /dev/null +++ b/docs/inference_runtime_api_design.md @@ -0,0 +1,640 @@ + + +# FlashDreams Inference Runtime API Design Proposal + +Date: July 30, 2026 + +## Summary + +This proposal defines a standard inference runtime API for FlashDreams +integrations. The goal is to make world-model integrations easier to build, +benchmark, and run without forcing every model into the same input shape or +optimization stack. + +The proposed API separates the pieces that are currently mixed together in +integration-specific runner code: + +- `InferenceConfig`: how the model and inference stack should run; +- `UserInputs`: controls or events from an app, replay trace, or benchmark; +- `ModelInputs`: prompts, frames, videos, trajectories, maps, scene data, and + other values required by a specific model; +- input mapping: model/application-specific conversion from user-facing inputs + into model-facing inputs; +- runtime/session execution: model setup, warmup, per-rollout state, and + stepping; +- output targets: WebRTC, native display, MP4, benchmark artifacts, or headless + runs; +- metrics/profiling: timings, memory, traces, NVTX ranges, and benchmark + outputs. + +The API should standardize the envelope and lifecycle. It should not pretend +that all world models have the same inputs, that all models use the same +optimization stack, or that a raw checkpoint can fully describe how to run the +model. + +## Current Implementation Plan + +Implementation should happen on an experimental integration branch. PRs for this +work should target that branch until the API shape, LingBot migration, and +OmniDreams migration are all working well enough to merge to `main` together. + +The experimental branch can temporarily break or simplify command-line options +while the demos are being moved to the new API. The required outcome is that the +LingBot and OmniDreams demos still run through the new runtime path, and that +benchmark tooling can confirm they are at least broadly healthy before the +branch is merged back to `main`. + +Initial scope: + +- define the minimal runtime API envelope; +- migrate LingBot and OmniDreams to use it; +- support selectable output modes such as MP4, JPEG/MJPEG stream, WebRTC, and + headless/null where appropriate; +- use or update benchmark tooling to verify the migrated demos; +- defer broader model migrations, hosted execution, full autotune, and polished + metrics until the first branch proves the API shape. + +## Task Tracker + +| ID | Workstream | Can run in parallel? | Depends on | Done when | +| --- | --- | --- | --- | --- | +| T0 | Create experimental branch and contribution rules. | No, this starts the work. | None. | Branch exists, PR target is agreed, and main merge criteria are written down. | +| T1 | Minimal API envelope and naming. | Partly. | T0. | `InferenceConfig`, `UserInputs`, `ModelInputs`, runtime/session, output target, and mapping boundaries are defined well enough for demos to use. | +| T2 | Event-based `UserInputs`. | Yes, after T1 direction is agreed. | T1. | User inputs are primarily timestamped events; replay traces and derived snapshots are supported where needed. | +| T3 | `ModelInputs`, schemas, and mapping boundary. | Yes, after T1 direction is agreed. | T1. | Models can declare required initial/per-step inputs, and mappings can convert user events into model inputs. | +| T4 | `ModelRunner`, `InferenceRuntime`, and `InferenceSession` skeleton. | Partly. | T1. | A minimal standard loop can initialize a runtime, run at least one sequential session, and close cleanly. | +| T5 | Output mode selection. | Yes, after the result/output shape is agreed. | T1, T4. | A run can choose output behavior such as MP4, JPEG/MJPEG stream, WebRTC, benchmark artifact, or headless/null without changing model code. | +| T6 | LingBot migration. | Yes, once T2-T4 have a usable skeleton. | T2, T3, T4. | LingBot runs through the new API path with its event inputs mapped into model inputs. | +| T7 | OmniDreams migration. | Yes, once T2-T4 have a usable skeleton. | T2, T3, T4. | OmniDreams runs through the new API path with its model-specific inputs and mapping preserved. | +| T8 | Benchmark/smoke verification for LingBot and OmniDreams. | Preparation can run early; final gate is late. | T5, T6, T7. | Existing or updated benchmark tooling can run both migrated demos and produce enough evidence that they still work. | +| T9 | Metrics and profiling normalization for the branch. | Yes, but final integration is late. | T4, T5, T8. | Basic canonical metrics are emitted for migrated demos; deeper metrics can remain follow-up work. | +| T10 | CLI compatibility and migration cleanup. | Yes, after demo migrations start. | T6, T7. | Required demo commands are restored or replaced, temporary hacks are removed, and user-facing docs/notes match the branch behavior. | +| T11 | Stabilize and merge experimental branch to `main`. | No, final integration step. | T6-T10. | LingBot and OmniDreams pass agreed smoke/benchmark checks, review feedback is addressed, and the branch can merge as one API transition. | + +Suggested parallel split: + +- one person owns T1/T4, because the API envelope and standard loop are the + critical path; +- one person owns T2/T3, because event inputs, schemas, and mapping need to + stay coherent; +- one person owns T5/T8/T9, because outputs, benchmarks, and metrics are tightly + related; +- LingBot and OmniDreams can be assigned separately once the skeleton is usable; +- one person should track branch health, CLI compatibility, and merge readiness. + +## Architecture + +```text +Optional discovery for CLI, benchmark, hosted, or installed-package flows: + Model/preset registry + -> adapter/preset/default setup/scenario metadata + -> contributes defaults to the app-supplied run setup + +Main runtime flow: +App / integration / benchmark / transport + chooses how the run is driven and where output goes + supplies run setup: + InferenceConfig + UserInputs + ModelInputs + output/metrics options + | + v +ModelRunner / standard loop + orchestrates validation, lifecycle, stepping, output, and metrics + uses input mapping to: + validate that user/app inputs can drive the model + build initial and per-step ModelInputs during the run + | + v +InferenceRuntime + reusable heavyweight lifecycle: distributed init, model load, compile, warmup + load once; create sessions sequentially unless the backend supports concurrency + | + v +InferenceSession + one rollout/stream: prompt/initial inputs, cache/state, current step, reset + keeps per-run state from leaking across prompts, clients, or benchmark repeats + | + v +Model implementation / inference pipeline + hot path: encode -> model step -> decode -> cache/finalize + | + v +Output target + WebRTC | native window | MP4 | benchmark | headless/null + | + v +Metrics / artifacts / logs / reports / traces +``` + +## Example Sequential Session Flow + +The runtime/session split is primarily about reusing expensive model setup while +keeping each rollout's state isolated. The default mental model should be +sequential sessions, not required concurrent sessions. + +```text +ModelRunner / standard loop + | + v +Create InferenceRuntime from InferenceConfig + load checkpoint/model + initialize distributed/backend state + compile/capture/warm up if configured + | + v +Start InferenceSession A + initial ModelInputs: prompt/frame/scene/etc. + per-session state: cache, current step, reset state + step 0 -> step 1 -> ... -> done + outputs -> Output target + metrics -> Metrics recorder + close session A + | + v +Start InferenceSession B + new initial ModelInputs or replay scenario + independent cache/state + step 0 -> step 1 -> ... -> done + outputs -> Output target + metrics -> Metrics recorder + close session B + | + v +Close InferenceRuntime + release model/backend resources +``` + +For v0, an `InferenceRuntime` may support only one active session at a time. +Concurrent sessions should be treated as an optional backend/model capability, +not a baseline API requirement. + +`StreamInferencePipeline` should remain an important local implementation path +for models that already use it, but it should not be treated as the only +possible model boundary. A session may call `StreamInferencePipeline`, another +local model implementation, a Dynamo-like backend, or a hosted service. + +## System Components + +| Component | Role | Boundary | +| --- | --- | --- | +| Model/preset registry | Lists what can run: model/preset slugs, scenarios, capabilities, resource hints, and supported output modes. | Must remain cheap to query and must not load checkpoints. | +| App / integration / benchmark / transport | Owns the user-facing mode: CLI, native integration, WebRTC, benchmark, hosted request, or replay. | Supplies run setup, user inputs, model inputs, and output target selection. | +| User input library | Normalizes live or replayed controls into FlashDreams-supported user input events/windows. | Shared primitives for keyboard, reset, prompt/image updates, traces, and future scalar controls. | +| Input mapping | Converts user/app inputs plus initial model inputs into the model-specific inputs needed by the session. | Owned by model/application code; may be a no-op for simple runs. | +| ModelRunner / standard loop | Orchestrates one run from setup through runtime initialization, stepping, output, metrics, and teardown. | Shared orchestration layer used by CLIs, benchmarks, MP4 runs, and simple realtime flows. | +| InferenceRuntime | Owns heavyweight lifecycle: distributed init, model construction, checkpoint loading, compile/capture, warmup, hosted-service connection, and teardown. | Long-lived reusable runtime created from `InferenceConfig`; lets FlashDreams load/warm once and create sessions sequentially unless the backend supports concurrency. | +| InferenceSession | Owns one rollout or stream: initial inputs, cache state, current step, reset behavior, step requirements, and step execution. | Per-rollout interface consumed by the standard loop; keeps state isolated across prompts, browser clients, replay scenarios, or benchmark repeats. | +| Model implementation / inference pipeline | Implements encode, model step, decode, cache updates, and model-specific optimizations. | FlashDreams wraps this boundary; it should not replace every model implementation. | +| Output target | Consumes generated outputs and handles presentation or persistence. | Separate from model execution so the same session can feed WebRTC, MP4, benchmark, or headless output. | +| Metrics, artifacts, and profiling | Records timings, memory, quality data, logs, reports, traces, and optional NVTX ranges. | Shared observation layer for local runs, benchmarks, CI smoke, and hosted runs. | + +## API Layers + +FlashDreams should expose layered APIs rather than a single all-or-nothing +interface: + +```text +High-level runtime API + run setup -> standard loop -> output targets -> metrics/artifacts + +Adapter/runtime API + model adapter -> InferenceRuntime -> InferenceSession + +Low-level inference API + StreamInferencePipeline -> encoders/decoders -> cache/perf/profiling helpers +``` + +| Layer | Intended user | Provides | +| --- | --- | --- | +| High-level runtime API | Users who want FlashDreams to own the run loop. | Run setup, input mapping, runtime/session lifecycle, output targets, metrics, profiling, and benchmark artifacts. | +| Adapter/runtime API | Model owners who want their model to plug into the standard loop. | Model adapter, input requirements, runtime/session implementation, and model-specific mapping or validation. | +| Low-level inference API | Users who want to own their own loop while reusing FlashDreams building blocks. | `StreamInferencePipeline`, encoders, decoders, cache helpers, profiling tools, and optimization utilities. | + +These layers should remain compatible. The new runtime API sits above the +existing lower-level pieces; it does not replace them. + +## Goals + +- Make FlashDreams easier to use for new world-model integrations. +- Keep model-specific input semantics explicit instead of hiding them in runner + code. +- Avoid a single monolithic inference stack; different models should be able to + validate and use different optimization features. +- Separate model execution from presentation and persistence. +- Support both live input and deterministic replay through the same + runtime/session boundary. +- Make metrics, benchmark artifacts, and profiling first-class without forcing + profiling overhead into normal runs. +- Preserve room for local single-GPU, local distributed, Dynamo-like, and hosted + execution. + +## Non-Goals + +- Do not infer arbitrary model semantics from a raw checkpoint. +- Do not require every model to use the same encoder, decoder, scheduler, + control representation, transport, or optimization set. +- Do not make WebRTC or native display part of the model API. +- Do not make autotuning part of normal inference startup. +- Do not require users to use the high-level standard loop when they only need + lower-level inference building blocks. +- Do not require every existing integration to migrate in one large change. + +## API Placement + +The new API should sit above the existing `flashdreams.infra` layer. Existing +pipelines, encoders, decoders, runner configs, realtime input helpers, WebRTC +code, and quality/benchmark utilities should be reused where possible. + +The exact package layout and class definitions can be decided during +implementation. This document should define responsibilities and boundaries, not +the final Python shape. + +## InferenceConfig + +`InferenceConfig` describes how to run the model/runtime. It should cover: + +- model or preset identity; +- checkpoint or model asset selection; +- execution backend, such as local single GPU, local multi-GPU, Dynamo-like, or + hosted/external execution; +- device placement, precision, and resource hints; +- optimization choices such as compile, CUDA graph capture, attention backend, + cache policy, overlap, prefetch, and native extensions; +- runtime-affecting profiling or tracing options. + +It should not contain prompts, keyboard state, browser settings, MP4 paths, +benchmark output directories, or other app/output settings. Those belong in the +run setup around `InferenceConfig`. + +Existing `StreamInferencePipelineConfig` and `InstantiateConfig` style configs +can remain valid model references behind this layer. The model adapter should +validate which execution and optimization choices are supported. Unsupported +choices should fail clearly or be explicitly handled only when the user selected +an automatic mode. + +## UserInputs + +`UserInputs` describes user-facing controls produced by a live UI, browser, +native app, replay trace, synthetic benchmark driver, or no-op source. + +User inputs should primarily be represented as timestamped events. This gives +live apps, replay traces, and benchmarks the same basic shape, and lets +FlashDreams resample or window those events when a model session asks for the +next chunk of inputs. + +Initial supported user input types should stay close to what FlashDreams already +uses: + +- keyboard keydown/keyup events; +- reset requests; +- prompt update requests; +- image update requests; +- future scalar controls such as throttle, brake, steer, or camera axes once an + integration needs them. + +Snapshot-style inputs, such as current key state, can still be supported when +useful. They should be treated as a derived or compatibility form rather than +the primary user-input abstraction. + +User inputs are not model inputs. A keyboard event does not have one universal +meaning. One model may map it to pose segments, another to steering commands, +and another may ignore it. + +## ModelInputs + +`ModelInputs` describes the data the model or inference pipeline actually +requires. It should distinguish: + +- initial inputs: values needed to start or reset a rollout; +- per-step inputs: values needed for one generated chunk or frame window. + +Examples of initial model inputs include prompt, negative prompt, first frame, +input video, scene id, HD map asset, camera calibration, initial camera pose, +seed, or model-specific fields. + +Examples of per-step model inputs include frame timestamps, pose segments, +camera trajectory chunks, rendered HD map frames, conditioning video windows, +control tensors, event markers, or model-specific fields. + +Model input payloads should use semantic names, not only modality names. For +example, a first frame and an HD map frame should be distinct inputs even if +both are image-like values. + +For interactive runs, most `ModelInputs` will be initial values plus per-step +inputs produced by input mapping. For MP4 generation and benchmarking, the API +should also support fixed per-step model inputs so runs can be deterministic. + +## Schemas + +The API should support lightweight `UserInputSchema` and `ModelInputSchema` +metadata. + +These schemas are not meant to be a rich type system or a replacement for +model-specific validation. They should be just enough to answer: + +- what can this app, transport, trace, or benchmark source provide? +- what does this model require before startup and at each step? +- can this event source drive this model with the selected mapping? + +The purpose is to fail early before expensive model initialization, produce +clearer errors, make fixed scenarios easier to validate, and avoid ambiguous +dict payloads where keys only describe modality. + +For simple CLI text-to-video or image-to-video runs, `UserInputSchema` can be +trivial or omitted because there may be no live controls. `ModelInputSchema` is +more important because each supported model still needs to declare the +model-facing values it expects. + +## Model Requirements + +A raw checkpoint should not be treated as self-describing. It may imply tensor +shapes or architecture details, but it usually does not fully define: + +- required semantic inputs; +- initial versus per-step inputs; +- units for timestamps, poses, or calibration values; +- how user controls become model controls; +- preprocessing, encoder, decoder, mask, prompt, or cache rules. + +Therefore, a FlashDreams-supported model should have an adapter or integration +layer that declares its model input requirements and prepares inputs for the +underlying model implementation. + +Users running an existing FlashDreams-supported model should not need to write +that adapter. Developers bringing a new world model to FlashDreams should expect +to provide one. + +## External Model Usage + +Users should be able to run their own models without adding those models to the +FlashDreams repository. The flow depends on which API layer they use: + +```text +High-level runtime API + user supplies or installs model adapter + FlashDreams owns standard loop, outputs, metrics, benchmarks + +Adapter/runtime API + model owner implements adapter/runtime/session + adapter can be passed directly or registered by an installed package + +Low-level inference API + user owns loop and lifecycle + user reuses pipeline, encoder/decoder, cache, profiling, or optimization tools +``` + +| Flow | Registry needed? | Who provides model-specific code? | Result | +| --- | --- | --- | --- | +| Direct Python | No. | User or model owner passes an adapter/setup directly. | FlashDreams can run the standard loop without the model living in the repo. | +| Installed package | Yes, for discovery. | External or internal package registers adapters/presets. | CLIs, benchmarks, and hosted schedulers can discover the model cheaply. | +| Low-level only | No. | User owns the loop and calls lower-level FlashDreams pieces directly. | Useful when the user wants optimizations or pipeline helpers but not the standard loop. | + +The model adapter is a role/boundary, not necessarily a concrete class. It is +the model-specific code that declares input requirements, validates supported +configs, creates the runtime/session, and connects FlashDreams to the actual +model implementation. + +The registry should not be treated as a central FlashDreams-owned catalog of all +possible models. It is a discovery mechanism for installed adapters. Built-in +public integrations, internal GitLab-only integrations, and third-party packages +can all participate through the same mechanism. + +FlashDreams should not claim to run an arbitrary checkpoint with no adapter +unless the checkpoint already matches a supported generic adapter. + +## Input Mapping + +Input mapping is required whenever `UserInputs` need to become per-step +`ModelInputs`. The exact implementation does not need to be a required top-level +object. It could be: + +- a method on the model adapter; +- a method on an app/runtime adapter; +- a separate mapper object; +- a default no-op or identity mapping for simple T2V/I2V/fixed-input runs. + +There are two separate moments to keep clear: + +- before runtime initialization, FlashDreams should select the mapping and check + obvious compatibility between the app event source and the model; +- during the standard loop, the runner uses the mapping to build initial or + per-step `ModelInputs` from the relevant event window, often after the session + reports what it needs next. + +Examples: + +- T2V mapping validates a prompt and creates no per-step control inputs. +- I2V mapping validates a prompt plus first frame and creates no live controls. +- A keyboard-driven integration maps key events or event windows into pose + segments or steering controls. +- OmniDreams-like integrations may map driving commands into camera poses, HD + map frames, and dynamic actor state. +- Benchmark mapping can read fixed event traces and produce identical step + inputs each run. + +The compatibility check should be treated as early validation, not a guarantee +that the run will succeed. It can catch obvious mismatches, but the model +adapter/runtime still owns deep tensor validation and model semantics. + +## Runtime And Standard Loop + +The standard loop should be shared by CLI generation, headless playback, MP4 +generation, benchmarks, and simple realtime applications. + +A run should: + +1. Discover the model or preset without loading checkpoints. +2. Resolve inference config, user inputs, model inputs, output target, metrics, + profiling, and optional scenario setup. +3. Validate that the event source and mapping can drive the selected model. +4. Initialize the runtime. +5. Start a session from initial model inputs. +6. For each step, ask the session what it needs, gather live or fixed inputs, + build step model inputs, run the session step, route outputs, and record + metrics. +7. Finalize output artifacts, metrics, logs, reports, and traces. + +Realtime transports may need an async variant, backpressure, and explicit flow +control, but the conceptual boundary should remain the same: event/input source, +input mapping, session, output target, metrics. + +The session should expose what it needs for the next step rather than requiring +the app or output layer to guess. This matters because AR step 0 can differ from +steady-state steps, and encoder/decoder temporal compression can produce +different input and output frame windows. + +## Output Targets + +Output handling should be separate from model execution. The model session +returns generated outputs and metadata; the output target decides what to do +with them. + +Expected output targets include: + +- WebRTC streaming; +- native window display; +- MJPEG or lightweight remote preview; +- MP4 writing; +- benchmark artifact writing; +- headless playback; +- null output for pure throughput measurements. + +Display and transport can still affect measured performance through copies, +encoding, queueing, backpressure, and presentation timing. Those costs should be +measured as output-target or end-to-end metrics instead of being mixed into core +model-stage timings. + +## Fixed Inputs, Benchmarks + +The API should support fixed runs as a first-class case. This is needed for MP4 +generation, benchmarks, regression testing, and autotune. + +Two replay levels should be supported: + +- user-event replay: records timestamped key events, prompt updates, image + updates, reset events, and timing, then runs normal input mapping; +- model-input replay: records or defines already-mapped per-step model inputs + for stricter model-level regression tests. + +User-event replay tests more of the application stack. Model-input replay is +better for isolating model runtime performance and reproducibility. + +## Metrics And Profiling + +Metrics should have a small canonical baseline plus optional extras. + +The baseline should cover: + +- lifecycle timing: startup, load, warmup, first-step latency; +- model-stage timing: encode, model step, decode, finalize/cache update; +- memory: allocated, reserved, peak, and per-rank where applicable; +- throughput: frames per second, chunks per second, real-time factor. + +Realtime runs may add input-to-present latency, jitter, missed deadlines, queue +depth, dropped frames, WebRTC stats, encoder bitrate, and client stats. +Benchmark runs may add quality metrics, logs, MP4/image previews, and reports. + +Persisted timing metrics should use seconds as the canonical unit because +seconds compose cleanly across Python timers, traces, and long-running +durations. Reports and UIs can display milliseconds for short latencies. + +Profiling should be optional and controlled separately from normal metrics. +NVTX ranges should be supported for Nsight profiling, but profiling should not +be required for normal inference or benchmark runs. + +## Autotune + +Autotune should be a separate harness that evaluates candidate +`InferenceConfig` variants against fixed scenarios. It should not be part of +normal startup. + +Autotune may search over compile, CUDA graph capture, attention backend, +precision, cache policy, overlap, prefetch, native extensions, and chunk size +when the model supports those knobs. + +Results are only valid for a specific model, checkpoint, hardware, driver, +FlashDreams commit, and scenario. First-run compile/capture cost should be +separated from steady-state metrics. Agent assistance could help propose search +spaces or summarize results, but the measured selection process should be +deterministic code. + +## Distributed And Hosted Execution + +The API should leave room for local single-GPU, local multi-GPU, Dynamo-like +execution, and hosted execution such as a Reactor-style platform. + +At this stage, the proposal should not define Reactor- or Dynamo-specific +contracts in detail. It should preserve the right boundary: execution backend +selection belongs in `InferenceConfig`, while backend-specific scheduling, +authentication, asset access, output streaming, artifact handling, and failure +behavior belong behind the runtime/backend implementation. + +The practical order should be local first, then local distributed, then +hosted/distributed backends once concrete backend owners can validate the +requirements. + +## Existing Code And Migration + +The new API should reuse existing code instead of replacing everything: + +- keep `flashdreams.infra.pipeline` as the common local encode/model/decode + implementation path; +- keep existing encoder and decoder contracts and reuse temporal size helpers; +- keep existing runner configs and CLI compatibility during migration; +- reuse `KeyboardResampler` and realtime input helpers behind the new input + boundary; +- treat WebRTC as a transport/output adapter and bridge it gradually; +- reuse existing quality and benchmark utilities where applicable; +- keep internal-only integrations registered only in the GitLab/internal + workspace. + +The task tracker near the start of this document is the source of truth for the +first implementation branch. The first milestone is intentionally narrower than +the full design: prove the API with LingBot and OmniDreams, selectable output +modes, and enough benchmark/smoke coverage to merge the experimental branch +back to `main` safely. + +## Design Risks + +- `InferenceConfig` could become too broad if prompts, controls, output paths, + browser settings, and benchmark settings are added to it. Keep it focused on + model/runtime execution. +- Dict-like model inputs are flexible but can fail late. Keep dict payloads for + flexibility, but require lightweight schemas and adapter validation for + supported models. +- Schemas could become too heavy. Keep them minimal and role-oriented. +- User inputs are not model inputs. Keep input mapping explicit and + model/application-owned. +- Per-frame, per-chunk, and AR-step clocks are easy to confuse. The session + should expose step requirements instead of making app code guess. +- Output separation is necessary but not free. Measure output and transport + costs separately from core model timings. +- Hosted/distributed execution is still under-specified. Keep the API boundary + open until backend owners validate concrete requirements. +- Existing WebRTC behavior is nontrivial. Bridge it gradually to avoid + regressions. +- Public/internal boundaries must remain clean. Internal adapters, slugs, and + scenarios should not leak into the public repo. + +## Decisions To Make Before Implementation + +- What should the top-level package/API be called? +- Should the main registered object be called an adapter, integration, runtime + factory, or something else? +- What direct-Python API should let users pass an external adapter without + registering it? +- What package registration mechanism should third-party and internal adapters + use for CLI discovery and benchmarks? +- How lightweight should `UserInputSchema` and `ModelInputSchema` be? +- Where should input mapping live: model adapter, app adapter, separate object, + or a mix? +- What should the output abstraction be called? +- What is the minimum v0 set of supported user input events? +- What is the first public model to migrate? +- What metrics are required for every benchmark run? +- What metadata must be discoverable without loading checkpoints? +- What requirements do Dynamo/Reactor-style backends need before we commit to + hosted execution details? + +The document currently uses "integration" for model-specific packages and app +entrypoints. If the team prefers "model" as the public term, that can be changed +later without changing the architecture. + +## Recommendation + +Proceed with the proposed split: + +- `InferenceConfig` for model/runtime execution; +- `UserInputs` for app-facing controls and replay traces; +- `ModelInputs` for model-facing initial and per-step inputs; +- input mapping for model/application-specific conversion; +- runtime/session boundaries for lifecycle and stepping; +- output targets for display, streaming, files, and benchmarks; +- shared metrics and optional profiling. + +The main constraint is that arbitrary world-model inputs cannot be standardized +away. FlashDreams can provide the shared envelope, loop, metrics, replay, and +output tools, but each supported model still needs an adapter that declares and +validates its own input contract. From 0c089c20c711f291b95589d8af26ada18028fb54 Mon Sep 17 00:00:00 2001 From: jarcherNV Date: Tue, 4 Aug 2026 02:11:54 -0700 Subject: [PATCH 02/30] Add experimental inference runtime API envelope (#403) Define the initial flashdreams.runtime package with minimal T1 boundaries for runtime config, user/model inputs, schemas, input mapping, model adapters, runtime/session protocols, output targets, and metrics. Add focused CPU tests for the new API surface without migrating existing runners. --- docs/inference_runtime_api_design.md | 112 ++-- flashdreams/flashdreams/runtime/__init__.py | 60 +++ flashdreams/flashdreams/runtime/_utils.py | 17 + flashdreams/flashdreams/runtime/config.py | 76 +++ flashdreams/flashdreams/runtime/inputs.py | 200 +++++++ flashdreams/flashdreams/runtime/interfaces.py | 90 ++++ flashdreams/flashdreams/runtime/mapping.py | 85 +++ flashdreams/flashdreams/runtime/metrics.py | 124 +++++ flashdreams/flashdreams/runtime/output.py | 78 +++ flashdreams/flashdreams/runtime/types.py | 56 ++ .../tests/test_inference_runtime_api.py | 492 ++++++++++++++++++ 11 files changed, 1350 insertions(+), 40 deletions(-) create mode 100644 flashdreams/flashdreams/runtime/__init__.py create mode 100644 flashdreams/flashdreams/runtime/_utils.py create mode 100644 flashdreams/flashdreams/runtime/config.py create mode 100644 flashdreams/flashdreams/runtime/inputs.py create mode 100644 flashdreams/flashdreams/runtime/interfaces.py create mode 100644 flashdreams/flashdreams/runtime/mapping.py create mode 100644 flashdreams/flashdreams/runtime/metrics.py create mode 100644 flashdreams/flashdreams/runtime/output.py create mode 100644 flashdreams/flashdreams/runtime/types.py create mode 100644 flashdreams/tests/test_inference_runtime_api.py diff --git a/docs/inference_runtime_api_design.md b/docs/inference_runtime_api_design.md index 6a4eea9dc..2f0ba19f8 100644 --- a/docs/inference_runtime_api_design.md +++ b/docs/inference_runtime_api_design.md @@ -59,25 +59,25 @@ Initial scope: ## Task Tracker -| ID | Workstream | Can run in parallel? | Depends on | Done when | -| --- | --- | --- | --- | --- | -| T0 | Create experimental branch and contribution rules. | No, this starts the work. | None. | Branch exists, PR target is agreed, and main merge criteria are written down. | -| T1 | Minimal API envelope and naming. | Partly. | T0. | `InferenceConfig`, `UserInputs`, `ModelInputs`, runtime/session, output target, and mapping boundaries are defined well enough for demos to use. | -| T2 | Event-based `UserInputs`. | Yes, after T1 direction is agreed. | T1. | User inputs are primarily timestamped events; replay traces and derived snapshots are supported where needed. | -| T3 | `ModelInputs`, schemas, and mapping boundary. | Yes, after T1 direction is agreed. | T1. | Models can declare required initial/per-step inputs, and mappings can convert user events into model inputs. | -| T4 | `ModelRunner`, `InferenceRuntime`, and `InferenceSession` skeleton. | Partly. | T1. | A minimal standard loop can initialize a runtime, run at least one sequential session, and close cleanly. | -| T5 | Output mode selection. | Yes, after the result/output shape is agreed. | T1, T4. | A run can choose output behavior such as MP4, JPEG/MJPEG stream, WebRTC, benchmark artifact, or headless/null without changing model code. | -| T6 | LingBot migration. | Yes, once T2-T4 have a usable skeleton. | T2, T3, T4. | LingBot runs through the new API path with its event inputs mapped into model inputs. | -| T7 | OmniDreams migration. | Yes, once T2-T4 have a usable skeleton. | T2, T3, T4. | OmniDreams runs through the new API path with its model-specific inputs and mapping preserved. | -| T8 | Benchmark/smoke verification for LingBot and OmniDreams. | Preparation can run early; final gate is late. | T5, T6, T7. | Existing or updated benchmark tooling can run both migrated demos and produce enough evidence that they still work. | -| T9 | Metrics and profiling normalization for the branch. | Yes, but final integration is late. | T4, T5, T8. | Basic canonical metrics are emitted for migrated demos; deeper metrics can remain follow-up work. | -| T10 | CLI compatibility and migration cleanup. | Yes, after demo migrations start. | T6, T7. | Required demo commands are restored or replaced, temporary hacks are removed, and user-facing docs/notes match the branch behavior. | -| T11 | Stabilize and merge experimental branch to `main`. | No, final integration step. | T6-T10. | LingBot and OmniDreams pass agreed smoke/benchmark checks, review feedback is addressed, and the branch can merge as one API transition. | +| ID | Status | Workstream | Can run in parallel? | Depends on | Done when | +| --- | --- | --- | --- | --- | --- | +| T0 | Complete | Create experimental branch and contribution rules. | No, this starts the work. | None. | Branch exists, PR target is agreed, and main merge criteria are written down. | +| T1 | Complete | Minimal API envelope and naming. | Partly. | T0. | `InferenceConfig`, `UserInputs`, `ModelInputs`, runtime/session, output target, and mapping boundaries are defined well enough for demos to use. | +| T2 | Planned | Event-based `UserInputs`. | Yes, after T1 direction is agreed. | T1. | User inputs are primarily timestamped events; replay traces and derived snapshots are supported where needed. | +| T3 | Planned | `ModelInputs`, schemas, and mapping boundary. | Yes, after T1 direction is agreed. | T1. | Models can declare required initial/per-step inputs, and mappings can convert user events into model inputs. | +| T4 | Planned | `ModelRunner`, `InferenceRuntime`, and `InferenceSession` skeleton. | Partly. | T1. | A minimal standard loop can initialize a runtime, run at least one sequential session, and close cleanly. | +| T5 | Planned | Output mode selection. | Yes, after the result/output shape is agreed. | T1, T4. | A run can choose output behavior such as MP4, JPEG/MJPEG stream, WebRTC, benchmark artifact, or headless/null without changing model code. | +| T6 | Planned | LingBot migration. | Yes, once T2-T4 have a usable skeleton. | T2, T3, T4. | LingBot runs through the new API path with its event inputs mapped into model inputs. | +| T7 | Planned | OmniDreams migration. | Yes, once T2-T4 have a usable skeleton. | T2, T3, T4. | OmniDreams runs through the new API path with its model-specific inputs and mapping preserved. | +| T8 | Planned | Benchmark/smoke verification for LingBot and OmniDreams. | Preparation can run early; final gate is late. | T5, T6, T7. | Existing or updated benchmark tooling can run both migrated demos and produce enough evidence that they still work. | +| T9 | Planned | Metrics and profiling normalization for the branch. | Yes, but final integration is late. | T4, T5, T8. | Basic canonical metrics are emitted for migrated demos; deeper metrics can remain follow-up work. | +| T10 | Planned | CLI compatibility and migration cleanup. | Yes, after demo migrations start. | T6, T7. | Required demo commands are restored or replaced, temporary hacks are removed, and user-facing docs/notes match the branch behavior. | +| T11 | Planned | Stabilize and merge experimental branch to `main`. | No, final integration step. | T6-T10. | LingBot and OmniDreams pass agreed smoke/benchmark checks, review feedback is addressed, and the branch can merge as one API transition. | Suggested parallel split: -- one person owns T1/T4, because the API envelope and standard loop are the - critical path; +- one person owns T4 and keeps it aligned with the completed T1 envelope, + because the standard loop is now the critical path; - one person owns T2/T3, because event inputs, schemas, and mapping need to stay coherent; - one person owns T5/T8/T9, because outputs, benchmarks, and metrics are tightly @@ -182,7 +182,7 @@ local model implementation, a Dynamo-like backend, or a hosted service. | Model/preset registry | Lists what can run: model/preset slugs, scenarios, capabilities, resource hints, and supported output modes. | Must remain cheap to query and must not load checkpoints. | | App / integration / benchmark / transport | Owns the user-facing mode: CLI, native integration, WebRTC, benchmark, hosted request, or replay. | Supplies run setup, user inputs, model inputs, and output target selection. | | User input library | Normalizes live or replayed controls into FlashDreams-supported user input events/windows. | Shared primitives for keyboard, reset, prompt/image updates, traces, and future scalar controls. | -| Input mapping | Converts user/app inputs plus initial model inputs into the model-specific inputs needed by the session. | Owned by model/application code; may be a no-op for simple runs. | +| Input mapping | Converts user/app inputs plus initial model inputs into the model-specific inputs needed by the session. | A model adapter may provide a default mapping; runtimes, applications, benchmarks, and replay tools may override it without changing the model step. | | ModelRunner / standard loop | Orchestrates one run from setup through runtime initialization, stepping, output, metrics, and teardown. | Shared orchestration layer used by CLIs, benchmarks, MP4 runs, and simple realtime flows. | | InferenceRuntime | Owns heavyweight lifecycle: distributed init, model construction, checkpoint loading, compile/capture, warmup, hosted-service connection, and teardown. | Long-lived reusable runtime created from `InferenceConfig`; lets FlashDreams load/warm once and create sessions sequentially unless the backend supports concurrency. | | InferenceSession | Owns one rollout or stream: initial inputs, cache state, current step, reset behavior, step requirements, and step execution. | Per-rollout interface consumed by the standard loop; keeps state isolated across prompts, browser clients, replay scenarios, or benchmark repeats. | @@ -281,8 +281,11 @@ native app, replay trace, synthetic benchmark driver, or no-op source. User inputs should primarily be represented as timestamped events. This gives live apps, replay traces, and benchmarks the same basic shape, and lets -FlashDreams resample or window those events when a model session asks for the -next chunk of inputs. +FlashDreams route, drain, or window those events when a model session asks for +the next chunk of inputs. Resampling and interpolation should remain +input-specific mapping or helper behavior, because controls such as rotations, +poses, or controller state may need semantics that generic runtime code cannot +infer safely. Initial supported user input types should stay close to what FlashDreams already uses: @@ -359,8 +362,8 @@ shapes or architecture details, but it usually does not fully define: - preprocessing, encoder, decoder, mask, prompt, or cache rules. Therefore, a FlashDreams-supported model should have an adapter or integration -layer that declares its model input requirements and prepares inputs for the -underlying model implementation. +layer that declares its model input requirements, declares any user inputs it can +map by default, and prepares inputs for the underlying model implementation. Users running an existing FlashDreams-supported model should not need to write that adapter. Developers bringing a new world model to FlashDreams should expect @@ -407,21 +410,25 @@ unless the checkpoint already matches a supported generic adapter. ## Input Mapping Input mapping is required whenever `UserInputs` need to become per-step -`ModelInputs`. The exact implementation does not need to be a required top-level -object. It could be: - -- a method on the model adapter; -- a method on an app/runtime adapter; -- a separate mapper object; -- a default no-op or identity mapping for simple T2V/I2V/fixed-input runs. +`ModelInputs`. In the T1 envelope this boundary is represented by a separate +`InputMapping` protocol. A model adapter may provide the default mapper because +it knows how its supported user controls affect model-facing inputs. Applications, +benchmarks, replay tools, or hosted runtimes may replace that mapper when they +need a different wire surface or aggregation policy. There are two separate moments to keep clear: - before runtime initialization, FlashDreams should select the mapping and check obvious compatibility between the app event source and the model; -- during the standard loop, the runner uses the mapping to build initial or - per-step `ModelInputs` from the relevant event window, often after the session - reports what it needs next. +- during the standard loop, the runtime or runner queues and timestamps user + events, then uses the selected mapping to build initial or per-step + `ModelInputs` from the relevant event window, often after the session reports + what it needs next. + +This keeps the Reactor-style contract intact: the model-side integration can +declare user inputs, declare model inputs, and provide a default mapping, while +the runtime owns transport, event validation, timestamping, input queue/window +selection, output delivery, and optional overrides. Examples: @@ -465,6 +472,11 @@ the app or output layer to guess. This matters because AR step 0 can differ from steady-state steps, and encoder/decoder temporal compression can produce different input and output frame windows. +Input and output timing should share a session timeline even when raw capture +rates and presentation rates differ. A session can request a user-input window +for mapping, then return an output window or equivalent metadata so an output +target can present the generated chunk at the intended cadence. + ## Output Targets Output handling should be separate from model execution. The model session @@ -598,20 +610,40 @@ back to `main` safely. - Public/internal boundaries must remain clean. Internal adapters, slugs, and scenarios should not leak into the public repo. -## Decisions To Make Before Implementation +## Decisions Made In T1 + +Task T1 settles the initial package and naming envelope without committing to a +registry, standard loop, concrete output modes, or model migrations: + +- The experimental API lives under `flashdreams.runtime`. +- The model-specific integration boundary is named `ModelAdapter`. +- Heavyweight lifecycle is split into `InferenceRuntime` and + `InferenceSession`. +- Step data carriers are named `StepRequest` and `StepResult`; a session returns + `None` from `next_step_request()` when the rollout is complete. +- User-facing inputs use `UserInputs`; model-facing inputs use `ModelInputs`. + Both remain lightweight payload envelopes with shallow read-only mappings. +- `UserInputSchema` and `ModelInputSchema` stay intentionally small: they + declare supported event types and required named fields for early validation, + not a full type system. +- Input mapping is represented by a separate `InputMapping` protocol. Model + adapters may provide a default mapping; runtimes and applications may override + it while preserving the `UserInputs` to `ModelInputs` boundary. Simple + fixed-input runs can use `IdentityInputMapping`. +- Output handling is represented by `OutputTarget`; `NullOutputTarget` is the + initial headless implementation. +- Metrics collection is represented by `MetricsRecorder`; timing samples use + seconds as the canonical unit. +- The minimum v0 user input shape is timestamped `UserInputEvent` records plus + optional snapshot data. Concrete event-type catalogs are left to T2 and demo + migrations. + +## Remaining Decisions -- What should the top-level package/API be called? -- Should the main registered object be called an adapter, integration, runtime - factory, or something else? - What direct-Python API should let users pass an external adapter without registering it? - What package registration mechanism should third-party and internal adapters use for CLI discovery and benchmarks? -- How lightweight should `UserInputSchema` and `ModelInputSchema` be? -- Where should input mapping live: model adapter, app adapter, separate object, - or a mix? -- What should the output abstraction be called? -- What is the minimum v0 set of supported user input events? - What is the first public model to migrate? - What metrics are required for every benchmark run? - What metadata must be discoverable without loading checkpoints? diff --git a/flashdreams/flashdreams/runtime/__init__.py b/flashdreams/flashdreams/runtime/__init__.py new file mode 100644 index 000000000..03e6202b0 --- /dev/null +++ b/flashdreams/flashdreams/runtime/__init__.py @@ -0,0 +1,60 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Experimental inference runtime API envelope. + +This package defines the small v0 boundary above ``flashdreams.infra``. It is +intentionally additive while integrations migrate onto it. +""" + +from flashdreams.runtime.config import ExecutionBackend, InferenceConfig, Precision +from flashdreams.runtime.inputs import ( + InputField, + ModelInputs, + ModelInputSchema, + TimeWindow, + UserInputEvent, + UserInputs, + UserInputSchema, +) +from flashdreams.runtime.interfaces import ( + InferenceRuntime, + InferenceSession, + ModelAdapter, +) +from flashdreams.runtime.mapping import IdentityInputMapping, InputMapping +from flashdreams.runtime.metrics import ( + InMemoryMetricsRecorder, + MetricsRecorder, + NullMetricsRecorder, + RuntimeMetricSample, +) +from flashdreams.runtime.output import NullOutputTarget, OutputArtifact, OutputTarget +from flashdreams.runtime.types import StepRequest, StepResult + +__all__ = [ + "ExecutionBackend", + "IdentityInputMapping", + "InferenceConfig", + "InferenceRuntime", + "InferenceSession", + "InMemoryMetricsRecorder", + "InputField", + "InputMapping", + "MetricsRecorder", + "ModelAdapter", + "ModelInputs", + "ModelInputSchema", + "NullMetricsRecorder", + "NullOutputTarget", + "OutputArtifact", + "OutputTarget", + "Precision", + "RuntimeMetricSample", + "StepRequest", + "StepResult", + "TimeWindow", + "UserInputEvent", + "UserInputs", + "UserInputSchema", +] diff --git a/flashdreams/flashdreams/runtime/_utils.py b/flashdreams/flashdreams/runtime/_utils.py new file mode 100644 index 000000000..d8016c6b7 --- /dev/null +++ b/flashdreams/flashdreams/runtime/_utils.py @@ -0,0 +1,17 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Small helpers shared by the experimental runtime API.""" + +from __future__ import annotations + +from collections.abc import Mapping +from types import MappingProxyType +from typing import TypeVar + +ValueT = TypeVar("ValueT") + + +def freeze_mapping(value: Mapping[str, ValueT]) -> Mapping[str, ValueT]: + """Return a read-only shallow copy of ``value``.""" + return MappingProxyType(dict(value)) diff --git a/flashdreams/flashdreams/runtime/config.py b/flashdreams/flashdreams/runtime/config.py new file mode 100644 index 000000000..4b8752f13 --- /dev/null +++ b/flashdreams/flashdreams/runtime/config.py @@ -0,0 +1,76 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Runtime-facing configuration envelope.""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Literal + +from flashdreams.runtime._utils import freeze_mapping + +ExecutionBackend = Literal["local", "local-distributed", "external", "hosted"] +"""Where and how inference compute is run.""" + +Precision = Literal["auto", "fp32", "fp16", "bf16"] +"""Coarse runtime precision choices.""" + + +@dataclass(frozen=True, kw_only=True, slots=True) +class InferenceConfig: + """Runtime settings that affect model execution. + + Prompts, user controls, browser settings, output paths, and benchmark + directories intentionally live outside this object. The typed optimization + fields cover common cross-backend knobs; open-ended adapter-specific choices + can use :attr:`runtime_options`. + """ + + __hash__ = None + + model_id: str + """Stable identity for the model adapter or runtime integration.""" + + preset_id: str | None = None + """Optional preset identity under :attr:`model_id`.""" + + checkpoint: str | Path | None = None + """Optional checkpoint or model-asset selector understood by the adapter.""" + + backend: ExecutionBackend = "local" + """Execution placement and backend family for inference compute.""" + + device: str | None = None + """Optional device selector such as ``cuda`` or ``cuda:0``; ``None`` leaves placement to the adapter/backend.""" + + precision: Precision = "auto" + """Preferred compute precision.""" + + compile: bool | None = None + """Optional - Whether model compilation is requested or disabled. `None` means left to the adapter to decide.""" + + cuda_graph: bool | None = None + """Optional - Whether CUDA graph capture is requested or disabled. `None` means left to the adapter to decide.""" + + attention_backend: str | None = None + """Optional attention implementation selector; ``None`` leaves the choice to the adapter.""" + + cache_policy: str | None = None + """Optional cache policy selector; ``None`` leaves the choice to the adapter.""" + + runtime_options: Mapping[str, Any] = field(default_factory=dict) + """Adapter/backend-specific runtime options.""" + + resource_hints: Mapping[str, Any] = field(default_factory=dict) + """Resource hints for launchers, schedulers, or hosted backends.""" + + def __post_init__(self) -> None: + if not self.model_id.strip(): + raise ValueError("InferenceConfig.model_id must be non-empty.") + object.__setattr__( + self, "runtime_options", freeze_mapping(self.runtime_options) + ) + object.__setattr__(self, "resource_hints", freeze_mapping(self.resource_hints)) diff --git a/flashdreams/flashdreams/runtime/inputs.py b/flashdreams/flashdreams/runtime/inputs.py new file mode 100644 index 000000000..e14b35722 --- /dev/null +++ b/flashdreams/flashdreams/runtime/inputs.py @@ -0,0 +1,200 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""User- and model-input envelopes for the experimental runtime API.""" + +from __future__ import annotations + +import math +from collections.abc import Iterable, Mapping +from dataclasses import dataclass, field +from typing import Any + +from flashdreams.runtime._utils import freeze_mapping + + +@dataclass(frozen=True, kw_only=True, slots=True) +class TimeWindow: + """Half-open time window in seconds since session start.""" + + start_s: float + end_s: float + + def __post_init__(self) -> None: + if not math.isfinite(self.start_s) or not math.isfinite(self.end_s): + raise ValueError("TimeWindow bounds must be finite seconds.") + if self.start_s < 0 or self.end_s < 0: + raise ValueError("TimeWindow bounds must be non-negative.") + if self.end_s < self.start_s: + raise ValueError("TimeWindow.end_s must be >= start_s.") + + def contains(self, timestamp_s: float) -> bool: + """Return whether ``timestamp_s`` falls within this half-open window.""" + return self.start_s <= timestamp_s < self.end_s + + +@dataclass(frozen=True, kw_only=True, slots=True) +class InputField: + """Lightweight schema field for user snapshots or model inputs.""" + + name: str + required: bool = True + semantic_type: str | None = None + description: str = "" + + def __post_init__(self) -> None: + if not self.name.strip(): + raise ValueError("InputField.name must be non-empty.") + + +@dataclass(frozen=True, kw_only=True, slots=True) +class UserInputSchema: + """Minimal metadata for user events a source or mapping can provide.""" + + event_types: frozenset[str] = field(default_factory=frozenset) + snapshot_fields: tuple[InputField, ...] = () + description: str = "" + + def supports_event_types(self, event_types: Iterable[str]) -> bool: + """Return whether every requested event type is declared supported.""" + requested = frozenset(event_types) + if not requested: + return True + return requested.issubset(self.event_types) + + def missing_snapshot(self, inputs: "UserInputs") -> tuple[str, ...]: + """Return required snapshot fields absent from ``inputs``.""" + return _missing_required(self.snapshot_fields, inputs.snapshot) + + def require_snapshot(self, inputs: "UserInputs") -> None: + """Raise if required snapshot fields are absent.""" + missing = self.missing_snapshot(inputs) + if missing: + raise ValueError(f"Missing required user snapshot field(s): {missing}") + + +@dataclass(frozen=True, kw_only=True, slots=True) +class ModelInputSchema: + """Minimal metadata for model-facing initial and per-step inputs.""" + + initial_fields: tuple[InputField, ...] = () + """Model inputs required before starting the initial generation/session.""" + + step_fields: tuple[InputField, ...] = () + """Per-step model inputs required after the session starts.""" + + description: str = "" + + def missing_initial(self, inputs: "ModelInputs") -> tuple[str, ...]: + """Return required initial fields absent from ``inputs``.""" + return _missing_required(self.initial_fields, inputs.initial) + + def missing_step(self, inputs: "ModelInputs") -> tuple[str, ...]: + """Return required per-step fields absent from ``inputs``.""" + return _missing_required(self.step_fields, inputs.step) + + def require_initial(self, inputs: "ModelInputs") -> None: + """Raise if required initial fields are absent.""" + missing = self.missing_initial(inputs) + if missing: + raise ValueError(f"Missing required initial model input(s): {missing}") + + def require_step(self, inputs: "ModelInputs") -> None: + """Raise if required per-step fields are absent.""" + missing = self.missing_step(inputs) + if missing: + raise ValueError(f"Missing required step model input(s): {missing}") + + +@dataclass(frozen=True, kw_only=True, slots=True) +class UserInputEvent: + """User-facing input event timestamped in seconds since session start. + + Live runtimes, transports, replay loaders, or benchmark drivers stamp events + before queuing them for input mapping. Payload schema is intentionally minimal + in T1; concrete event catalogs belong to follow-up input-mapping work. + """ + + __hash__ = None + + timestamp_s: float + event_type: str + payload: Mapping[str, Any] = field(default_factory=dict) + source: str | None = None + source_event_id: str | None = None + + def __post_init__(self) -> None: + if not math.isfinite(self.timestamp_s) or self.timestamp_s < 0: + raise ValueError("UserInputEvent.timestamp_s must be finite and >= 0.") + if not self.event_type.strip(): + raise ValueError("UserInputEvent.event_type must be non-empty.") + object.__setattr__(self, "payload", freeze_mapping(self.payload)) + + +@dataclass(frozen=True, kw_only=True, slots=True) +class UserInputs: + """Transport-neutral user input batch or window. + + Events must be in non-decreasing timestamp order. Runtimes can pass the full + input history, a drained queue batch, or a session-requested time window to an + ``InputMapping``. + """ + + __hash__ = None + + events: tuple[UserInputEvent, ...] = () + snapshot: Mapping[str, Any] = field(default_factory=dict) + metadata: Mapping[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + previous_timestamp_s = -math.inf + for event in self.events: + if event.timestamp_s < previous_timestamp_s: + raise ValueError( + "UserInputs.events must be sorted by non-decreasing timestamp_s." + ) + previous_timestamp_s = event.timestamp_s + object.__setattr__(self, "snapshot", freeze_mapping(self.snapshot)) + object.__setattr__(self, "metadata", freeze_mapping(self.metadata)) + + def window(self, time_window: TimeWindow) -> "UserInputs": + """Return inputs with events filtered to ``time_window``.""" + return UserInputs( + events=tuple( + event + for event in self.events + if time_window.contains(event.timestamp_s) + ), + snapshot=self.snapshot, + metadata=self.metadata, + ) + + +@dataclass(frozen=True, kw_only=True, slots=True) +class ModelInputs: + """Model-facing payloads split by initial and per-step use.""" + + __hash__ = None + + initial: Mapping[str, Any] = field(default_factory=dict) + step: Mapping[str, Any] = field(default_factory=dict) + metadata: Mapping[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + object.__setattr__(self, "initial", freeze_mapping(self.initial)) + object.__setattr__(self, "step", freeze_mapping(self.step)) + object.__setattr__(self, "metadata", freeze_mapping(self.metadata)) + + def with_step(self, step: Mapping[str, Any]) -> "ModelInputs": + """Return a copy with replaced per-step payload.""" + return ModelInputs(initial=self.initial, step=step, metadata=self.metadata) + + +def _missing_required( + fields: tuple[InputField, ...], payload: Mapping[str, Any] +) -> tuple[str, ...]: + return tuple( + input_field.name + for input_field in fields + if input_field.required and input_field.name not in payload + ) diff --git a/flashdreams/flashdreams/runtime/interfaces.py b/flashdreams/flashdreams/runtime/interfaces.py new file mode 100644 index 000000000..9b6a064fd --- /dev/null +++ b/flashdreams/flashdreams/runtime/interfaces.py @@ -0,0 +1,90 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Protocols for model adapters, reusable runtimes, and sessions.""" + +from __future__ import annotations + +from typing import Protocol, runtime_checkable + +from flashdreams.runtime.config import InferenceConfig +from flashdreams.runtime.inputs import ( + ModelInputs, + ModelInputSchema, + UserInputSchema, +) +from flashdreams.runtime.mapping import InputMapping +from flashdreams.runtime.types import StepRequest, StepResult + + +@runtime_checkable +class InferenceSession(Protocol): + """One rollout or stream with isolated model/cache state.""" + + def next_step_request(self) -> StepRequest | None: + """Describe the next step's inputs, or return ``None`` when complete.""" + ... + + def step(self, inputs: ModelInputs) -> StepResult: + """Run one sequential inference step.""" + ... + + def reset(self, inputs: ModelInputs | None = None) -> None: + """Reset this session's rollout state when the backend supports it.""" + ... + + def close(self) -> None: + """Release per-session resources.""" + ... + + +@runtime_checkable +class InferenceRuntime(Protocol): + """Heavyweight reusable runtime created from :class:`InferenceConfig`.""" + + def start_session(self, inputs: ModelInputs) -> InferenceSession: + """Create an isolated session from initial model inputs.""" + ... + + def close(self) -> None: + """Release model/backend resources.""" + ... + + +# Do not mark ModelAdapter runtime-checkable: properties make issubclass() +# unreliable, and isinstance() would only verify attribute presence. +class ModelAdapter(Protocol): + """Model-specific boundary that declares defaults and creates runtimes. + + Adapters declare model-facing input requirements, optional user-input + capabilities, and an optional default mapping between the two. Runtime, + application, or benchmark code may override that mapping while preserving the + same ``UserInputs`` to ``ModelInputs`` boundary. + """ + + @property + def model_id(self) -> str: + """Stable identity for the model adapter or runtime integration.""" + ... + + @property + def model_input_schema(self) -> ModelInputSchema: + """Model-facing initial and per-step input requirements.""" + ... + + @property + def user_input_schema(self) -> UserInputSchema | None: + """User inputs supported by the adapter's default mapping, if any.""" + ... + + def default_input_mapping(self) -> InputMapping | None: + """Return the model-provided default user-to-model mapping, if any.""" + ... + + def validate_config(self, config: InferenceConfig) -> None: + """Fail early for unsupported runtime settings.""" + ... + + def create_runtime(self, config: InferenceConfig) -> InferenceRuntime: + """Initialize and return the heavyweight runtime.""" + ... diff --git a/flashdreams/flashdreams/runtime/mapping.py b/flashdreams/flashdreams/runtime/mapping.py new file mode 100644 index 000000000..756351081 --- /dev/null +++ b/flashdreams/flashdreams/runtime/mapping.py @@ -0,0 +1,85 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Input mapping boundary from user input windows to model inputs.""" + +from __future__ import annotations + +from typing import Protocol, runtime_checkable + +from flashdreams.runtime.inputs import ( + ModelInputs, + ModelInputSchema, + UserInputs, + UserInputSchema, +) +from flashdreams.runtime.types import StepRequest + + +@runtime_checkable +class InputMapping(Protocol): + """Convert user-facing inputs into model-facing inputs. + + A mapping may be supplied by the model adapter as a default or by an + application/runtime override. Step mappings usually receive a timestamped + event window selected by the runner for the current model step or chunk. + """ + + def validate( + self, + *, + user_schema: UserInputSchema | None = None, + model_schema: ModelInputSchema | None = None, + ) -> None: + """Fail early for obvious app, event-source, and model mismatches.""" + ... + + def map_initial_inputs( + self, + *, + user_inputs: UserInputs, + model_inputs: ModelInputs, + ) -> ModelInputs: + """Build initial model inputs before a session starts.""" + ... + + def map_step_inputs( + self, + *, + user_inputs: UserInputs, + model_inputs: ModelInputs, + request: StepRequest, + ) -> ModelInputs: + """Build model inputs for one session step from the current input window.""" + ... + + +class IdentityInputMapping: + """No-op mapper for fixed model-input or simple generation flows.""" + + def validate( + self, + *, + user_schema: UserInputSchema | None = None, + model_schema: ModelInputSchema | None = None, + ) -> None: + del user_schema, model_schema + + def map_initial_inputs( + self, + *, + user_inputs: UserInputs, + model_inputs: ModelInputs, + ) -> ModelInputs: + del user_inputs + return model_inputs + + def map_step_inputs( + self, + *, + user_inputs: UserInputs, + model_inputs: ModelInputs, + request: StepRequest, + ) -> ModelInputs: + del user_inputs, request + return model_inputs diff --git a/flashdreams/flashdreams/runtime/metrics.py b/flashdreams/flashdreams/runtime/metrics.py new file mode 100644 index 000000000..4286204f6 --- /dev/null +++ b/flashdreams/flashdreams/runtime/metrics.py @@ -0,0 +1,124 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Runtime metrics boundary for inference sessions.""" + +from __future__ import annotations + +import math +from collections.abc import Mapping +from dataclasses import dataclass, field +from typing import Any, Protocol, runtime_checkable + +from flashdreams.runtime._utils import freeze_mapping + + +@dataclass(frozen=True, kw_only=True, slots=True) +class RuntimeMetricSample: + """One runtime metric sample. + + Timing samples should use seconds as their canonical unit. + """ + + __hash__ = None + + name: str + value: float | int + unit: str = "s" + step_index: int | None = None + category: str = "runtime" + metadata: Mapping[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + if not self.name.strip(): + raise ValueError("RuntimeMetricSample.name must be non-empty.") + if isinstance(self.value, bool) or not isinstance(self.value, (int, float)): + raise TypeError("RuntimeMetricSample.value must be numeric.") + if not math.isfinite(float(self.value)): + raise ValueError("RuntimeMetricSample.value must be finite.") + if self.step_index is not None and self.step_index < 0: + raise ValueError("RuntimeMetricSample.step_index must be >= 0.") + if not self.unit.strip(): + raise ValueError("RuntimeMetricSample.unit must be non-empty.") + if self.category == "timing" and self.unit != "s": + raise ValueError("Timing metric samples must use unit='s'.") + object.__setattr__(self, "metadata", freeze_mapping(self.metadata)) + + +@runtime_checkable +class MetricsRecorder(Protocol): + """Collector for runtime metrics.""" + + def record(self, sample: RuntimeMetricSample) -> None: + """Record one metric sample.""" + ... + + def record_timing( + self, + name: str, + duration_s: float, + *, + step_index: int | None = None, + metadata: Mapping[str, Any] | None = None, + ) -> None: + """Record one timing sample in seconds.""" + ... + + def close(self) -> None: + """Finalize metric collection.""" + ... + + +@dataclass(slots=True) +class InMemoryMetricsRecorder: + """Simple metrics recorder useful for tests, smoke runs, and adapters.""" + + samples: list[RuntimeMetricSample] = field(default_factory=list) + closed: bool = False + + def record(self, sample: RuntimeMetricSample) -> None: + if self.closed: + raise RuntimeError("Cannot record metrics after close().") + self.samples.append(sample) + + def record_timing( + self, + name: str, + duration_s: float, + *, + step_index: int | None = None, + metadata: Mapping[str, Any] | None = None, + ) -> None: + self.record( + RuntimeMetricSample( + name=name, + value=duration_s, + unit="s", + step_index=step_index, + category="timing", + metadata={} if metadata is None else metadata, + ) + ) + + def close(self) -> None: + self.closed = True + + +class NullMetricsRecorder: + """Metrics recorder that intentionally drops all samples.""" + + def record(self, sample: RuntimeMetricSample) -> None: + del sample + + def record_timing( + self, + name: str, + duration_s: float, + *, + step_index: int | None = None, + metadata: Mapping[str, Any] | None = None, + ) -> None: + del name, duration_s, step_index, metadata + + def close(self) -> None: + return None diff --git a/flashdreams/flashdreams/runtime/output.py b/flashdreams/flashdreams/runtime/output.py new file mode 100644 index 000000000..aac341ee1 --- /dev/null +++ b/flashdreams/flashdreams/runtime/output.py @@ -0,0 +1,78 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Output target boundary for generated inference results.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass, field +from typing import Any, Protocol, runtime_checkable + +from flashdreams.runtime._utils import freeze_mapping +from flashdreams.runtime.types import StepResult + + +@dataclass(frozen=True, kw_only=True, slots=True) +class OutputArtifact: + """Artifact produced by an output target.""" + + __hash__ = None + + kind: str + uri: str + metadata: Mapping[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + if not self.kind.strip(): + raise ValueError("OutputArtifact.kind must be non-empty.") + if not self.uri.strip(): + raise ValueError("OutputArtifact.uri must be non-empty.") + object.__setattr__(self, "metadata", freeze_mapping(self.metadata)) + + +@runtime_checkable +class OutputTarget(Protocol): + """Consumes generated session outputs for presentation or persistence.""" + + def open(self) -> None: + """Prepare the target for a new run.""" + ... + + def write(self, result: StepResult) -> None: + """Consume one generated step result.""" + ... + + def close(self) -> Sequence[OutputArtifact]: + """Finalize and return any produced artifacts.""" + ... + + +@dataclass(slots=True) +class NullOutputTarget: + """Output target for headless runs and throughput measurements.""" + + store_results: bool = False + output_count: int = field(default=0, init=False) + results: list[StepResult] = field(default_factory=list, init=False) + _opened: bool = field(default=False, init=False, repr=False) + + @property + def closed(self) -> bool: + return not self._opened + + def open(self) -> None: + self._opened = True + self.output_count = 0 + self.results.clear() + + def write(self, result: StepResult) -> None: + if not self._opened: + raise RuntimeError("Cannot write to a closed output target.") + self.output_count += 1 + if self.store_results: + self.results.append(result) + + def close(self) -> Sequence[OutputArtifact]: + self._opened = False + return () diff --git a/flashdreams/flashdreams/runtime/types.py b/flashdreams/flashdreams/runtime/types.py new file mode 100644 index 000000000..52bf82166 --- /dev/null +++ b/flashdreams/flashdreams/runtime/types.py @@ -0,0 +1,56 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Plain data carriers shared by runtime protocols and adapters.""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass, field +from typing import Any + +from flashdreams.runtime._utils import freeze_mapping +from flashdreams.runtime.inputs import ModelInputSchema, TimeWindow + + +@dataclass(frozen=True, kw_only=True, slots=True) +class StepRequest: + """Model-session request for the next step's inputs. + + ``user_input_window`` lets a runner drain or slice timestamped user events for + the current step before invoking the selected ``InputMapping``. + """ + + __hash__ = None + + step_index: int + model_input_schema: ModelInputSchema | None = None + user_input_window: TimeWindow | None = None + metadata: Mapping[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + if self.step_index < 0: + raise ValueError("StepRequest.step_index must be >= 0.") + object.__setattr__(self, "metadata", freeze_mapping(self.metadata)) + + +@dataclass(frozen=True, kw_only=True, slots=True) +class StepResult: + """Generated output and metadata for one inference step.""" + + __hash__ = None + + step_index: int + output: Any = None + frame_count: int | None = None + output_window: TimeWindow | None = None + metadata: Mapping[str, Any] = field(default_factory=dict) + metrics: Mapping[str, float | int] = field(default_factory=dict) + + def __post_init__(self) -> None: + if self.step_index < 0: + raise ValueError("StepResult.step_index must be >= 0.") + if self.frame_count is not None and self.frame_count < 0: + raise ValueError("StepResult.frame_count must be >= 0.") + object.__setattr__(self, "metadata", freeze_mapping(self.metadata)) + object.__setattr__(self, "metrics", freeze_mapping(self.metrics)) diff --git a/flashdreams/tests/test_inference_runtime_api.py b/flashdreams/tests/test_inference_runtime_api.py new file mode 100644 index 000000000..1474383a0 --- /dev/null +++ b/flashdreams/tests/test_inference_runtime_api.py @@ -0,0 +1,492 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from dataclasses import fields +from typing import Any, cast + +import pytest + +from flashdreams.runtime import ( + IdentityInputMapping, + InferenceConfig, + InferenceRuntime, + InferenceSession, + InMemoryMetricsRecorder, + InputField, + InputMapping, + MetricsRecorder, + ModelAdapter, + ModelInputs, + ModelInputSchema, + NullOutputTarget, + OutputArtifact, + OutputTarget, + RuntimeMetricSample, + StepRequest, + StepResult, + TimeWindow, + UserInputEvent, + UserInputs, + UserInputSchema, +) + +pytestmark = pytest.mark.ci_cpu + + +def test_inference_config_keeps_runtime_settings_separate() -> None: + denied_app_fields = {"prompt", "output_dir", "browser_settings"} + config = InferenceConfig( + model_id="lingbot-world", + preset_id="fast-taehv", + backend="local", + precision="bf16", + compile=False, + runtime_options={"chunk_size": 3}, + ) + + assert config.model_id == "lingbot-world" + assert config.preset_id == "fast-taehv" + assert config.runtime_options["chunk_size"] == 3 + assert denied_app_fields.isdisjoint(field.name for field in fields(InferenceConfig)) + with pytest.raises(TypeError): + cast(Any, config.runtime_options)["chunk_size"] = 4 + + +def test_inference_config_rejects_empty_model_id() -> None: + with pytest.raises(ValueError, match="model_id"): + InferenceConfig(model_id=" ") + + +@pytest.mark.parametrize( + ("factory", "match"), + [ + (lambda: InputField(name=" "), "InputField.name"), + (lambda: TimeWindow(start_s=1.0, end_s=0.0), "end_s"), + (lambda: TimeWindow(start_s=-1.0, end_s=0.0), "non-negative"), + (lambda: TimeWindow(start_s=0.0, end_s=float("nan")), "finite"), + ( + lambda: UserInputEvent(timestamp_s=-1.0, event_type="keydown"), + "timestamp_s", + ), + (lambda: UserInputEvent(timestamp_s=0.0, event_type=" "), "event_type"), + (lambda: StepRequest(step_index=-1), "step_index"), + (lambda: StepResult(step_index=-1), "step_index"), + (lambda: StepResult(step_index=0, frame_count=-1), "frame_count"), + (lambda: RuntimeMetricSample(name=" ", value=1.0), "name"), + (lambda: RuntimeMetricSample(name="sample", value=float("nan")), "finite"), + (lambda: OutputArtifact(kind=" ", uri="artifact://demo"), "kind"), + (lambda: OutputArtifact(kind="mp4", uri=" "), "uri"), + ], +) +def test_runtime_envelopes_reject_invalid_values(factory: object, match: str) -> None: + with pytest.raises(ValueError, match=match): + cast(Any, factory)() + + +def test_runtime_metric_sample_rejects_bool_values() -> None: + with pytest.raises(TypeError, match="numeric"): + RuntimeMetricSample(name="sample", value=True) + + +def test_model_input_schema_validates_initial_and_step_payloads() -> None: + schema = ModelInputSchema( + initial_fields=( + InputField(name="prompt"), + InputField(name="first_frame"), + ), + step_fields=(InputField(name="camera_poses"),), + ) + inputs = ModelInputs(initial={"prompt": "drive", "first_frame": object()}) + + schema.require_initial(inputs) + assert schema.missing_step(inputs) == ("camera_poses",) + + with pytest.raises(ValueError, match="camera_poses"): + schema.require_step(inputs) + + +def test_user_inputs_filter_timestamped_event_windows() -> None: + inputs = UserInputs( + events=( + UserInputEvent( + timestamp_s=0.1, + event_type="keyboard.keydown", + payload={"key": "w"}, + ), + UserInputEvent( + timestamp_s=0.4, + event_type="keyboard.keyup", + payload={"key": "w"}, + ), + UserInputEvent(timestamp_s=0.8, event_type="reset"), + ) + ) + + windowed = inputs.window(TimeWindow(start_s=0.25, end_s=0.75)) + + assert [event.event_type for event in windowed.events] == ["keyboard.keyup"] + + +def test_user_inputs_require_sorted_events() -> None: + with pytest.raises(ValueError, match="non-decreasing"): + UserInputs( + events=( + UserInputEvent(timestamp_s=1.0, event_type="late"), + UserInputEvent(timestamp_s=0.5, event_type="early"), + ) + ) + + +def test_user_input_schema_declares_event_capabilities() -> None: + schema = UserInputSchema( + event_types=frozenset({"keyboard.keydown", "keyboard.keyup", "reset"}) + ) + + assert schema.supports_event_types(["keyboard.keydown", "reset"]) + assert not schema.supports_event_types(["prompt.update"]) + + +def test_user_input_schema_validates_required_snapshot_fields() -> None: + schema = UserInputSchema( + snapshot_fields=( + InputField(name="pressed_keys"), + InputField(name="prompt", required=False), + ) + ) + inputs = UserInputs(snapshot={"pressed_keys": frozenset({"w"})}) + + schema.require_snapshot(inputs) + assert schema.missing_snapshot(UserInputs()) == ("pressed_keys",) + + with pytest.raises(ValueError, match="pressed_keys"): + schema.require_snapshot(UserInputs()) + + +def test_identity_input_mapping_leaves_model_inputs_unchanged() -> None: + mapping = IdentityInputMapping() + model_inputs = ModelInputs(initial={"prompt": "fixed"}, step={"hdmap": object()}) + request = StepRequest(step_index=0) + + assert ( + mapping.map_initial_inputs( + user_inputs=UserInputs(), + model_inputs=model_inputs, + ) + is model_inputs + ) + assert ( + mapping.map_step_inputs( + user_inputs=UserInputs(), + model_inputs=model_inputs, + request=request, + ) + is model_inputs + ) + + +def test_null_output_target_counts_and_optionally_stores_results() -> None: + target = NullOutputTarget(store_results=True) + result = StepResult(step_index=0, output=b"frame") + + assert target.closed + with pytest.raises(RuntimeError, match="closed output target"): + target.write(result) + + target.open() + assert not target.closed + target.write(result) + artifacts = target.close() + + assert target.closed + assert artifacts == () + assert target.output_count == 1 + assert target.results == [result] + with pytest.raises(RuntimeError, match="closed output target"): + target.write(StepResult(step_index=1)) + + +def test_null_output_target_open_resets_per_run_state() -> None: + target = NullOutputTarget(store_results=True) + + target.open() + target.write(StepResult(step_index=0, output=b"first")) + target.close() + target.open() + + assert target.output_count == 0 + assert target.results == [] + target.write(StepResult(step_index=0, output=b"second")) + assert target.output_count == 1 + assert target.results == [StepResult(step_index=0, output=b"second")] + + +def test_in_memory_metrics_recorder_uses_seconds_for_timing() -> None: + recorder = InMemoryMetricsRecorder() + + recorder.record_timing("model_step", 0.125, step_index=2) + + assert len(recorder.samples) == 1 + sample = recorder.samples[0] + assert sample.name == "model_step" + assert sample.value == pytest.approx(0.125) + assert sample.unit == "s" + assert sample.category == "timing" + assert sample.step_index == 2 + + +def test_timing_metric_samples_must_use_seconds() -> None: + with pytest.raises(ValueError, match="unit='s'"): + RuntimeMetricSample( + name="model_step", + value=12.5, + unit="ms", + category="timing", + ) + + +def test_runtime_api_components_compose_for_sequential_session() -> None: + adapter = _FakeAdapter() + config = InferenceConfig(model_id="fake-model") + user_inputs = UserInputs( + events=( + UserInputEvent( + timestamp_s=0.25, + event_type="keyboard.keydown", + payload={"key": "w"}, + ), + ) + ) + model_inputs = ModelInputs(initial={"prompt": "drive forward"}) + output = NullOutputTarget(store_results=True) + metrics = InMemoryMetricsRecorder() + + adapter.validate_config(config) + mapping = adapter.default_input_mapping() + assert mapping is not None + _drive_two_step_session( + adapter=adapter, + config=config, + mapping=mapping, + user_inputs=user_inputs, + model_inputs=model_inputs, + output=output, + metrics=metrics, + ) + + assert output.output_count == 2 + assert [result.output for result in output.results] == ["chunk-0", "chunk-1"] + assert [result.frame_count for result in output.results] == [3, 3] + assert output.results[0].output_window == TimeWindow(start_s=0.0, end_s=0.5) + assert [sample.step_index for sample in metrics.samples] == [0, 1] + assert metrics.closed + + +def test_reference_loop_validates_mapping_before_runtime_creation() -> None: + mapping = _OrderCheckingMapping() + adapter = _OrderCheckingAdapter(mapping=mapping) + + _drive_two_step_session( + adapter=adapter, + config=InferenceConfig(model_id="fake-model"), + mapping=mapping, + user_inputs=UserInputs(), + model_inputs=ModelInputs(initial={"prompt": "drive forward"}), + output=NullOutputTarget(), + metrics=InMemoryMetricsRecorder(), + ) + + assert mapping.validated + assert adapter.created_runtime_after_validate + + +def test_reference_loop_closes_runtime_when_session_start_fails() -> None: + adapter = _FailingStartAdapter() + output = NullOutputTarget() + metrics = InMemoryMetricsRecorder() + + with pytest.raises(RuntimeError, match="start failed"): + _drive_two_step_session( + adapter=adapter, + config=InferenceConfig(model_id="fake-model"), + mapping=IdentityInputMapping(), + user_inputs=UserInputs(), + model_inputs=ModelInputs(initial={"prompt": "drive forward"}), + output=output, + metrics=metrics, + ) + + assert adapter.runtime is not None + assert adapter.runtime.closed + assert output.closed + assert metrics.closed + + +def _drive_two_step_session( + *, + adapter: ModelAdapter, + config: InferenceConfig, + mapping: InputMapping, + user_inputs: UserInputs, + model_inputs: ModelInputs, + output: OutputTarget, + metrics: MetricsRecorder, +) -> None: + mapping.validate( + user_schema=adapter.user_input_schema, + model_schema=adapter.model_input_schema, + ) + initial_inputs = mapping.map_initial_inputs( + user_inputs=user_inputs, + model_inputs=model_inputs, + ) + runtime = adapter.create_runtime(config) + session: InferenceSession | None = None + output_opened = False + try: + session = runtime.start_session(initial_inputs) + output.open() + output_opened = True + while (request := session.next_step_request()) is not None: + step_inputs = mapping.map_step_inputs( + user_inputs=( + user_inputs.window(request.user_input_window) + if request.user_input_window is not None + else user_inputs + ), + model_inputs=ModelInputs( + initial=initial_inputs.initial, + step={"chunk_index": request.step_index}, + ), + request=request, + ) + result = session.step(step_inputs) + output.write(result) + metrics.record_timing( + "model_step", + float(result.metrics["model_step_s"]), + step_index=result.step_index, + ) + finally: + if output_opened: + output.close() + if session is not None: + session.close() + runtime.close() + metrics.close() + + +class _FakeAdapter: + model_id = "fake-model" + model_input_schema = ModelInputSchema( + initial_fields=(InputField(name="prompt"),), + step_fields=(InputField(name="chunk_index"),), + ) + user_input_schema = UserInputSchema(event_types=frozenset({"keyboard.keydown"})) + + def default_input_mapping(self) -> InputMapping: + return IdentityInputMapping() + + def validate_config(self, config: InferenceConfig) -> None: + if config.model_id != self.model_id: + raise ValueError(f"Unsupported model_id={config.model_id!r}.") + + def create_runtime(self, config: InferenceConfig) -> InferenceRuntime: + self.validate_config(config) + return _FakeRuntime(model_input_schema=self.model_input_schema) + + +class _FakeRuntime: + def __init__(self, *, model_input_schema: ModelInputSchema) -> None: + self._model_input_schema = model_input_schema + self.closed = False + + def start_session(self, inputs: ModelInputs) -> InferenceSession: + self._model_input_schema.require_initial(inputs) + return _FakeSession(model_input_schema=self._model_input_schema) + + def close(self) -> None: + self.closed = True + + +class _FailingRuntime(_FakeRuntime): + def start_session(self, inputs: ModelInputs) -> InferenceSession: + del inputs + raise RuntimeError("start failed") + + +class _FakeSession: + def __init__(self, *, model_input_schema: ModelInputSchema) -> None: + self._model_input_schema = model_input_schema + self.step_index = 0 + self.closed = False + + def next_step_request(self) -> StepRequest | None: + if self.step_index >= 2: + return None + return StepRequest( + step_index=self.step_index, + model_input_schema=self._model_input_schema, + user_input_window=TimeWindow( + start_s=0.5 * self.step_index, + end_s=0.5 * (self.step_index + 1), + ), + ) + + def step(self, inputs: ModelInputs) -> StepResult: + self._model_input_schema.require_step(inputs) + result = StepResult( + step_index=self.step_index, + output=f"chunk-{self.step_index}", + frame_count=3, + output_window=TimeWindow( + start_s=0.5 * self.step_index, + end_s=0.5 * (self.step_index + 1), + ), + metrics={"model_step_s": 0.01}, + ) + self.step_index += 1 + return result + + def reset(self, inputs: ModelInputs | None = None) -> None: + del inputs + self.step_index = 0 + + def close(self) -> None: + self.closed = True + + +class _OrderCheckingMapping(IdentityInputMapping): + def __init__(self) -> None: + self.validated = False + + def validate( + self, + *, + user_schema: UserInputSchema | None = None, + model_schema: ModelInputSchema | None = None, + ) -> None: + super().validate(user_schema=user_schema, model_schema=model_schema) + self.validated = True + + +class _OrderCheckingAdapter(_FakeAdapter): + def __init__(self, *, mapping: _OrderCheckingMapping) -> None: + self._mapping = mapping + self.created_runtime_after_validate = False + + def create_runtime(self, config: InferenceConfig) -> InferenceRuntime: + self.validate_config(config) + self.created_runtime_after_validate = self._mapping.validated + return _FakeRuntime(model_input_schema=self.model_input_schema) + + +class _FailingStartAdapter(_FakeAdapter): + def __init__(self) -> None: + self.runtime: _FailingRuntime | None = None + + def create_runtime(self, config: InferenceConfig) -> InferenceRuntime: + self.validate_config(config) + self.runtime = _FailingRuntime(model_input_schema=self.model_input_schema) + return self.runtime From d0c4a4ca3fc576f766d5432adf86f8d2b33f3701 Mon Sep 17 00:00:00 2001 From: Fangjun Zhou Date: Tue, 4 Aug 2026 16:50:55 -0700 Subject: [PATCH 03/30] Add flashdreams.runtime documentation --- .../flashdreams-runtime-data-flow.png | Bin 0 -> 197049 bytes .../_static/diagrams/flashdreams-runtime.png | Bin 0 -> 101726 bytes .../developer_guides/flashdreams_runtime.rst | 307 ++++++++++++++++++ docs/source/developer_guides/index.rst | 1 + 4 files changed, 308 insertions(+) create mode 100644 docs/source/_static/diagrams/flashdreams-runtime-data-flow.png create mode 100644 docs/source/_static/diagrams/flashdreams-runtime.png create mode 100644 docs/source/developer_guides/flashdreams_runtime.rst diff --git a/docs/source/_static/diagrams/flashdreams-runtime-data-flow.png b/docs/source/_static/diagrams/flashdreams-runtime-data-flow.png new file mode 100644 index 0000000000000000000000000000000000000000..d94ac73573783e6223238aefab7e70791d601248 GIT binary patch literal 197049 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%0AS!bSEmf_*kjOp5MySmVz#lfVujvKYbq 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 literal 0 HcmV?d00001 diff --git a/docs/source/_static/diagrams/flashdreams-runtime.png b/docs/source/_static/diagrams/flashdreams-runtime.png new file mode 100644 index 0000000000000000000000000000000000000000..7178cc6af97deb4dc0e240605da38d4fde7b830f GIT binary patch literal 101726 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 z�xaCEJ0^R?DWl*xkYuqkju5k-aQ;iK;oR8EY<=b<&7W+w^vEK}?J~ZEfTvb*(mN z0&whW{LXX}h+>8YHw*=51g?=nAGa3FIq9hj@UT2}Z9KOeTTaS1Af8mwZ9A@c+TgjV z;&Su^?G%>B#*2g2R+O>6LR0M0rc<03-y8~a7HD7nJnJGd)-l%qWWHW?u09M(0C~OB z>uS6ztxX4${b5uem+uDkq?>TNt7`i%M%{!Jh4vo{Wj&{fa?KC68l7_yX372o`NMF; zW{d2nCCC9{Gw*l!Dz1F4`U3xhg=f~7iy_%4*rsiseyNI66>Ey~7d2D4py9gD&*S6P zX=7N(BOd$3%f(lp&rz6~on1%0I`8iFu9?QHG`rciHq523t-hFO z8DTLB3aAqP(YcnYYzO4G37I=PwC~8t4?Jha9kzvT!_Si{p_8JMZA4IX8f{-FPpP>* zS01%yyecU7L5o~!`8h`0OsnG5S}_7WGUK(Q)bsjX?(Dd5SIy5+dNrda>^##omb)_a z65L~T0S)To7P=0xA5eC;?DD{>_i@VPE@NUcqt+;zaxWQcOp@a>S-eoI*3Bm;UX)fm zaAasZuXG2(YNL)6n`GYnuxr_QWv}2Bvb9eatKY?(#9OJxjeNURYtJ!uuN1wQ%+da0 zKx}YXj>p+?bOrJm_`P=FrJYpsXrC1<7dS=^cFz#RCJyQ}G`lU*>f(0fFBQ;41)mA8 ztu&asa`|{C_6@3i(V_0d<<|{4H_-QWy;}Wj5$k-Yr6SS)W#c@%0Q)dsUA)&+`1|;& z14~y!(jmQrzmwJTtEY}$crG=U({;OIAosA^f>)|_yepqh{~?#M7NU{ShLzibOCm|3 zZF59bTsM86j86Yqt)^}75B?S4It}G>d%$^RgKcJ)&c0a}O{J6fonQaLf~npgfkGQM zQ0ZDQv8-77sx}UD;)8f4nL8M3Q?nrg3Vo~FR|s7MvqR`)Js|zqmF!

^S0JYkf@R z{v9mQx`_m2H1YQd)}mZ7T^$&Ty)9lV?XDqVy7`*Z?Ky8QOskLg7H!r0FD0Y6PPb@; zjI!=4Yi%O4e=_f~XM2$vqN2#^0V!BR+gG9`zgkX-PxmU@vi~|m1z_Z0f7DVA<*aJ@ zT%pQSlQq9I&aPtU1dhq4v#S?b%&5b42YX3)4dZ_PKGB)oC%g0Q#x6YDn>G2m!4

    !45v2stwEgp|aBPEt}?#FT)qcH|p0a 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 z ze7ieobd2v97@5GU$-Y;BNNUz4@vK)aFecydfYDbJ-+0vO$a^qll2>xJ)g zp{%`j_x&er)_&AF(BnOxpHA@vgYwEwHw#J)fvPbKml>=V2EDZgTk}uL*b$;pNDJEw zS^(ExaE812gFKoPG)+8li%_fRP>(~{dM_yzR=%p{BJY@OLreF4i%Ygx{feqrz;uc* zEvB}oH#KC-)1K8iGt(U}EOJRt<$k_v4k>hnWd*+F+a>H3b$w%I3?+JwUZbY^Vho|w zhjQDA3Wy}=-1+H81B$o2M};6sZp-n`P9K@+P7U33ezmrLacfxt9Z(na(X*`cyMT>h zsX;-b^97vnQKa*}Uh9zMrsAoAqTNA_Wiq*HH{-OqUFnT07CLrdKg0puV7@|>7cyIxKfx`a_<4!x@B;Lzjn`A5-O$GY#gGP zzZHRg*QcdD@7+Qv|6$b=sJLYlIRg=lG*PrsSBVMu=uJzh3?<-*4H*uNkpeS_E7Jo; zZY$S}&feOxO;9nH$gspL{8->`iLe`3krcG=MnMe2pWmp}I`^MTB>NJ{QgaSt!e0r$ z&k@~h)(B=6P-8hsy2NAGWh}$bRotWF6xZ;|oVd&WaTFT=%Q>X@hAvo;a302QAb($z z$FGvt@#-H_2-yZ>+f0kQIdA*UV6uk$m#?c^vyQ!IA$SFP1D^!Y=hzvxAE0aV0AiO0 z-=^=S`gMDEjW918&2m)Kz`4!hY*TY-_s(*v3dnws%tNV7SjM&N!tG|m zcQbwGvvouJlvmDw@*oKzDa@u>2&_T%QLFvF_2C&m(c{kHl%|t(2f4HC*%e5gx@NT6 z0$Bk*W()MmIRAdlCvL5?`zG(Q7^?`|qY3quR*+7P$|zG=+_etDb43bUe;e@Y3PK0> zxnoKjQ5#ovD7`*@MAiEWeNDc^r&`#;AVxQZUWosMbv){5%(^D`h_!%kCmS;gwY`ic z9x8KUx`>@r;+QEDLX;<%`iAx|?p@fII*JOsX4BBLxyOBa{Y0SY#%^a{XP>Thd>Bg! zUBM+Nv`r;~n@q1_yxk{IU04`=w~`P?(V#HUa4Oo-vq+~yHP#NV|!; zvy5ZOGlq1NGF={X!m}fCdyGcmw-My-0+PTLcV7Z-TEYHL8*xORBB;Bks#^^QaT(~H zDhO@mNZ@(8uCNSJg^X}!Uj9eA3exJCT+Bb_4e}h;so`l9&aBi)!U#j6ZM6$A7(_H1 z9wELse-QKNlq`R!9nj7q$6q)k>p9Tg=bhH=FgXY(yUJ|V!DC`B6w527PB5fO`+}rI zcOQMi?2{xPo4q%o&Hps-fm37xLmKaCUVKdLJ3f(RF@+R@A=W36{1ghAQTsHT02d$? z*wdByh+Cd{t)4OE}!@)9t|dRm98Y7E~=SW zpx;lcl+@s^w6!jN{`e<<{;TM47apgGDrl}11^yUaY9NS>tdRFJ# z693dgfKDKU>6`HLEi1Ftv!d+~H;U5yD;2i$p0q0F8C+5Y(M0ofb4Y9>m(hB_d+kUI zHrbX)1UzHFAOxRO_q8Ye#=6?OTNz)A9J(?MZ_ulE{^;imdiNGf8n3i4cAYjFRZ+0@ z+ut@`Hn5`+aSHoTL_BgJ`ktA=GclUGd^38qJ{Yx-%TZxxAJtS5G(_J;Xf$>xSL=TAjK$RoA$R z?;|gfn9#t{ucZ*(%8s?sgYoB4pO^M!oW+rBNntGZcYOO9&Pe`)^Flm+TFSjwu3%-wZsjf%bs(Q){#QnM>W`r~-c6kgI_duCFW z)OSV{^~)5xF-ZuAMf>9v|HgX3P|m~nq5T?nxAS2P(_*E!FL_I0$2Q?nycT(pgTctj zJVF}fxQV!=Ha|g$e8{V>pq|v-^bG+&FwWoG`6G305&d-!+{Y$6A%BWUm9(2l1Q4!KliC|u2F>I`tN{aa?$5Zq3$jKhy zC-Ecsu)YK({mADRb=!}Tc3y!B0gY3GJPUS_-}h2yH@Q#A^= zL%Q*L(sZ8z?R0{#5L%HgLrDa5M~73Ahb3R)+c6eRlmyu$+Dnty)wm95ww1>fw#l{! zA#_V!x+hGvyLDmCbBw4;t{XjTl03makeq# zV6l%$mSCMii}KLEIEBy>Y~zpU8gi7OF#IVDBld?O@y3py=Kd?9%pOi0=8{FJVdg?0HfNZ2Z%GR2sa$Gf5^$f;s9zXl(s@)Ih2|oqZr}w$z_G!;|3)Rm;OgV>^&{Kw81z!FMt~pbfSr1l@nnPkJi$ET%<>60tC>LZO`0T6L<2gk zZ!mmYLWbk#51(LkCTxqD;ass9-;U_3Ug`Bo5mH9&OGN9u8@dl@l(J&Nqj>dbr}(S* zd!zY7HB9j*D_DoF3lmjXL=p1Uy11zsf49=rdtWQbC@+9|G0rk^olJ#`Z!^4)a7ZTu zU+JfZRw#To^)R!RC!Q;CoM?6bA}K>(;JjxB|E!+ftz9}V>YKcuwC@?S;DJ_VZ=Zl_ ztt=rvV`MY90#xERqMMiLL&m!@D-l>w(#XC2EjLL1c6YQUJhy^BJFZcKlZ&ONkHdmH zB6!rro>p5x_d(-i*3!Ux^$(T?)0r;cv74Identb~33jtLboQq}ca=@+njU>2{*&TM zk=DiJTr`GiiJ5m}?$tc~lK9{iXJ%}#rXq1qYtND*A8`c2E`xt7gNo$pFv<0*KBSu& zOqq&qbf(DLVSqX04FNZ4Cd~pDOZ4eIo!PSue_P(0)E@%~PWpp%@fl1Gl@{iDWm4I< zwJT3j?qkOSI_NB)F{KyXjPfVN!F!snKSV^+l_517jho|SD-qRK)KCi!7K#*L{*Sof zE$?)F^}bjduE%>-Ab~J3NHzSl*K!v}_lVajJYpxFD+bZwzwbnhRs1@#%9QbtG?IJs zzQU~*!&#{d7Vx~bP3;a&?O9_nKu`rUG$A1M9ZZ(NigwZ6dm+ zu09z>fd?P0?QQkE0s4 zq~1$+=V83jV1#e46EYYY(Ppf-?^M#X)$8r6N~biHB+%H=OfrUGR^as9ZCi5=>Wtrx zKS)4cnsJZLO$x}9-0YV-yx04!UlS{@tbcY+^jPfXBjaF}lIJL@bP2@l745!Q$C1F| z)&*lH_u^-S_t%vxB26OUx|yw7)KQsAca!puve%h@athDVwYr=LmgqaAQ%bRnhfa@w z-_|Spc}BSl&t`XHdh^m!o(Uh|9-^M0J4xigM8}f`)Xa;`)|}_|@EUv6#`h0%SRujR z1J9=3MV>^v4gGODY0b65i&wXLr|x~F z)#3f76`$uxRh2$N$Cc^uM$}M>(1-7XEZdSVoV_wR2PB0M=RNS8R#=H;1(%{v~8X5FEpfx4%3xowK4n@am6^_fs&as(}`Z! zSH8jiw!7}A&S<1DKZoAV`Dp$FO5|gYuUvh~eNpQ-rc%UQPdUnWnaS zq;O!PB2?gZ>tb6xZ?(KyHvU|;;T1RT?w)mSI?Et;o^dAyw-1&@a~aPEuT?t7*XD4?_q7jeI3S{asDSAWU$m7(&l@A7PRC}kXIZC&&&vup5ExabTDKgw|j#@$~fhiW( z@u^qJvv^tp3JU?nEc|y;J{X=X1lMx4S_# z3|ZVuROOojR-#>H0{BC1MZKss%c*tO%S{hEXP#V%P$TJ#F6sMoS3M+pOFzQvm(OA% zPUE5`vB4qr#J?W?R zP=Pd$j<+#rd+KrJw(}jmi`}AkL?T~1((L4*JHxw5-C^XqS0rDEJ{=ss?)urkhmrpW zb7yvb*XARRE^R0K)^1E~W_a%5qXNljBSbg&@~46@p<}(+51;Dx9(~wOdP~Dko%ns| zcnJ>5I0f@eb>6`W4_JGyD{>|3kYQhl&SGExQyyRXkr2Xe)9)UqY_4#n^}EiYmPX9; zAGKlE|D6Sh-}fRw8`+@ z$l@z+T{_=)jt``PG^$c7^Q5n%wmk&O2EOuyZ>@_|pJ{5HeAY2|wDxtEfexK1igCo{ z2p6ILjaYAZRL8{cS=MnK>b9hH=xQ`Kq9r6LwP)-#B@Wp>akM;(|0kQv{rzjb@(Fsa z2+1%nuxWaWcPRy@SW-EPiFT7OkQmai89d@9(;O_WOu@WG@V1t0Z9l3tsJ#GYitLcW zL)zTpPNSJ`dfN*Hl7)uX2hG^b=)buNu}<;51M`{9F`kaxG!8QP?<$IfRKJY8A4&R{ zu?-pfDkNOq>rnaLteEUNI8+()s$GRs`lOi-oI9OGIDH8?S;5CbU7FLRFq*7fnjPua z6?2ainalm@c6#cuXrrx-{?q`lEN^ITFedvQv!CBM*E>rIcp){VB`=@M1v?d%54=RK zi7NOkM6uPw-i*R#k_|(IEZx>*D%9p_NuwE&O=bQSh!EcQD)*bA}v~YetK)KFR86khj%@?g3|6*P1EqugS9L22* zvs#VYW9atJ#c`4*HXqYIursLPu6?mfK$pWSK6OwptzhWX(#k>%h`2{U)@Qub(FEFHqu8&!k;=qFw=j=49C#I z5*DFqEb=GV!>n&Ob3}hCgMPG;RCk6>Z%}WY+%Rk}*jBT>ZYWM z97_B{yMg^KAT}5o@{Q~F-qhaHSNF1F+>(54F1J3HGOS_C<4ZPa>}YYP$SIBr`A(|) zHT|{X7KO=vXxjjZVvgODjX4njAwKURyyahOO{ntB2A`lLoJ7czbx+c<lp zp{Dpc4`PP;`plxSs8r#mFLyg&`o+?xz1JEUCKDXIdi)j;(py4)9LRf0mDV%-!q_Do zq2L+Qx9|(Rzr#+_)%gC*0pOBTzkGBX!tQTCGRLlivgDmM|1b0xEJkqKjJACS<=t|1 zi)cce{|3wyDLOyyPyKsW#UF&AcU4SFGcPasFwuQq+ZV;2vwnV~_DOlmHDtQ_K1moe z(_^|FT4z@+aKwrLjZ`+lHY&4aB-u2Odtz#Yq&caL~Y z9)&3$3~esfHLh)z`5$CTpXDax&a4>0Ym$_~ZIV6507uc78PYf#E#W#gB@t%1Ub&lv zUUNkF@9ZfI&nMpsFM+VFJ;@>?c~#&$D;Itwwhg^Jw!7C6gEOe*qoY@a0tu6KNoK<^duQd_NKcR@zY1@dm;G=PZYa`c~ zqe`|LSvRaflXl;2ErnOt)$#sxB^!Vg`pT)}*v?=P=RE^2{PuqxC2&dfSVb|XBVL^WzZ^kD zj-Ts)E5q^>{6#Wd3lj(nE3ao2dqqmmK|hkrc|eo4n>sr?K~rywKX9^w2}x0I>4FK* z4l?*9&7zeZP9Nf;#exWVNV~hG`!$V0&u z-}%^R;SUsRWrG_FRHb#M)9c7irTuheQ*pP)+ow823<-O& zo+VxsAW6%#_j1#J(lEZ@2>p)hKB_lm}`QJT#f&)cz`K@SBybu?24m z!hiSv$FQ)nDNIs>_pMFxjnkpjziMN@rJyY_? z$cE=L;B9klTh99WSMD7q*5L5h@B7hMys0#>DXlqF%a?uZNwvSGw81tL&3k!x0NiyW z&vz-lvY*SQyBqy zr8+8~ApAUud`GLjxOH?UNs1@?lJ%9|P*_~D>Rp2z|6hYk_Q)J_De+%&y3Q=fPf07g zoh8qIV$)_T&Ccp-+5c$&AFuWOF#Y*mra?TkM1zECb%N)a!ZFQ1yST(Fr77N}DSxjJ zP%?hY^PU)cX(|~djfIGu6$T2k1}w)7J$7De(sD4Z%~B1sOPez;A@fjC-5UKQw5WG# zkwph87z$TsiM)j~+Vs2S=h=38aLJc6!uXs+J%-4zpN*xtf3t|h5_*=$^gFlIq>bXK zF~R<{FWAjP)Jr4qMu^@L;?*XVSeVd8Q0PSf4Ck{quWm(#vkVyali|nNWn`xL5Jytb zW}6o;095PUvmaJgeCir0TgJ0QSM}#61^Ir=Oh&&_-R-{F>iNHlCpR_ew{YWutB3eV&M?tI4=q6YVo!)lU+X;t$X!Axu)Nt(9G1$bxpQxArG7 z*n`!f<`n}0cOCV=y4YCnPg3%4)@LSNof8Y`v!jiAbXl&xKI<2@9fP#HAK2eCd`RZ$3p-)PiYAKKuKtn z6trjKm)_Q~njo@TA#Cxibm3I^*S0!z7JEfIn_m^XybZG1z8IyWa{9mf?*tGTTx&DD z?9-EfeOJ=*HHO)mH6~)NFc#4#t2SmOZw#mHiU^G>Dx$pQWJj)lm#^6cg`JPa*%|E4 z>SDm2CQ0Cs zKDA$(z1yOA_iJ8J`7tE>DRsqI%F@Ul7}@6gV_MMhc52@FS6vyDh`R~0{>{QQpC*D< zC}00)Ex4q*cUq9U)25f`*O9=|%+9TT&`pI{V6ze|$KTGY*v>NxW z?i+q`_OT_x{Mii~1O7&W_^do<(`1BeWKiU>ritQ)h)OFoYO_6NS&mmT3(0z=tVh-# z+{zo-=3%qG|K`hT=j@NfLxuyJ8!n7|Oid4#eo{sI8z}58%sSAz^;amI_VaRN$49hq z&3yE^G+1#}#A?#Tb`G9-A#+)h{3u%QUX2dugn~`>US;@uQz$K$!RndsM+0eTM^bt3 zW8QCd=9$av4i@*}4|4(%h3R-h%}niG+ImmMdFc^(N)+y9laJE17(Lh}u7kUwKCPL^ zS_iz{4qlV>DR37wI~G+Mp~p|i?eQ>vd(|)P;F`zCLrs$zG~sD#kycthTq5F?#{BG% z-GLJ^4R#`R79ko!{0%-u?9o{3RiMDf^)0=`>$inq20V-zr}-phryrO{dtzO<(p(G% zTE6TDs9tPS%aFGa#A7_y{tqK!IC?pcgC>lK!KK6USQM=3Bdj zcg|IspQ=a0OH8%ME2J3Cdy|I58!#rsq`b>4PZR|0BkuPsR^;(^M^8hNX{pFTb3=I_ zo8X3V>u)!tCHno-yhK8cLKah+zmYYtZt3c2gs|Orgr!u#w!|;XJ&>rkowxV0mFQ@& zWL^9--&beQi0Yq6b>vrj&>UyWD$rY6~xXjqsT1UnYDDSzH+rQM#>B5@TmRJ0E+tfn#>uEucp2yvM%Ov* zX70V{z(bOa3O%5Fc#OFr82qQvS*EWW(;7k%i^)H`UPZ2Tlp8#*>D}63Fs;gX3MPQS zc?dKO!)o&*82aEW;%}L&_BDk$&pFX``z7n!n0zoqvR5VQG<2-Z7?M_3Dz7g4=Vu(( zo7yFw7Jj0&S!XF%xB$mo>^VUZzYi3^|+Nv<#NDWb(k`=H4Gl9$v zS9W}AxYTavN61IFP^bB-lH3DW(uN#|XhIP^)4sQ}aOWP%xPJfdT0{LxcjU6!-^=Z5 z)ohMD-fiD!%^aFVDAaI3$mb+=rp)_LqF!q-S|K}5gPAhI+87!8 z@akIWUS<=RX>h0SlIXy!novX&2uOZcNk|f4CY13VqLNi6ZxQ7)+-s?0s?l8*C`hY` zFtJkIWCwJwSJNz*{REV6?V#dY0s&SfEx^cdKN zb!p9qze092g$5`NmmVd!xDOR3a^G;~|vpX$+?c2fD-V{_s>V^5mCDHG39|=sUNx;Z|p);w$|t4 z$EZ>0kejnq_M@#`-Xya}kpUT~@C*4zu`ZGwmrFi2l^mRCIrroI()ilrX7wyX6gPm3vhLcNHZ-q@T!mEkzIwjkbcZ!uZY?wys;CahLeC zUODJnLTGo3|5NuDw((~1U)|+U8uSQTREI#Y?tJfzsH*p}Lj6ngswlfqX9?N1h4AOw6oBx20;axKdptDjLe6&xxqzuI9%sjqgeMU(-J0pqnaUsKoO#dB3<|0D7&YcwVdq;bM3wOuc2AVWf zSM4`c!A+@*BEH?G5*ChQfTso`FmYBf=Ocg5`<#Q|(dX1M8FUWn?!zV2maCQ6PmxUueU3oFHccvWNlE8GxWa)$9QoOvy) zl=5TTh~tuE`0yxac|}ER^Q+c5q&D=VL%>|eWf>3hHW72GwGUXfqkOlx?eBYc%p&0$ zKMhuM0}@dg+fEsFE}tDEBafbP67^Z|$Q z+GUy;$zq2P^)C^)KFfSrK1HK`j1g>2l=pCh3AaM4FDfODBkblp9oAq+3J%#fF$)-) zPL#BT8T9a{8$|k&h9**tYVh%7w6#s89TX&uRB3Bw!89EFcr#4(E|(*#il`ZVsOe5%AYz)D<` zx=wmeM&6UmS5NdQjs{ZY?cAL#TrLU-qc(RucX_m&Z&1d4RvdFftc?}7V>c>(dZToe zlbUAt^>ca0TcAI;%TSnl#H22YPqQkv__$`6`{|GdPM44UjQ6f%S#k|PyQ{eM%g$VK zGRjDpxrI|{xtdG|=2LX-9YhdI?lP-R8A*MvYPI8!eYbaJMeBL1buF&VW&gYy8`&sZ z=;y48?bysCs&6v*fo4}~?W;?2PH@7g+v>v$FSMtOTQ&DZT8?%54Fxgl(^fuICb56t zB3wErb95nW>>W+I__&~|5OOTx?yPd060yb=ApW10w$surs{9G}^-27*UHXS9i`|VU zgvY0k@Cue6DS$;+>PkFH2xAd3yKvz=QLUU){*09Z^ic`2EgRA|tRx!n@h{`p$bELp zK1w??cyanC_tRnXL`{Au&FHNbH(q-Pl!q4#IP%#a4YK%AKI}}=Dg~Jh$5VJk@_Jph zY;=1i6-%Zh%TosV>$85jECt+)&Z3ASH&Mj)O1tCcjprJ3_*xwtGl*0inIGZ)#Wew( ztaG*3_d~l9s{GLAu#Af(Bf+Codk?JZlh-@yBa%M3<$~7Uq&@RdQ+Re8Uzk4(8}wr> z;m2}4wr{ylUr80i5yB;P39Xx;nCHXmFL0F0$Utyd9GE>Hc!*1)vkuYl$`*?@pWc>| zM8zDW`r;$9b-dRq`2?R|}A&g|~ld#}webx`SFcAjk=f#$?wMMu@w z6cKOGGSz`j#f@ut?kFF$=ijI$(~Q5^jL)Wflfm#_{NT?t21@yS0aHrN*R@aYT0G{U z71xvh>;q1B8N8UhhzS4N_r*VIt8!aYUudu*L(`Dw(~8=`90b%j386C){sC*xFHS>s zkO`jDL(xeyr8?R0O(-R*X;v#$s`3R)MIJZ?D3G3 z0)-DWo}IgL6P)ET%Oeg>BJg4VEepIZviw}w^Y$fed^wx^TbkObFUW}RG6<_giNrp= z^s=M+#nVzU3MV#rqe%0Kp#^COA!DtEfQi8yD-YRY#9a-o8Xy^dQ)9(X&1w4O^0PlE zm81886v4U&YF8$)iKYaW@*}R;#S>GSuGqrw_RYLy)b*u{!^>&NM~-d7velSG2sm!i3Iny_S|0tNz1HDwLY>?+lJ%CB1bSD1h`By!=wlofe z^!1ZBL_Xp;)9?Me)gGgP5>ccUpy<%QC4j725@-s)exavHzu^m%&g=gQXT5r2nCzH= z$fHA{fCpyEQl1gU0k$3iGti%s$yazAEUS(Kog)k6&ieq^9mO!U|I~W5b{csv5uzE$ z152jf41Mugg+)yOEK8{e;)NLClOPcS;2nQFnI4P*`z0|ppoVe$EB5q}w!Gu3HRILv zu?HG10}}K%`~m&UkSZG4d0NS-5&{&Ce-+^HC)Lk$fJoAICy0a*u=$ocPTGWpb{E=@ z=j~Kg73cqzBDNFdz`@D!Sq$Sn_S^>f2;Toff&AK)Z}v`WB>mjeZx?_!&WpL#Ze2f@yVe9MBC?0<}fJ@4!yT)q|r0h{|+) z2Y|e0aT?9vlR^#&^LpKY-_2j;Xr1lO`|(h5h)B2sy170Ufvi_|$dx2;?|}et+<&hG zNR4({-nNTD0<)DQnK5^E9|dTIFSh-C_M7l0X#hc<@Va~u#s6#@Yt1`sjt zOu!SuV9t^e=Srv4=Z~Cj_LQ1b=n{UGCgmo(RDHXoZiywI)>o5tzeJT>!DAd(5C8!6fFHmLPKM&?26Z&_ z9gF9zV9SSx*!)I69jnr?aQsIxt!K*&WbX0issH=0%6z9ov{zdQ@H~z{(n|tNg=}iW z1;LA=qO1D*qNrP#kcs0W!R)T8avWHQZw3VNg;tIzV25xL*ru7D4w@`~gUl}w!T<;6 z2@vGNdXoVI2FS~!zzTZ^oM8d0j2RMc?tnMj{Fnm5bU>bJ5W_0=9~leOJA+)tdLcWh zGr+QpgW@xUb6Ei&eNcpC)mJ}=LWgF1yZFSjcJuJ**EW6#+mfs*5j3QKyJSQLcxM4S z1meE?FQIMv0LkLkSMvyB0sal;7?wW%<}EDX|CGG%xHaWWal6BPP;|?K8FN^{i2Zjpw+$V=VeuKTeXRO%0So= z)&pd~tN;d8(hm=r9kFAa2FIG9g5g&5=M+xdGBNr?m1rwasKx>Gn#7s-xv_owFN?a% zdsw?UU^%1{2g#oo6ZvwiU|v9ygJyA5^d?lmwOT^bpB?a~74N{Y-y85(0NlAZOUSH; zQgM^o4*bqI;9w&K5hYe;-AeACQjUVPK_6k@%hn3O2dW41A63=YvMr7PV7%u0{hg5G zM#GlKt7g*>BFz(l14sNfY%`+}0q0nFa~INb*Mnae2f=POf!gg8aocw>A4JK0RPPF4 zj+tSm-*zyZoB~i)dknAqc4pEO)z&cXVi$DGkD9 z@Pxprf<^j^ADHeuP`3hTyk1B|Ck3XA#*%fThpR~<<$x?xa9Q0vl9@q@qU@_@wP#taf>IY8Ku{)y?OK^S7tNd}hliIr=%#j4vZKPy`5n=XXsqy&KElrn2gxw_CT&t4!mQPd`kb|CSk)wuk+ zU|UTov5LX*l133^P4~)w=Y2ri8hgVzHEM$K_<2>Nlf)JvU!F0kV26xjA$c{r_!B3T zHLV?WqLY0nfM?hDyc1K#IfOfj?-dPxO}l*w{AK`v3ta&b-461`>VW`9o#c4(CFOPR zbmmzJZ-h{yX+s+TD*;gJLJh*;;E^vuo5yA;Kj!y~)A&?4_yccK$~(8R`mrKpErcf5JR_&b46Zb6<9P$BwPcagC9S0-vW&+ z=Dpp;HrGvD?Hwi+I@MnJm=?gqD@~GQSrOU))!L+zdl5DdK-nM%OzB|cOjufLg@mU} z$3N9=$0ND4F!Nc^iZ-VX5>^!@5p%eEAz;8|3E5RJJ=eXSFvyp2GOCxm0#odHtmBFvod zTKwCpCyA28ri$`Z8kIn`6ZWHWAOX<`dUsnde*FP{D)ccr@t$UUs(mi;AE@PAxAm>5 znRpn>esQfXXdbH&UMGnw{R!y8QMiJWtO`+wKgsdT8Ss{5zRG&)wT(|MN*&6R$bTVB zOUpLoTDC_6D%{@Tlg@N81lapzDcU#)YnW5mhb@DkMAI}j{8+4cLc=6&*y_U0fauQ^ z#dmf;zXUL0UmATaILFm4mP>z~)axP_4PQNv@UPz?Q~f9tTb1i9TCXmj@<35+AFnuy zCsj%MOc|7uWN5%HIJ8OYJtC=-K{>hg2I!10c~)m2cFb%9RX+#4L7>D|uw z=Rpa5syA=BBu+%#@HJ0-{`3xQJ1;fj4CF_}u6WxZVPSuH0e0aznVkf0_XvdQtOs>Q zOA3KSXmxGe1-{PvSMV7K>&)}YjZHaiAAQnl$go=l2#Ay~JJjP`5Ag`4Nn9K8^9jqC z#)=q@xB{)g`MTD!67*A1c+fP4%2bhBP7KgzWmFH?f_wo*$XoMvbHuCOsp(CGIYR?r z{2wu+MKwI;a$|lw*#wR3)AX}1ZIYUrx%}zIho)%G;Zp)yd6YNQr7gscKa9u@NI&D)H!-% zs&e30QxV~-py3&;b-Os@M!l5)Ilcws@N#9T<&y1sor0~;*ztt-^5bYvR${Q7#w@aeVfmdK+$ukX*k0y2n0g{1T{c-^d9E)ZSRX+jvn>_D=SI zreyrq6GIqFU%j5o$eRTq`8MgwVzM{6$n0oLd&Cv4=k&^dOYOn1JPlZxuNA+75pD7$ zPdwx_Z2st&w9-)f8Ut3C9gusk$dq6WaKWv=zd;=X$a(wR_R*!ZJ}rUbKqoJ^x*c}& z==Iz0f4qP&SdwR31~pD|VW4?oBkHKg(IEk;BfDYVF?dIwf*yuUn?P* zQv#+_a8HnHqJe4iat|GA!ypq#ypA*)k4l0++WDOx5^wm~k`Svj~5fP0fZW zi>$=*M>4I(n_`sDASKV)JOZLBletr4OlOnDDj|D!K??Z6OZ$c6JH@;=uz-U+apS2# zMtJV8X07_{Kw7Kj-{mlF(XU9Y6dYzLL7cl7E%6_>DMRzf-35GJ79$5><1C~{f}I;HF^7c155_U96}W6S&kJ}lx; z#6e^0U*i{T-COs@SY?%e*A~$B|WcU{R>HWwpR<1^@d3=JNnza zWRXyw;_X}1R|sKztiVu#Q_L4A9V0VrUb zdN3RQf~)lXubP)VKp6i;!A`UFkdf4prpE5!5Z?+w{&-k?HRPH+Map$ZX{$w9CE7B( z4}fAwYA>_D!he06fgTYI_Q@)*<_$1J>Tl&3!*CbAdyU$B3#56r+xi^QH_cV1TtUM=Tv~1HfZA&o$Zd znKB?hxfQ^txwn4W-#$$6#l+iV0X5g8}~O$vfAYYSW~|lz@LVGfxO&O zMCm@Lf4$#*Oz zO&kjal!QKZ0;qzl2d}ix8S!^{n?#nsTd%_FK+6s4MR|u(Ry7)AyEAgRUTlM$kN@VN zT5aZSxlF5!DgVW3gFw3d*ne23**M@-@IM%ys^h?^loc}76fCv{`LfMGb;S|b`5{SG zgb_$cZ7w)Jm~d^@2@7Tr^yrrww;tj@6Z>M!9b9ZTb_$_Yy+@J+`MPB{;|r;uuE$7N zX9zym?Ft5UuU7gcd^1g5>lG=6Fq#mUU4&h9y;txVMBqz!NZs+mAHbY?(SB(R6KtTk zGpWCkt*F^=jZtYS*QO_kqTewBxN@?}cf|V}pdrDKI>ltpzdpVh`u_avWUc*~J&n&> zXHcjlZz%&-{q3(MYI*yhVfmWxg2MMKvmWTk6qGKz>>Sy#X6JV){v|ZdJbi9blomEM z4p9+mCjd<0C^I3z%g|WWrKB8U!m`+blvpALn6WE|00>TM2X8lizWEy=VE=~drHN~d z$1lmcWlNx8PFM}W9UvFkwT5+7uGR^>2L3~4$9u;{Ee?R+w8$`ySTc4QI0P#Vf`HVY zcMZeBFZ(=yX<93)ihQ-u-k*E9Ev_Jkx(zy{W#bUO9w|C31o{)NUHvb%bp>!uk8u#x zT;T*$w&-nZJRe&BJ&Ik?VeJO~t13Hbpuc>YNBjw>dQJc#7&;0SE8$)BXXcP5?&=8zu|Ux*gX{SAnt{@4w zzVL8>JwWr*HYjV31r2`qX=OF+H9fR-)vRkj{^O4_g2y+IJ3q^9-*ol$HT#4jek^uM zy)%h2r+Z-^$iXatW;b_1#!0>&0dQGS9(nDXV;XEh-2Vgf_;iT1MvY>7#$lZEk-(&DT= z=8m194^}Qur>gn*&^(*neF_4ihE78!_abAB6YG#!vugY`I#Hm+H(?J2{Y1oh>bQ@N zoFY~G9n0NB%!(+6ps#AS<#4q%F+{wcI%W;B&t^0G+s9$>#}=23Q?1?(FyY_e})BY`Kt*zZcNLZVd zL&{-DRI*!gmLid3N+U5Qtiw1>+EQ96AqJUuD@sdP=ZwRms^WK<;b z3rm#x#U!!F_1iA&#uuzk)6v5Z(|7K+IZlL9r`pp+azCCsO$8vDSOd@jou*L&-~>{u z6_J=gip|Up;DAKAJF8_jQ9(*p1|Y2x8l!U~Pho~+6uH*mjF(si0!`II2Ugd#S0#f& z-^~r&06H*mYY=!FYAL*571d9*0;b7`1f?%|1P}Tgy71&1SZ$rEdDa0S2YelK0^;Mjrx;oJEnnaXjShqE!ehu*mf2ydlC8yBa~<&A55!Ju6bMsk0fh>ia3qNi zpXT>UYGMl%bnbnj4F;+s~+Q*!JgyAAnUK#EyPbLVOBiv)Mi z!uP^42sC3}S1w^*wxt|bQgZ>K^~t(HaVsmuCF1OkYHHQ`ScMbNslg>FbsmhK91Wd9 zWI}S2(jo&Whxtr&mZyx(Q?)fP82Rl}EnJZA2R;{~4`K`dLyt{?WN%qOHg5*utRNtJ ze&DT%aO@sm5X^j}50cwDtV_oJe|T8`a&%iWt%rs!NtlzT%3MJwx1Z0@Ao_u(iWzFtoIod^zcxj&g(YoMMf%OlQ;a^Dj z{~z1{M8g>*931F9SR2*W_q}jz^0HaS;!FN-$NgjUIgixO_v}I}E%Nh$uVRHgzFB5^ zKT=b?iM~AiJRN9{ElA#ak(c~*zJn0a*C6*O%?*E@o)`(m96Y*LeVKmd(E*QyQbao#>UYmR!!C+(ue6^K7g$%q6R7^+9!?sz7^Sya$y2Jn4ZZl|bFW+`h)6 zNqHpV66x*AZv&6$-%xx1fyYXM>Nubg)vy|^4;cpo$km{0zv0j7)!%TOKx%0SnLbRR z8LNaehl9#R6%Tl|`Zu2G{}5akGh7YsRyhkSJRRn{yYL-OL616U%ha8mD^q`WOC}azUY7d;}!FOY$ zf<>}ce2;o7%BL0fwlTqaT*rAH68*OOhN@O#pB7MTlm26c25YI}V$S{lZ3&2&fCib5 zZwP|7M!!fp{%v1n(Zb&A2^VCIZzu_E+RPT@te4l% zoZF}nx8B3dtwL(JF+5k;)T;U}DV)g6K3mIPEYxZAw-hy7Cz8lrv1(jY-EjJrn{fODJUe zO)PPhx#7!H@4kw#016ZpIo9uY(psHT<+);|c^mj~Cuchpw|qqeudC(^i11)w@|t9-@vWha33)@3EcO3I@^}(&i+N37fJ}EfHH zyYORP@i~>msc&Tg^0OVBEpf`lBwIZaNAlOSCjVKmKhz?J&wH@OE4eq|TBVknb=z}B zb>W;*2JFtgw7ohGHsCOI@UeG4_qU}-_DqyV0m zH3xaLS*b-9^F($Z^c<{4?&$(|(SXMt$9?TNxra0z3JT%PRDG^vgV9cU(urhbVWA7w zD~)&@y)DgdsLsBUx=OvkUw>7^JA84ywC$wDn;>nTD0uO6H5VlYBO=+qyqL*NN zMk!t2a!#%ZW8vxHO)rc{!I+t6l#%-A5S6^Nu06zQ!uQyU#%M<2l*1cp9lH$MoDAb> z9)e+EvC=Klwpe2Ller!6SQy`sMiuj&@3+P2jg$Nj&?$AgSN;0jpE3Hx606?BsA?g1 z0sMDooF za^BJ+pKr7|T65I`IOBa2$yJ_%! zYDOvKr)G1;4#nQt3J)WJ_J3Fke)Fb;%;5s}x)_;e@E5UZFx$r137V!mcEv>``Vq^a zVk6(6SKG(&8O>f#BXg{`yo|*;$4%TPHOpr`1=U~{Q%@yL+CLiseC#=@1#3I9KUI>6 zO}rF~5@)NozCdK`Acwv)@v9b6jPJYAP^3tEp7-Fbh(=FR25BsvAG4G%235qn1(E%*W-N? zy9ihHF2OepmGNb@tY;l@uSBRnAX_JBWR&csU&jQ%R`BK&1#EwsfeLbf{`w432K!2i zya6GDqsr3qr{I?K12+qwoYA|L_KNXfs39$fP-w(%-0+az_l9y*N)}`fZqp&dRfdsx zujITg5OkLld=u^UItdh*S8VC~X>ay$A?2qbP5kuGJ5M4)W(O{sm73w4G;$W^V5?M4 z0KY#N7hG>sOidwSOII-Lp`nc~c1lm(gC3X8@EFRi*)k_N!M9M}q%y1;no)-%EXPwn(@D;DE)Sm84|6qkV&b)9t@-hlU52p&y__%2;83Z1oq}P7 zR|dSQ_u@|2@vCjU3ac^(qRy+!#Qf0#`*Zu)M-_xKZ(O@>!OqNPF~ro|M&S)9O0&Op z9@^m4Py1~XHsQw;k2`4}Qkl2qSF>*4&c!mCyVxob#Y9T7tz0e~N6_4*)=F~{3EDgn z7Q@ivXtJ6?=+ljZKauO_7KN5;l@I-}61*={NSxmQQ-&#>;qsQ3|A@SXVq?%9)k zx*=TTmuZvR>Q^m|PU`2a_hi}Ko~bWzcP7Uq+lFAs*v3%%Um(u2Uf&~xVY1+V&(oR| z4K&Fw8z=oBdQQ1@#pynPXt=QZTx<=WC>K%n$*$ApV}E!F_Nv zKZ8wEfR0m0k{31W5<1DUzMN+0zpgQTzCJ7Q^pqPl1bZ`1cig9C?G+y<@i4TTbLRW- zPp8Ycf7ORyXIGf~?-y@0{@X@kVXcCcy=35b!X|M0>)cN6HPYI#-EjL3bq&mRZC%WM WtsO%Av|uzeCi@Q`_ +provides similar concepts for configuring input devices and actions. In +FlashDreams, users can configure arbitrary key bindings through +``InputSystem``, which converts device signals into canonicalized user input. + +.. admonition:: Example + :class: note + + Either WASD or HJKL can be bound to movement directions and mapped into a + 2D character-movement vector. + +.. admonition:: Queued events between pulls + :class: note + + A call to ``InferenceSession.step()`` can take approximately 100--1000 ms + because it runs a latent-diffusion step. ``InputSystem`` must therefore + preserve every input change that occurs while inference is running rather + than returning only the most recent device state. It queues all events since + the previous ``InputSystem`` pull and returns them as an ordered list of + timestamped, canonicalized user-input events. + + For example, assume positive *x* means right and positive *y* means forward. + The user presses W at 5 ms, presses D at 10 ms, releases W at 50 ms, presses + S at 60 ms, releases D at 70 ms, and releases S at 80 ms. The next pull + returns the following canonical movement states: + + .. code-block:: text + + [ + ( 5 ms, vec2(0, 1)), # W pressed + (10 ms, vec2(1, 1)), # D pressed; W remains pressed + (50 ms, vec2(1, 0)), # W released + (60 ms, vec2(1, -1)), # S pressed; D remains pressed + (70 ms, vec2(0, -1)), # D released + (80 ms, vec2(0, 0)), # S released + ] + + The timestamps are the original event times, not the time at which the + application eventually calls ``pull()``. + +This layer handles device-facing concerns such as key bindings, dead zones, +axis conventions, and event sampling. Its output describes the user's intent +in a stable, device-independent form. It does not create model embeddings or +know how a particular inference pipeline represents conditioning. + +InputMapping +~~~~~~~~~~~~ + +``InputMapping`` consumes the ordered list of timestamped, canonicalized +user-input events returned by ``InputSystem`` and produces the model-ready, +per-step inference conditioning expected by an ``InferenceSession``. Depending +on the model, this conversion can include embedding control values, rendering +a control representation, changing layouts, or assembling tensors. + +.. admonition:: Example + :class: note + + ``integrations/omnidreams`` represents canonicalized driving input as a + floating-point steering-wheel angle and a floating-point paddle/brake value. + Its ``InputMapping`` runs the vehicle-dynamics simulation, renders the + resulting HD map with the Ludus renderer, and produces the rendered RGB HD + map as the per-step user-input condition. The resulting tensor has shape + ``(3, H, W)``. + +The ``(3, H, W)`` output is specific to OmniDreams, not a universal +``InputMapping`` contract. Another ``InferenceSession`` might expect an image +embedding as its condition. In that case, ``InputMapping`` can use an image +encoder to encode the frame and return the resulting embedding instead. + +This is the boundary between application-level control semantics and +model-specific conditioning. Replacing a keyboard with a controller should +usually affect the ``InputSystem``; replacing the model or its control encoder +should usually affect the ``InputMapping``. + +OutputTarget +~~~~~~~~~~~~ + +``OutputTarget`` consumes the ``FrameStream`` produced by the inference +session. A target can present frames in a native window, send them to a video +encoder, publish them through a WebRTC host, or adapt them for another output +system. + +The output target owns presentation and transport concerns. It must not be +responsible for interpreting user controls or advancing model inference. +Buffering and backpressure policies belong at this output boundary so that a +slow consumer does not silently redefine inference behavior. + +InferenceSession +~~~~~~~~~~~~~~~~ + +``InferenceSession`` is the execution boundary for the main inference +pipeline. It accepts inference input, maintains the state required across +autoregressive steps, runs the pipeline, and exposes generated output as a +``FrameStream``. + +The session receives model-ready data only. It does not poll devices, +canonicalize user intent, or present generated frames. After accepting global +inference conditioning, it retains the active global condition across later +steps until the application supplies an update or the session ends. + +Input data flow +--------------- + +The input path deliberately separates physical device readings, semantic +controls, and model-ready conditioning. This separation allows devices and +models to evolve independently. + +User conditioning +~~~~~~~~~~~~~~~~~ + +Raw user input +^^^^^^^^^^^^^^ + +**Raw user input** is a reading or event in the vocabulary of a physical input +source. Examples include: + +* WASD key presses and releases; +* digital wheel input; +* controller joystick readings; and +* Meta Quest hand-tracking readings. + +Raw values can depend on a particular device, driver, sampling rate, or key +binding. They are consumed by ``InputSystem`` and must not be passed directly +to ``InferenceSession``. + +Canonicalized user input +^^^^^^^^^^^^^^^^^^^^^^^^ + +**Canonicalized user input** expresses user intent in the vocabulary of the +interaction, independent of the device that produced it. Typical structures +include: + +* a 2D character-movement vector and a 2D camera-movement vector for + character-control games; +* a floating-point wheel value and a floating-point paddle value for driving; + and +* hand-tracking positions, correction vectors, or another agreed semantic hand + control for Cosmos-style interaction models. + +The exact structure depends on the interaction type and can evolve as its +semantics become clearer. The important invariant is that equivalent intent +from different devices has the same canonical representation. + +Per-step inference conditioning +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +**Per-step inference conditioning** is the model-ready encoding of +canonicalized user input for one inference step. ``InputMapping`` produces it by +performing whatever embedding, rendering, or tensor conversion the selected +model requires. + +This term distinguishes the changing user control for one step from global +conditioning, which normally remains stable across many steps. + +Inference input +^^^^^^^^^^^^^^^ + +**Inference input** is the complete input delivered to ``InferenceSession``. +It is the runtime boundary object, not another name for a raw or canonicalized +control. It can carry: + +* the per-step inference conditioning; and +* optional global inference conditioning. + +The first inference step generally carries both. On later steps, the global +condition remains active inside the session, so the application normally sends +only new per-step inference conditioning. Omitting global conditioning means +"continue using the active global condition"; it must not mean "clear the +global condition." + +Global conditioning +~~~~~~~~~~~~~~~~~~~ + +Global conditioning establishes the scene-level context for generation and can +contain model-specific data. Two of the most common examples are: + +* a **global conditioning frame**, sometimes called an initial frame by a + model; and +* a **global conditioning prompt**, containing the text description for the + run. + +The runtime uses *global conditioning frame* instead of *initial frame* +because the condition is not inherently limited to initialization. A future +runtime can replace it while a session is already running. + +Raw and canonicalized global conditioning +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +**Raw global conditioning** is the application-facing representation. For +example, the prompt is text and the conditioning frame is image data. + +**Canonicalized global conditioning** is the model-ready representation sent +through inference input as **global inference conditioning**. A text prompt is +typically converted into embedded tokens. A conditioning frame is +model-dependent: one model might convert it into CLIP embeddings, while +another might retain a frame or spatial representation such as an HD-map +condition. + +For that reason, *canonicalized* is preferred over *embedded* for the combined +global condition. It does not incorrectly imply that every part of the global +condition must become an embedding. + +Updating global conditioning during a run +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Although mid-run global-conditioning updates are not implemented yet, the +runtime contracts must leave room for them. An application could, for example, +submit a new conditioning frame and prompt to make an OmniDreams driving scene +transition suddenly to rainy weather. + +A later inference input can therefore carry new global inference conditioning +alongside its per-step conditioning. The session then treats it as a change to +the active global context. When no update is present, the session reuses the +previous context. The exact effects on model history, caches, and transition +behavior are model-specific and must be defined by the corresponding pipeline; +the application-level contract must not assume that global conditioning is +initialization-only. + +End-to-end runtime loop +----------------------- + +At a conceptual level, one runtime iteration follows these steps: + +#. ``Application`` pulls ``InputSystem`` for the events accumulated since the + previous pull. +#. ``InputSystem`` returns an ordered list of timestamped, canonicalized + user-input events. +#. ``InputMapping`` consumes this list of timestamped, canonicalized events and + produces per-step inference conditioning. +#. ``Application`` packages that data as inference input, adding canonicalized + global conditioning on the first step or whenever it changes. +#. ``InferenceSession`` advances the pipeline and emits generated frames + through ``FrameStream``. +#. ``OutputTarget`` consumes the stream for display, encoding, transport, or + another presentation path. + +These boundaries are the central architectural constraint: input devices +produce semantic controls, the input map produces model-ready conditioning, +the inference session runs the model, and the output target delivers the +result. diff --git a/docs/source/developer_guides/index.rst b/docs/source/developer_guides/index.rst index 7cc2bac85..7b7b50573 100644 --- a/docs/source/developer_guides/index.rst +++ b/docs/source/developer_guides/index.rst @@ -60,6 +60,7 @@ generated clip, see :doc:`/quickstart/index`. :hidden: :maxdepth: 1 + flashdreams_runtime inference_pipeline_overview config_system new_integration From 1e8fe779c7fbb6a454c0cb81640b485364e6a239 Mon Sep 17 00:00:00 2001 From: Fangjun Zhou Date: Tue, 4 Aug 2026 17:52:17 -0700 Subject: [PATCH 04/30] Update InputSystem doc --- .../developer_guides/flashdreams_runtime.rst | 88 +++++++++---------- 1 file changed, 44 insertions(+), 44 deletions(-) diff --git a/docs/source/developer_guides/flashdreams_runtime.rst b/docs/source/developer_guides/flashdreams_runtime.rst index 58e974899..85159ec20 100644 --- a/docs/source/developer_guides/flashdreams_runtime.rst +++ b/docs/source/developer_guides/flashdreams_runtime.rst @@ -45,7 +45,7 @@ Application layer ----------------- ``Application`` is the composition and lifecycle boundary for an interactive -FlashDreams runtime. It owns one ``InputSystem``, one ``InputMapping``, one +FlashDreams runtime. It owns ``InputSystem``, ``InputMapping``, ``OutputTarget``, and the main ``InferenceSession`` that runs the inference pipeline. These components are passed to the application through dependency injection. The application connects them, drives the runtime loop, and shuts @@ -59,37 +59,33 @@ the inference session. InputSystem ~~~~~~~~~~~ -``InputSystem`` owns the interaction with input devices and converts their -events into a canonical control representation. Raw input can come from many -sources, including keyboard events, a digital steering wheel, a controller -joystick, or Meta Quest hand tracking. +``InputSystem`` accepts an ordered list of timestamped raw input events +supplied by the application or an upstream input framework. It converts that +list into an ordered stream of timestamped, canonicalized user-input events. +Raw events can include keyboard key-down and key-up events, digital wheel +readings, controller joystick readings, or Meta Quest hand-tracking readings. `Unity's Input System `_ -provides similar concepts for configuring input devices and actions. In -FlashDreams, users can configure arbitrary key bindings through -``InputSystem``, which converts device signals into canonicalized user input. +provides similar concepts for devices and actions. FlashDreams has a narrower +boundary: it does not poll devices or configure key bindings. Device polling, +event collection, and binding configuration are handled upstream. -.. admonition:: Example - :class: note - - Either WASD or HJKL can be bound to movement directions and mapped into a - 2D character-movement vector. - -.. admonition:: Queued events between pulls +.. admonition:: Preserving events during slow inference :class: note A call to ``InferenceSession.step()`` can take approximately 100--1000 ms - because it runs a latent-diffusion step. ``InputSystem`` must therefore - preserve every input change that occurs while inference is running rather - than returning only the most recent device state. It queues all events since - the previous ``InputSystem`` pull and returns them as an ordered list of - timestamped, canonicalized user-input events. + because it runs a latent-diffusion step. The upstream input source must + preserve every raw input change that occurs while inference is running + rather than retain only the most recent device state. On the next runtime + iteration, the application passes the complete ordered raw-event list to + ``InputSystem``, which converts every event without collapsing intermediate + states. For example, assume positive *x* means right and positive *y* means forward. - The user presses W at 5 ms, presses D at 10 ms, releases W at 50 ms, presses - S at 60 ms, releases D at 70 ms, and releases S at 80 ms. The next pull - returns the following canonical movement states: + The raw-event list contains a W key-down at 5 ms, a D key-down at 10 ms, a W + key-up at 50 ms, an S key-down at 60 ms, a D key-up at 70 ms, and an S + key-up at 80 ms. ``InputSystem`` produces this canonical event stream: .. code-block:: text @@ -102,13 +98,14 @@ FlashDreams, users can configure arbitrary key bindings through (80 ms, vec2(0, 0)), # S released ] - The timestamps are the original event times, not the time at which the - application eventually calls ``pull()``. + The timestamps are the original raw-event times, not the time at which + ``InputSystem`` processes the list. -This layer handles device-facing concerns such as key bindings, dead zones, -axis conventions, and event sampling. Its output describes the user's intent -in a stable, device-independent form. It does not create model embeddings or -know how a particular inference pipeline represents conditioning. +This layer handles raw-to-canonical conversion concerns such as device-value +normalization, dead zones, axis conventions, and event ordering. Its output +describes the user's intent in a stable, device-independent form. It does not +create model embeddings or know how a particular inference pipeline represents +conditioning. InputMapping ~~~~~~~~~~~~ @@ -186,9 +183,10 @@ source. Examples include: * controller joystick readings; and * Meta Quest hand-tracking readings. -Raw values can depend on a particular device, driver, sampling rate, or key -binding. They are consumed by ``InputSystem`` and must not be passed directly -to ``InferenceSession``. +Raw events can depend on a particular device, driver, and upstream input +framework. They are collected and timestamped before entering FlashDreams. The +application passes the ordered raw-event list to ``InputSystem``; raw events +must not be passed directly to ``InferenceSession``. Canonicalized user input ^^^^^^^^^^^^^^^^^^^^^^^^ @@ -205,8 +203,10 @@ include: control for Cosmos-style interaction models. The exact structure depends on the interaction type and can evolve as its -semantics become clearer. The important invariant is that equivalent intent -from different devices has the same canonical representation. +semantics become clearer. ``InputSystem`` emits these values as an ordered +stream of timestamped, canonicalized events. The important invariant is that +equivalent intent from different devices has the same canonical +representation. Per-step inference conditioning ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -288,12 +288,12 @@ End-to-end runtime loop At a conceptual level, one runtime iteration follows these steps: -#. ``Application`` pulls ``InputSystem`` for the events accumulated since the - previous pull. -#. ``InputSystem`` returns an ordered list of timestamped, canonicalized - user-input events. -#. ``InputMapping`` consumes this list of timestamped, canonicalized events and - produces per-step inference conditioning. +#. ``Application`` passes ``InputSystem`` the ordered list of timestamped + raw input events accumulated upstream since the previous runtime iteration. +#. ``InputSystem`` converts that list into an ordered stream of timestamped, + canonicalized user-input events. +#. ``InputMapping`` consumes this canonical event stream and produces per-step + inference conditioning. #. ``Application`` packages that data as inference input, adding canonicalized global conditioning on the first step or whenever it changes. #. ``InferenceSession`` advances the pipeline and emits generated frames @@ -301,7 +301,7 @@ At a conceptual level, one runtime iteration follows these steps: #. ``OutputTarget`` consumes the stream for display, encoding, transport, or another presentation path. -These boundaries are the central architectural constraint: input devices -produce semantic controls, the input map produces model-ready conditioning, -the inference session runs the model, and the output target delivers the -result. +These boundaries are the central architectural constraint: an upstream input +source collects timestamped raw events, ``InputSystem`` produces canonical +events, ``InputMapping`` produces model-ready conditioning, +``InferenceSession`` runs the model, and ``OutputTarget`` delivers the result. From ddae9749a04247b4ecf80eab12cc95facfbf9f03 Mon Sep 17 00:00:00 2001 From: aidanfnv Date: Wed, 5 Aug 2026 09:58:16 -0700 Subject: [PATCH 05/30] WIP Implement T2, T3, and part of T4 from API refactor plan (#413) * WIP implementation of T2, T3, partial T4 * Fix issues found by Claude * Rewrite based on discussion, port after merge * doc update * doc updates * Update based on new diagrams * Align closer to diagrams --- docs/inference_runtime_api_design.md | 111 +++- ...inference_runtime_inputs_implementation.md | 287 +++++++++ ...ence_runtime_supported_inputs_inventory.md | 321 ++++++++++ flashdreams/flashdreams/runtime/__init__.py | 59 +- flashdreams/flashdreams/runtime/canonical.py | 387 ++++++++++++ flashdreams/flashdreams/runtime/inputs.py | 359 ++++++++++- flashdreams/flashdreams/runtime/interfaces.py | 30 +- flashdreams/flashdreams/runtime/mapping.py | 356 ++++++++++- flashdreams/flashdreams/runtime/types.py | 4 +- .../tests/test_inference_runtime_api.py | 154 +++-- flashdreams/tests/test_runtime_canonical.py | 590 ++++++++++++++++++ .../tests/test_runtime_input_mapping.py | 573 +++++++++++++++++ 12 files changed, 3076 insertions(+), 155 deletions(-) create mode 100644 docs/inference_runtime_inputs_implementation.md create mode 100644 docs/inference_runtime_supported_inputs_inventory.md create mode 100644 flashdreams/flashdreams/runtime/canonical.py create mode 100644 flashdreams/tests/test_runtime_canonical.py create mode 100644 flashdreams/tests/test_runtime_input_mapping.py diff --git a/docs/inference_runtime_api_design.md b/docs/inference_runtime_api_design.md index 2f0ba19f8..f70fbd890 100644 --- a/docs/inference_runtime_api_design.md +++ b/docs/inference_runtime_api_design.md @@ -19,7 +19,7 @@ integration-specific runner code: - `InferenceConfig`: how the model and inference stack should run; - `UserInputs`: controls or events from an app, replay trace, or benchmark; -- `ModelInputs`: prompts, frames, videos, trajectories, maps, scene data, and +- `InferenceInput`: prompts, frames, videos, trajectories, maps, scene data, and other values required by a specific model; - input mapping: model/application-specific conversion from user-facing inputs into model-facing inputs; @@ -30,6 +30,12 @@ integration-specific runner code: - metrics/profiling: timings, memory, traces, NVTX ranges, and benchmark outputs. +Current T2/T3 implementation notes are in +`docs/inference_runtime_inputs_implementation.md`. + +The supported-model input inventory used to revisit T2/T3 is in +`docs/inference_runtime_supported_inputs_inventory.md`. + The API should standardize the envelope and lifecycle. It should not pretend that all world models have the same inputs, that all models use the same optimization stack, or that a raw checkpoint can fully describe how to run the @@ -62,9 +68,9 @@ Initial scope: | ID | Status | Workstream | Can run in parallel? | Depends on | Done when | | --- | --- | --- | --- | --- | --- | | T0 | Complete | Create experimental branch and contribution rules. | No, this starts the work. | None. | Branch exists, PR target is agreed, and main merge criteria are written down. | -| T1 | Complete | Minimal API envelope and naming. | Partly. | T0. | `InferenceConfig`, `UserInputs`, `ModelInputs`, runtime/session, output target, and mapping boundaries are defined well enough for demos to use. | -| T2 | Planned | Event-based `UserInputs`. | Yes, after T1 direction is agreed. | T1. | User inputs are primarily timestamped events; replay traces and derived snapshots are supported where needed. | -| T3 | Planned | `ModelInputs`, schemas, and mapping boundary. | Yes, after T1 direction is agreed. | T1. | Models can declare required initial/per-step inputs, and mappings can convert user events into model inputs. | +| T1 | Complete | Minimal API envelope and naming. | Partly. | T0. | `InferenceConfig`, `UserInputs`, `InferenceInput`, runtime/session, output target, and mapping boundaries are defined well enough for demos to use. | +| T2 | Complete | Event-based `UserInputs`. | Yes, after T1 direction is agreed. | T1. | User inputs are primarily timestamped events; replay traces and derived snapshots are supported where needed. | +| T3 | Complete | `CanonicalInputs`, `InferenceInput`, schemas, and mapping boundary. | Yes, after T1 direction is agreed. | T1. | Models can declare required global/per-step inputs, and mappings can convert canonical inputs into inference inputs. | | T4 | Planned | `ModelRunner`, `InferenceRuntime`, and `InferenceSession` skeleton. | Partly. | T1. | A minimal standard loop can initialize a runtime, run at least one sequential session, and close cleanly. | | T5 | Planned | Output mode selection. | Yes, after the result/output shape is agreed. | T1, T4. | A run can choose output behavior such as MP4, JPEG/MJPEG stream, WebRTC, benchmark artifact, or headless/null without changing model code. | | T6 | Planned | LingBot migration. | Yes, once T2-T4 have a usable skeleton. | T2, T3, T4. | LingBot runs through the new API path with its event inputs mapped into model inputs. | @@ -97,14 +103,14 @@ Main runtime flow: App / integration / benchmark / transport chooses how the run is driven and where output goes supplies run setup: - InferenceConfig + UserInputs + ModelInputs + output/metrics options + InferenceConfig + UserInputs + InferenceInput + output/metrics options | v ModelRunner / standard loop orchestrates validation, lifecycle, stepping, output, and metrics uses input mapping to: validate that user/app inputs can drive the model - build initial and per-step ModelInputs during the run + build global and per-step InferenceInput during the run | v InferenceRuntime @@ -145,7 +151,7 @@ Create InferenceRuntime from InferenceConfig | v Start InferenceSession A - initial ModelInputs: prompt/frame/scene/etc. + global conditioning: prompt/frame/scene/etc. per-session state: cache, current step, reset state step 0 -> step 1 -> ... -> done outputs -> Output target @@ -154,7 +160,7 @@ Start InferenceSession A | v Start InferenceSession B - new initial ModelInputs or replay scenario + new global conditioning or replay scenario independent cache/state step 0 -> step 1 -> ... -> done outputs -> Output target @@ -305,33 +311,63 @@ User inputs are not model inputs. A keyboard event does not have one universal meaning. One model may map it to pose segments, another to steering commands, and another may ignore it. -## ModelInputs +## CanonicalInputs And InferenceInput + +Inputs move through three layers: + +```text +UserInputs -> CanonicalInputs -> InferenceInput + raw canonicalized encoded +``` + +Raw device events are canonicalized into device-independent modalities before an +application sees them, so adding a keyboard, gamepad, or wheel is a converter +registration rather than an application change. `InferenceInput` is what an +`InferenceSession` actually receives. + +`InferenceInput` describes the data the model or inference pipeline actually +requires. Both it and `CanonicalInputs` distinguish two conditioning slots: -`ModelInputs` describes the data the model or inference pipeline actually -requires. It should distinguish: +- global conditioning: values that condition the whole rollout; +- per-step conditioning: values needed for one generated chunk or frame window. -- initial inputs: values needed to start or reset a rollout; -- per-step inputs: values needed for one generated chunk or frame window. +Examples of global conditioning include prompt, negative prompt, conditioning +frame, input video, scene id, HD map asset, camera calibration, initial camera +pose, seed, or model-specific fields. -Examples of initial model inputs include prompt, negative prompt, first frame, -input video, scene id, HD map asset, camera calibration, initial camera pose, -seed, or model-specific fields. +Global conditioning is normally supplied when a session starts, but a non-empty +global slot on a mid-rollout input is an update request rather than a reset; +resetting rollout state is a separate `InferenceSession.reset()` call. Whether a +given value can be swapped mid-rollout is declared per field by +`InputField.update_policy`. -Examples of per-step model inputs include frame timestamps, pose segments, +Examples of per-step conditioning include frame timestamps, pose segments, camera trajectory chunks, rendered HD map frames, conditioning video windows, control tensors, event markers, or model-specific fields. -Model input payloads should use semantic names, not only modality names. For +Inference input payloads should use semantic names, not only modality names. For example, a first frame and an HD map frame should be distinct inputs even if both are image-like values. -For interactive runs, most `ModelInputs` will be initial values plus per-step -inputs produced by input mapping. For MP4 generation and benchmarking, the API +Model input metadata may also include a lightweight lifecycle label, such as +runtime config, cache initialization, rollout binding, per-step input, or +session update. This should remain query metadata, not model-specific tensor +validation. + +Model input names, payload kinds, lifecycle labels, and schema metadata should +be open-ended. Supported integrations such as SANA-WM, LingBot, Omnidreams, and +future external adapters may need different semantic fields. Adding a new model +should usually mean adding adapter-owned schema declarations and mappings, not +changing a central FlashDreams enum. + +For interactive runs, most `InferenceInput` values will be global conditioning +plus per-step inputs produced by input mapping. For MP4 generation and benchmarking, the API should also support fixed per-step model inputs so runs can be deterministic. ## Schemas -The API should support lightweight `UserInputSchema` and `ModelInputSchema` +The API should support lightweight `UserInputSchema`, `CanonicalInputSchema`, +and `InferenceInputSchema` metadata. These schemas are not meant to be a rich type system or a replacement for @@ -345,8 +381,15 @@ The purpose is to fail early before expensive model initialization, produce clearer errors, make fixed scenarios easier to validate, and avoid ambiguous dict payloads where keys only describe modality. +Schema objects may carry open-ended metadata for query-time hints such as +coordinate frame, units, rough shape summary, accepted file suffixes, schema +URI, model family, or source/transport details. Metadata should help humans and +adapter selection code, but compatibility should still be based on the declared +event capabilities, semantic model fields, payload representation hints, and +lifecycle labels. + For simple CLI text-to-video or image-to-video runs, `UserInputSchema` can be -trivial or omitted because there may be no live controls. `ModelInputSchema` is +trivial or omitted because there may be no live controls. `InferenceInputSchema` is more important because each supported model still needs to declare the model-facing values it expects. @@ -410,19 +453,24 @@ unless the checkpoint already matches a supported generic adapter. ## Input Mapping Input mapping is required whenever `UserInputs` need to become per-step -`ModelInputs`. In the T1 envelope this boundary is represented by a separate +`InferenceInput`. In the T1 envelope this boundary is represented by a separate `InputMapping` protocol. A model adapter may provide the default mapper because it knows how its supported user controls affect model-facing inputs. Applications, benchmarks, replay tools, or hosted runtimes may replace that mapper when they need a different wire surface or aggregation policy. +The selected mapping may be a single mapper or a composed set of mappers, so one +run can combine separate prompt, first-frame, and live-control mappings instead +of routing everything through one object. + There are two separate moments to keep clear: -- before runtime initialization, FlashDreams should select the mapping and check - obvious compatibility between the app event source and the model; +- before runtime initialization, FlashDreams should select the mapping or mapper + set and check obvious compatibility between the app event source and the + model; - during the standard loop, the runtime or runner queues and timestamps user events, then uses the selected mapping to build initial or per-step - `ModelInputs` from the relevant event window, often after the session reports + `InferenceInput` from the relevant event window, often after the session reports what it needs next. This keeps the Reactor-style contract intact: the model-side integration can @@ -621,14 +669,16 @@ registry, standard loop, concrete output modes, or model migrations: `InferenceSession`. - Step data carriers are named `StepRequest` and `StepResult`; a session returns `None` from `next_step_request()` when the rollout is complete. -- User-facing inputs use `UserInputs`; model-facing inputs use `ModelInputs`. +- Raw inputs use `UserInputs`, canonicalized inputs use `CanonicalInputs`, and + model-facing inputs use `InferenceInput`. Both remain lightweight payload envelopes with shallow read-only mappings. -- `UserInputSchema` and `ModelInputSchema` stay intentionally small: they +- `UserInputSchema`, `CanonicalInputSchema`, and `InferenceInputSchema` stay + intentionally small: they declare supported event types and required named fields for early validation, not a full type system. - Input mapping is represented by a separate `InputMapping` protocol. Model adapters may provide a default mapping; runtimes and applications may override - it while preserving the `UserInputs` to `ModelInputs` boundary. Simple + it while preserving the `CanonicalInputs` to `InferenceInput` boundary. Simple fixed-input runs can use `IdentityInputMapping`. - Output handling is represented by `OutputTarget`; `NullOutputTarget` is the initial headless implementation. @@ -660,7 +710,8 @@ Proceed with the proposed split: - `InferenceConfig` for model/runtime execution; - `UserInputs` for app-facing controls and replay traces; -- `ModelInputs` for model-facing initial and per-step inputs; +- `CanonicalInputs` for device-independent application-facing inputs; +- `InferenceInput` for model-facing global and per-step conditioning; - input mapping for model/application-specific conversion; - runtime/session boundaries for lifecycle and stepping; - output targets for display, streaming, files, and benchmarks; diff --git a/docs/inference_runtime_inputs_implementation.md b/docs/inference_runtime_inputs_implementation.md new file mode 100644 index 000000000..8d485768a --- /dev/null +++ b/docs/inference_runtime_inputs_implementation.md @@ -0,0 +1,287 @@ + + +# Inference Runtime Inputs Implementation Notes + +This note documents the input layers of the experimental runtime API: what +exists, how the pieces fit together, what the compatibility query answers, and +what is intentionally still outside this layer. + +Implementation lives in `flashdreams.runtime`: + +- `flashdreams/flashdreams/runtime/inputs.py` — the input types and schemas +- `flashdreams/flashdreams/runtime/canonical.py` — raw device to canonical + modality conversion +- `flashdreams/flashdreams/runtime/mapping.py` — canonical to encoded mapping + and compatibility +- `flashdreams/tests/test_runtime_canonical.py` +- `flashdreams/tests/test_runtime_input_mapping.py` +- `flashdreams/tests/test_inference_runtime_api.py` — the T1 envelope tests, + including a reference loop that exercises all three layers + +The supported-model input inventory that informed this work is in +`docs/inference_runtime_supported_inputs_inventory.md`. + +## The Three Layers + +```text +UserInputs ──InputCanonicalizer──▶ CanonicalInputs ──InputMapping──▶ InferenceInput + raw canonicalized encoded +(device events) (device-independent) (what the session gets) +``` + +| Layer | Type | Owner | Example | +| --- | --- | --- | --- | +| raw | `UserInputs` / `UserInputEvent` | transport, replay loader, benchmark driver | `key_down {"key": "w"}`, wheel axis reading | +| canonicalized | `CanonicalInputs` | device converters registered on `InputCanonicalizer` | `driver_command {throttle, brake, steer, ...}` | +| encoded | `InferenceInput` | the selected `InputMapping` | whatever the model's session consumes | + +Applications and mappings consume `CanonicalInputs`. They never read raw device +events: `InputMapping.map_step_inputs` takes `canonical_inputs`, not +`user_inputs`, so this is enforced by the signature rather than by convention. +Adding a keyboard, gamepad, or wheel is an `InputCanonicalizer.register` call +that touches no application, mapping, or model code. + +This path covers **live user control only**. Global conditioning is +application-owned data and reaches `InferenceInput` directly, without passing +through canonicalization or a device converter. An application that wants a +trigger key to swap the prompt reads that as ordinary canonical control input +and updates its own global conditioning in response. + +## Conditioning Slots + +Both the canonical and encoded layers split into two slots, and the split means +the same thing at each: + +- **global conditioning** — conditions the whole rollout: prompt, conditioning + frame, scene. Normally supplied at session start. +- **per-step conditioning** — needed to generate the next chunk or frame: + steering, HD map frames, camera trajectory. + +`InputPhase` is `Literal["global", "step"]`. The axis names *which slot*, not +*when the value may arrive* — see the next section. + +## Global Conditioning Updates Are Not Resets + +A non-empty global slot on a mid-rollout `InferenceInput` is an **update +request**. The session should apply it when the model supports doing so. +Resetting rollout state is a separate, explicit `InferenceSession.reset()` call. +The motivating case is changing prompt and conditioning frame mid-run to change +the weather in an Omnidreams rollout. + +```python +from flashdreams.runtime import InferenceInput + +steady_state = InferenceInput(step={"steering": 0.25}) +assert not steady_state.requests_global_update + +changed_weather = steady_state.with_global_update({"prompt": "heavy rain"}) +assert changed_weather.requests_global_update +``` + +Because `with_step()` carries the global slot through unchanged, use +`without_global_update()` for the steady-state case; otherwise every step looks +like an update request. + +Whether a value can actually be swapped mid-rollout is declared per field: + +```python +from flashdreams.runtime import SESSION_START_ONLY, InferenceInputSchema, InputField + +schema = InferenceInputSchema( + global_fields=( + InputField(name="prompt", update_policy="step_boundary"), + InputField(name="scene_id", update_policy=SESSION_START_ONLY), + ) +) +schema.unsupported_global_updates( + InferenceInput(global_conditioning={"prompt": "heavy rain", "scene_id": "town_02"}) +) +# ("scene_id",) +``` + +`SESSION_START_ONLY` is the one reserved `update_policy` token. Everything else +in that vocabulary, and all of `lifecycle`, is open and adapter-owned; this layer +only carries it as queryable metadata. + +Steady-state steps must leave the global slot empty; otherwise every step reads +as an update request. Converters emit every window, because live control is +level-triggered: a key held across a step emits no events but still means full +throttle. + +## Raw Inputs + +`UserInputEvent` carries `timestamp_s`, `event_type`, `payload`, `source`, and +`source_event_id`. `UserInputs` holds an ordered batch plus a `snapshot` and +`metadata`, and slices to a half-open `TimeWindow`: + +```python +from flashdreams.runtime import TimeWindow, UserInputEvent, UserInputs + +inputs = UserInputs( + events=( + UserInputEvent(timestamp_s=0.0, event_type="prompt_set", + payload={"prompt": "drive forward"}), + UserInputEvent(timestamp_s=0.5, event_type="key_down", payload={"key": "w"}), + ) +) +step_window = inputs.window(TimeWindow(start_s=0.0, end_s=1.0)) +``` + +`UserInputSchema` describes what a transport, replay trace, or benchmark driver +can provide. `event_types` declares only that an event type exists; +`UserInputCapability` additionally pins the payload fields it carries, so a +converter can require `key_down` events that actually have a `key`. A bare +`event_types` entry still satisfies any consumer needing no specific payload +fields, so schemas written before capabilities existed keep working. + +## Canonical Modalities + +A `CanonicalModality` is a device-independent input: a name and the payload +fields it guarantees. Converters implement `DeviceConverter`, declaring +what raw capabilities they consume and which modality they produce. + +```python +from flashdreams.runtime import ( + DRIVER_COMMAND, InputCanonicalizer, KeyboardToDriverCommand, TimeWindow, +) + +canonicalizer = InputCanonicalizer([KeyboardToDriverCommand()]) +canonicalizer.register(WheelToDriverCommand()) # a wheel is one call + +canonical = canonicalizer.canonicalize( + user_inputs, window=TimeWindow(start_s=0.0, end_s=1.0), source_schema=browser +) +canonical.values["driver_command"]["throttle"] +``` + +`DRIVER_COMMAND` is the one shipped modality. `KeyboardToDriverCommand` reuses +`KeyboardState`/`normalize_key` from `flashdreams.serving.realtime.input` and +mirrors the semantics the Omnidreams interactive-drive keyboard backend already +has. Its key bindings are data (`DEFAULT_DRIVING_BINDINGS`), and the set of +tracked keys is derived from them, so a rebound layout cannot leave an action +unreachable. + +`ScriptedModality` is the mock/replay converter. It consumes no raw +capabilities, so a benchmark or test can author a scenario at the canonical +level without knowing any device vocabulary: + +```python +canonicalizer = InputCanonicalizer([ + ScriptedModality(modality=DRIVER_COMMAND, timeline=[(0.0, full_throttle)]), +]) +canonicalizer.canonicalize( + UserInputs(), window=step_window, source_schema=UserInputSchema() +) +``` + +Application code is identical between a real run and a scripted one. + +Converters are stateful, so feed windows in session order and call +`InputCanonicalizer.reset()` at a rollout boundary. Replaying the same window +sequence reproduces the same `CanonicalInputs`. + +When several devices produce the same modality, the highest-priority one that +returned a value wins; `CanonicalInputs.metadata["canonical_sources"]` records +which device supplied each. Every feedable converter still sees each window, so +a preempted device's state stays current and unplugging the higher-priority +device does not resume from stale state. + +## Mapping And Compatibility + +`InputMapping` is the canonical-to-encoded boundary. `InputMappingSchema` is its +declarative surface: `consumes` names canonical modalities; `produces_global` +and `produces_step` name the `InferenceInput` fields it can build. + +`InputMapping.validate()` raises, which fails a run late and cannot say *which* +optional model input a source would enable or *which* missing modality makes a +required one unreachable. `check_mapping_compatibility` answers those before +expensive runtime initialization: + +```python +from flashdreams.runtime import check_mapping_set_compatibility + +compatibility = check_mapping_set_compatibility( + canonical_schema=canonicalizer.canonical_schema(browser), + inference_input_schema=adapter.inference_input_schema, + mapping_schemas=(prompt_mapping, frame_mapping, steering_mapping), +) +if not compatibility.can_drive: + compatibility.raise_if_incompatible() +``` + +`MappingCompatibility` reports `missing_modalities`, +`missing_required_model_fields`, `satisfied_required_model_fields`, +`available_optional_model_fields`, and `unavailable_mapping_schemas`. + +Compatibility is evaluated per mapping rather than over a flattened bag, so each +mapping keeps its own consumes/produces link. A mapping the source cannot feed +is dropped and reported, costing only the inputs it produced. So a dropped +mapping that fed only optional fields degrades the run instead of vetoing it, +and those fields are correctly absent from `available_optional_model_fields`; a +dropped mapping that was the only producer of a required field still blocks. + +Because a mapping consumes modalities rather than raw events, one mapping +written against `driver_command` works for a keyboard, a wheel, or any device +registered later, with no change to the mapping or the model schema. + +`undeclared_inference_inputs()` reports payload keys a mapping produced but did +not declare, which keeps hand-written schemas honest as the code drifts. + +## What This Does Not Validate + +The schemas intentionally avoid becoming a rich type system. These remain the +responsibility of the model adapter, runtime, session, or mapping: + +- tensor shape and dtype, image decode details; +- camera coordinate systems, pose and timestamp units; +- prompt-embedding swap mechanics; +- whether a model can actually apply a declared update policy at runtime; +- deep validation of scene, HD map, or actor-state data. + +The layer answers "can this source plausibly drive this model through this +mapping?" It does not replace model-owned validation. + +## Open Questions + +Tracked against the runtime API discussion, not yet settled: + +- **Alternative valid input combinations.** `InferenceInputSchema` has one flat + required set, so "accepts `{prompt}` OR `{prompt, conditioning_frame}`" cannot + be expressed. `MappingCompatibility.missing_required_model_fields` assumes a + single required set too. +- **`step()` returning a future**, for models with a dependency on their own + output. `InferenceSession.step()` is currently synchronous. +- **`Input System` ownership.** The diagrams show it pulling events, so the + Application owns an input system. `InputCanonicalizer` is currently a pure + function over a supplied window and owns no source. Whether it needs to grow + one depends on the loop-ownership decision. Mock input and key binding are + handled (`ScriptedModality`, `DEFAULT_DRIVING_BINDINGS`). + +## Owned Elsewhere + +Named here only so the boundary is explicit; these are not gaps in the input +layer: + +- **`FrameStream`**, which the architecture diagrams place between + `InferenceSession` and `Output Target`. The code writes `StepResult` straight + to `OutputTarget.write()`. Output shape is T5. +- **Declared output modalities**, so an output target or quality-eval can state + what it requires and be matched the way inputs now are. T5/T8. +- **`Application`**, the class that has-a input system, input map, global + conditioning, session, and output target. T4. +- **Loop ownership** — whether the application or the runtime/session drives the + main event loop, and whether inputs are queued and batched. + +## Validation + +```bash +.venv/bin/pytest flashdreams/tests/test_runtime_canonical.py \ + flashdreams/tests/test_runtime_input_mapping.py \ + flashdreams/tests/test_inference_runtime_api.py -q +.venv/bin/ty check flashdreams/flashdreams/runtime +``` + +At the time of writing these pass: 87 tests, and `ty` is clean. diff --git a/docs/inference_runtime_supported_inputs_inventory.md b/docs/inference_runtime_supported_inputs_inventory.md new file mode 100644 index 000000000..ebe9d853a --- /dev/null +++ b/docs/inference_runtime_supported_inputs_inventory.md @@ -0,0 +1,321 @@ + + +# Supported Model Input Inventory + +This note inventories the inputs used by the currently supported FlashDreams +runners and interactive runtimes, plus the SANA-WM input surface on `main`, then +records the T2/T3 API implications. It is intentionally about input contracts, +not tensor shape validation or model quality. + +## Inventory + +WAN 2.1 T2V, Self-Forcing WAN 2.1 T2V, Causal-Forcing T2V, +FastVideo Causal WAN 2.2 T2V, and Cosmos Predict2 T2V: + +- Source/app inputs: prompt text or prompt text file, pixel height/width, and + fps or block count depending on runner. +- Model-facing initial inputs: prompt text plus latent/output height and width + derived from run config. +- Model-facing step/update inputs: no live controls; AR loop steps with fixed + session state. + +WAN 2.1 I2V, Causal-Forcing I2V, and Cosmos Predict2 I2V: + +- Source/app inputs: prompt text or prompt file, first-frame image path or URL, + and pixel height/width. +- Model-facing initial inputs: prompt text and decoded first-frame tensor. +- Model-facing step/update inputs: no live controls. + +FlashVSR: + +- Source/app inputs: input video path or URL, chunk size, crop region, sparse + ratio, and optional output FPS. +- Model-facing initial inputs: no explicit prompt at runner time; the prompt + tensor is configured in the pipeline. Input video dimensions affect + per-video runtime/pipeline setup. +- Model-facing step/update inputs: video chunks passed to + `pipeline.generate(input=clip)`. + +LingBot CLI: + +- Source/app inputs: prompt or prompt path, first-frame image path, pose path, + intrinsics path, total blocks, dimensions, and fps. +- Model-facing initial inputs: prompt text and first-frame tensor. +- Model-facing step/update inputs: `CamCtrlInput` with intrinsics, camera poses, + and world scale. + +LingBot WebRTC: + +- Source/app inputs: session prompt, uploaded/remote/default first-frame image, + keyboard events, reset requests, text-event catalog, and trigger events. +- Model-facing initial inputs: prompt text, first-frame tensor, base text + embeddings, precomputed text-event embeddings, base intrinsics, and world + scale. +- Model-facing step/update inputs: keyboard event windows become pose segments + and camera trajectories. Text-event triggers can replace rollout text + embeddings when the model supports it. + +HY-WorldPlay WAN I2V: + +- Source/app inputs: prompt or prompt path, first-frame image path or example + image, pose string or pose JSON, memory-selection settings, dimensions, fps, + and seed. +- Model-facing initial inputs: prompt text and first-frame tensor for cache + initialization. +- Model-facing step/update inputs: pose data is bound for the rollout as action + labels, view matrices, intrinsics, and memory-selection state before AR steps. + +Omnidreams CLI: + +- Source/app inputs: shared prompt or per-camera prompts, HDMap video paths, + first-frame image/video paths, camera names, example-data UUID, and optional + embedding save/load paths. +- Model-facing initial inputs: prompt list, first-frame tensor, view names; or + precomputed text/image/negative-text embeddings. +- Model-facing step/update inputs: HDMap video chunks passed per AR step. + +Omnidreams WebRTC: + +- Source/app inputs: scene directory or scene UUID, scene variant, camera name, + prompt/first-frame assets resolved from the scene, keyboard events, reset + requests, and optional postprocess preset. +- Model-facing initial inputs: scene data, renderer, first-frame tensor, prompt, + camera calibration/extrinsics, initial ego pose, and initial timestamp. +- Model-facing step/update inputs: keyboard event windows become ego poses, + camera poses per view, and frame timestamps. The wrapper renders HDMap + conditioning internally for each step. + +Omnidreams interactive drive: + +- Source/app inputs: scene bundle, keyboard events or wheel/controller samples, + view-mode/reset/scene-exit controls, and vehicle/chunk config. +- Model-facing initial inputs: scene bundle, selected camera, prompt, initial + RGB frame, initial rig pose, and initial timestamp. +- Model-facing step/update inputs: `DriverCommand` samples become trajectory + chunks, rendered frames, and world-model conditioning. + +Template recipe: + +- Source/app inputs: synthetic runner config: batch size, height, width, context + tokens, AR steps, and seed. +- Model-facing initial inputs: synthetic transformer context, optional negative + context, height, and width. +- Model-facing step/update inputs: optional synthetic control tensor. + +WAN 2.2 TI2V pipeline config: + +- Source/app inputs: downstream runners use this rather than a standalone runner + in this tree. +- Model-facing initial inputs: prompt text and first-frame image for TI2V-style + cache initialization. +- Model-facing step/update inputs: downstream runners decide controls; + HY-WorldPlay currently binds action/camera state around it. + +SANA-WM bidirectional and streaming on `main`: + +- Source/app inputs: first-frame image path, prompt or prompt path, optional + negative prompt, camera trajectory path or action DSL, optional intrinsics + path or derived intrinsics, frame count, fps, Stage-1 sampling knobs, seed, + precision/refiner options, and streaming chunk/block settings. +- Model-facing initial inputs: decoder context such as prompt, fps, + `save_stage1`, refiner seed, sink size, and streaming refiner window/block + parameters. +- Model-facing step/update inputs: bidirectional passes one + `SanaWMI2VConditioningRequest` into the single generation step. Streaming + passes one `SanaWMStreamingI2VConditioningRequest` repeatedly; the + conditioning encoder caches rollout-wide prompt, first-frame, camera, latent + shape, and chunk-boundary state, then slices per AR chunk. +- Model-facing semantic fields include prompt, negative prompt, first frame, + camera-to-world trajectory, intrinsics vec4 sequence, frame count, fps, + sampling parameters, seed, and streaming chunking parameters. + +## API Implications + +The inventory changes the T2/T3 shape in four concrete ways. + +First, a selected mapping is often a composition. A LingBot-like run needs prompt +mapping, first-frame mapping, and keyboard-to-camera mapping. Omnidreams may add +scene selection, camera selection, and HDMap mapping. The implementation should +support checking a set of mapping schemas as one compatibility surface, while +still allowing a single mapping object when that is simpler. + +Second, `InferenceInputSchema` needs a lightweight lifecycle tag in addition to the +`initial` versus `step` phase. The phase answers when the value is needed at the +standard-loop level. The lifecycle tag distinguishes where the model adapter +uses it, such as: + +- `runtime_config`: values that affect setup before model/runtime construction, + such as FlashVSR input-video dimensions; +- `cache_init`: values passed when initializing or resetting a rollout cache, + such as prompts, first frames, view names, and precomputed embeddings; +- `rollout_binding`: values bound after cache initialization but before AR + steps, such as HY-WorldPlay action labels, camera tensors, and memory state; +- `step_input`: values consumed for one generated chunk, such as HDMap frames, + camera trajectories, driver commands, video chunks, and timestamps; +- `session_update`: values that can update an active session when supported, + such as LingBot text-event embedding swaps. + +The lifecycle tag is metadata, not a new deep type system. If both a model field +and mapping output specify lifecycle, compatibility should require them to agree. +If either side omits it, matching stays permissive for simple schemas. + +Third, `semantic_type` should be treated as a representation hint rather than a +universal semantic type. For example, `prompt` may arrive as inline text or a +path but become prompt text or text embeddings; the global conditioning frame +may arrive as a path, URL, bytes, or decoded tensor; camera motion may arrive as keys, pose JSON, +Numpy arrays, or integrated tensors. The semantic input name is still the main +contract. + +Fourth, schema objects need open-ended metadata for future adapters. This lets a +SANA-WM-like adapter advertise that `camera_trajectory_c2w` uses an +`[F,4,4]` OpenCV camera-to-world sequence, or lets another model advertise a +schema URI, units, coordinate frame, accepted file suffixes, cardinality hints, +or update notes. Metadata should remain query information and should not become +the compatibility type system. + +Fifth, `UserInputSchema` describes raw source capabilities, `CanonicalModality` +describes what an application consumes, and mapping schemas describe derived +model-facing semantics. A browser may provide `key_down`, `key_up`, +`prompt_set`, and `initial_frame_set` events. Those become canonical modalities +such as `driver_command` or `conditioning_prompt`; whether they can then drive +`steering`, `camera_trajectory`, or text embedding updates depends on the +selected mapping and model schema. + +## Implemented T2/T3 Shape + +The implementation that came out of this inventory is: + +1. Keep `UserInputEvent` and `UserInputs` as the raw event API, sliced by a + half-open `TimeWindow`. Static startup values remain timestamp-zero events. +2. Keep `UserInputSchema` lightweight and source-facing. `event_types` declares + that an event type exists; `UserInputCapability` additionally pins the + payload fields it carries. +3. Add a canonical layer between raw and encoded. `CanonicalModality` names a + device-independent input and its conditioning phase; `InputCanonicalizer` + registers per-device converters and produces `CanonicalInputs`. Applications + and mappings consume canonical inputs and never read raw device events. +4. Split `InferenceInput` (formerly `ModelInputs`) into `global_conditioning` + and `step`. A non-empty global slot mid-rollout is an update request, not a + reset; `InputField.update_policy` declares whether the model can apply it. +5. Extend `InputField` with `update_policy`, `lifecycle`, and `metadata` so + models can distinguish runtime config, cache initialization, rollout binding, + per-step inputs, and supported active-session updates. +6. Keep `InputMappingSchema` as the canonical-to-encoded boundary, with + mapping-set compatibility helpers for composed mappings. +7. Keep input names, semantic types, lifecycle labels, and metadata open-ended. + Adding a new model should usually mean adding adapter-owned schema + declarations and mappings, not changing the core input dataclasses. +8. Leave deep validation to model adapters, sessions, and mappings. The schema + layer catches obvious source/mapping/model mismatches before expensive + runtime initialization; it does not validate every tensor and coordinate + convention. + +See `docs/inference_runtime_inputs_implementation.md` for the resulting API. + +## Extensibility Contract + +The inventory above is not a vocabulary freeze. The core API does not contain a +closed enum of allowed input names. New adapters can introduce semantic field +names that match the model boundary they own. + +Use these conventions when adding future model schemas: + +- Prefer semantic names over modality names, such as `camera_trajectory_c2w` + instead of `array`, or `hdmap_frames` instead of `image`. +- Use `semantic_type` for a coarse representation hint, such as `path`, + `decoded_tensor`, `c2w_sequence`, `intrinsics_vec4_sequence`, or `embedding`. +- Use `lifecycle` to say where the adapter consumes the value, such as + `runtime_config`, `cache_init`, `rollout_binding`, `step_input`, or + `session_update`. +- Use `update_policy` to say when a value may change. `SESSION_START_ONLY` is + the one reserved token, meaning the value cannot be swapped mid-rollout. +- Use `metadata` for query hints: units, coordinate frame, shape summary, + accepted suffixes, schema URI, model family, value ranges, or cardinality. +- Keep deep validation in the adapter/mapping. The lightweight schemas answer + whether the selected source and mapping can plausibly drive the model before + expensive initialization. + +## Representative Schema Sketches + +These are not migration work for T4+, but they show that the current primitives +can describe the supported input surfaces. All use +`flashdreams.runtime.InferenceInputSchema` and `InputField`. + +```python +lingbot_model = InferenceInputSchema( + description="lingbot-world", + global_fields=( + InputField(name="prompt", lifecycle="cache_init"), + InputField(name="global_conditioning_frame", lifecycle="cache_init"), + ), + step_fields=( + InputField(name="camera_trajectory", lifecycle="step_input"), + InputField( + name="text_embeddings", + required=False, + update_policy="step_boundary", + lifecycle="session_update", + ), + ), +) +``` + +```python +omnidreams_model = InferenceInputSchema( + description="omnidreams", + global_fields=( + InputField(name="prompts", lifecycle="cache_init"), + InputField(name="global_conditioning_frames", lifecycle="cache_init"), + InputField(name="view_names", lifecycle="cache_init"), + InputField(name="text_embeddings", required=False, lifecycle="cache_init"), + InputField(name="image_embeddings", required=False, lifecycle="cache_init"), + ), + step_fields=(InputField(name="hdmap_frames", lifecycle="step_input"),), +) +``` + +```python +hy_worldplay_model = InferenceInputSchema( + description="hy-worldplay", + global_fields=( + InputField(name="prompt", lifecycle="cache_init"), + InputField(name="global_conditioning_frame", lifecycle="cache_init"), + InputField(name="action_labels", lifecycle="rollout_binding"), + InputField(name="camera_viewmats", lifecycle="rollout_binding"), + InputField(name="camera_intrinsics", lifecycle="rollout_binding"), + InputField(name="memory_config", lifecycle="rollout_binding"), + ), +) +``` + +```python +sana_wm_model = InferenceInputSchema( + description="sana-wm", + global_fields=( + InputField(name="prompt", lifecycle="cache_init"), + InputField(name="negative_prompt", required=False, lifecycle="cache_init"), + InputField(name="global_conditioning_frame", lifecycle="cache_init"), + InputField( + name="camera_trajectory_c2w", + semantic_type="c2w_sequence", + lifecycle="rollout_binding", + metadata={"shape": "[F,4,4]", "coordinates": "opencv_c2w"}, + ), + InputField( + name="camera_intrinsics_vec4", + required=False, + semantic_type="intrinsics_vec4_sequence", + lifecycle="rollout_binding", + metadata={"shape": "[F,4]"}, + ), + ), +) +``` + +SANA-WM's `stage1_sampling` and `streaming_chunking` are deliberately absent +above. They describe how to run the model rather than what conditions it, so +they belong in `InferenceConfig`, not in an input schema. Flagged here because +the runner currently threads them alongside the conditioning inputs. diff --git a/flashdreams/flashdreams/runtime/__init__.py b/flashdreams/flashdreams/runtime/__init__.py index 03e6202b0..ab303c745 100644 --- a/flashdreams/flashdreams/runtime/__init__.py +++ b/flashdreams/flashdreams/runtime/__init__.py @@ -7,22 +7,49 @@ intentionally additive while integrations migrate onto it. """ +from flashdreams.runtime.canonical import ( + DEFAULT_DRIVING_BINDINGS, + DRIVER_COMMAND, + DeviceConverter, + DeviceConverterSchema, + InputCanonicalizer, + KeyboardToDriverCommand, + ScriptedModality, +) from flashdreams.runtime.config import ExecutionBackend, InferenceConfig, Precision from flashdreams.runtime.inputs import ( + INPUT_PHASES, + SESSION_START_ONLY, + CanonicalInputs, + CanonicalInputSchema, + CanonicalModality, + InferenceInput, + InferenceInputSchema, InputField, - ModelInputs, - ModelInputSchema, + InputPhase, TimeWindow, + UserInputCapability, UserInputEvent, UserInputs, UserInputSchema, + validate_phase, ) from flashdreams.runtime.interfaces import ( InferenceRuntime, InferenceSession, ModelAdapter, ) -from flashdreams.runtime.mapping import IdentityInputMapping, InputMapping +from flashdreams.runtime.mapping import ( + DeclaresMappingSchema, + IdentityInputMapping, + InputMapping, + InputMappingSchema, + MappingCompatibility, + check_mapping_compatibility, + check_mapping_set_compatibility, + combine_mapping_schemas, + undeclared_inference_inputs, +) from flashdreams.runtime.metrics import ( InMemoryMetricsRecorder, MetricsRecorder, @@ -33,28 +60,50 @@ from flashdreams.runtime.types import StepRequest, StepResult __all__ = [ + "CanonicalInputs", + "CanonicalInputSchema", + "CanonicalModality", + "check_mapping_compatibility", + "check_mapping_set_compatibility", + "combine_mapping_schemas", + "DeclaresMappingSchema", + "DEFAULT_DRIVING_BINDINGS", + "DeviceConverter", + "DeviceConverterSchema", + "DRIVER_COMMAND", "ExecutionBackend", "IdentityInputMapping", "InferenceConfig", + "InferenceInput", + "InferenceInputSchema", "InferenceRuntime", "InferenceSession", "InMemoryMetricsRecorder", + "INPUT_PHASES", + "InputCanonicalizer", "InputField", "InputMapping", + "InputMappingSchema", + "InputPhase", + "KeyboardToDriverCommand", + "MappingCompatibility", "MetricsRecorder", "ModelAdapter", - "ModelInputs", - "ModelInputSchema", "NullMetricsRecorder", "NullOutputTarget", "OutputArtifact", "OutputTarget", "Precision", "RuntimeMetricSample", + "ScriptedModality", + "SESSION_START_ONLY", "StepRequest", "StepResult", "TimeWindow", + "undeclared_inference_inputs", + "UserInputCapability", "UserInputEvent", "UserInputs", "UserInputSchema", + "validate_phase", ] diff --git a/flashdreams/flashdreams/runtime/canonical.py b/flashdreams/flashdreams/runtime/canonical.py new file mode 100644 index 000000000..55f333ce7 --- /dev/null +++ b/flashdreams/flashdreams/runtime/canonical.py @@ -0,0 +1,387 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Raw device input to canonical modality conversion. + +This is the ``raw input -> canonicalized input`` leg. Applications consume +:class:`~flashdreams.runtime.inputs.CanonicalInputs`; they never read raw device +events. Adding a keyboard, gamepad, or force-feedback wheel is therefore a +:meth:`InputCanonicalizer.register` call that touches no application, mapping, +or model code. + +Converters are stateful, because HID input is edge-triggered while per-step +conditioning is level-triggered: a key held across a step emits no events yet +still means full throttle. Feed windows in session order and call +:meth:`InputCanonicalizer.reset` at a rollout boundary; replaying the same +window sequence then reproduces the same canonical inputs. + +This layer covers live user control only. Global conditioning such as a prompt +or conditioning frame is application-owned and reaches ``InferenceInput`` +directly, without passing through canonicalization. +""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping, Sequence +from dataclasses import dataclass, field +from types import MappingProxyType +from typing import Any, Protocol, runtime_checkable + +from flashdreams.runtime._utils import freeze_mapping +from flashdreams.runtime.inputs import ( + CanonicalInputs, + CanonicalInputSchema, + CanonicalModality, + TimeWindow, + UserInputCapability, + UserInputs, + UserInputSchema, +) +from flashdreams.serving.realtime.input import KeyboardState, normalize_key + +DriverBindings = Mapping[str, frozenset[str]] + +DEFAULT_DRIVING_BINDINGS: DriverBindings = MappingProxyType( + { + "throttle": frozenset({"w", "up"}), + "brake": frozenset({"s", "down"}), + "steer_left": frozenset({"a", "left"}), + "steer_right": frozenset({"d", "right"}), + "stop": frozenset({"space"}), + "reverse": frozenset(), + } +) +"""Default key bindings for :class:`KeyboardToDriverCommand`. + +Bindings are data so a layout can be rebound without editing the converter, and +so the set of tracked keys is derived from them rather than declared twice. +""" + +_DRIVER_ACTIONS = frozenset(DEFAULT_DRIVING_BINDINGS) + + +@dataclass(frozen=True, kw_only=True, slots=True) +class DeviceConverterSchema: + """Metadata for one device-to-canonical-modality converter.""" + + name: str + produces: CanonicalModality + consumes: tuple[UserInputCapability, ...] = () + device_kind: str | None = None + priority: int = 0 + metadata: Mapping[str, Any] = field( + default_factory=dict, + compare=False, + hash=False, + ) + + def __post_init__(self) -> None: + if not self.name.strip(): + raise ValueError("DeviceConverterSchema.name must be non-empty.") + if not isinstance(self.produces, CanonicalModality): + raise TypeError("produces must be a CanonicalModality object.") + object.__setattr__(self, "metadata", freeze_mapping(self.metadata)) + + +@runtime_checkable +class DeviceConverter(Protocol): + """Contract for turning one device's raw events into a canonical modality.""" + + @property + def schema(self) -> DeviceConverterSchema: + """Return converter metadata used for source selection.""" + ... + + def reset(self) -> None: + """Drop accumulated device state at a session or rollout boundary.""" + ... + + def convert( + self, + user_inputs: UserInputs, + window: TimeWindow, + ) -> Mapping[str, Any] | None: + """Return the modality value for ``window``, or ``None`` if inactive. + + ``user_inputs`` is already filtered to ``window``. Returning ``None`` + lets a present-but-idle device yield to a lower-priority one. + """ + ... + + +DRIVER_COMMAND = CanonicalModality( + name="driver_command", + payload_fields=frozenset({"throttle", "brake", "steer", "stop", "reverse"}), + description=( + "Normalized driving intent. throttle/brake are in [0, 1], steer is in " + "[-1, 1] with positive meaning left." + ), +) + + +class KeyboardToDriverCommand: + """Convert keyboard edges into :data:`DRIVER_COMMAND` level state. + + Mirrors the mapping the Omnidreams interactive-drive keyboard backend + already uses, so a keyboard reaches a model through the shared layer with + the same semantics it has today. + """ + + def __init__( + self, + *, + name: str = "keyboard-to-driver-command", + bindings: DriverBindings = DEFAULT_DRIVING_BINDINGS, + priority: int = 0, + ) -> None: + unknown = sorted(set(bindings) - _DRIVER_ACTIONS) + if unknown: + raise ValueError( + f"Unknown driver actions in bindings: {unknown}. " + f"Supported actions: {sorted(_DRIVER_ACTIONS)}." + ) + self._bindings = { + action: frozenset(normalize_key(key) for key in bindings.get(action, ())) + for action in _DRIVER_ACTIONS + } + # Tracked keys are derived, so they cannot drift from the bindings and + # silently make an action unreachable. + self._supported_keys = frozenset( + key for keys in self._bindings.values() for key in keys + ) + self._state = KeyboardState(supported_keys=self._supported_keys) + self._schema = DeviceConverterSchema( + name=name, + produces=DRIVER_COMMAND, + device_kind="keyboard", + priority=priority, + consumes=( + UserInputCapability( + event_type="key_down", + payload_fields=frozenset({"key"}), + ), + UserInputCapability( + event_type="key_up", + payload_fields=frozenset({"key"}), + ), + ), + ) + + @property + def schema(self) -> DeviceConverterSchema: + return self._schema + + def reset(self) -> None: + self._state = KeyboardState(supported_keys=self._supported_keys) + + def convert( + self, + user_inputs: UserInputs, + window: TimeWindow, + ) -> Mapping[str, Any] | None: + del window + for event in user_inputs.events: + if event.event_type not in {"key_down", "key_up"}: + continue + key = event.payload.get("key") + if not isinstance(key, str): + continue + self._state.apply_event( + event="keydown" if event.event_type == "key_down" else "keyup", + key=key, + ) + + pressed = {normalize_key(key) for key in self._state.snapshot()} + + def held(action: str) -> bool: + return bool(self._bindings[action] & pressed) + + steer = 0.0 + if held("steer_left"): + steer += 1.0 + if held("steer_right"): + steer -= 1.0 + return DRIVER_COMMAND.value( + { + "throttle": 1.0 if held("throttle") else 0.0, + "brake": 1.0 if held("brake") else 0.0, + "steer": steer, + "stop": held("stop"), + "reverse": held("reverse"), + } + ) + + +class ScriptedModality: + """Emit pre-authored canonical values, for benchmarks, replay, and tests. + + Mocking input should not require knowing the raw device vocabulary. This + converter consumes no raw capabilities, so it is feedable by any source + -- including an empty :class:`UserInputSchema` -- and application code is + identical between a real run and a scripted one. + + ``timeline`` is ``(start_s, value)`` pairs. Values are level-triggered and + held until the next entry begins, matching how live converters behave. An + entry applies to a window once it has begun by the window's end, and + ``None`` is returned for windows before the first entry. + """ + + def __init__( + self, + *, + modality: CanonicalModality, + timeline: Sequence[tuple[float, Mapping[str, Any]]], + name: str | None = None, + device_kind: str | None = "scripted", + priority: int = 0, + ) -> None: + entries = tuple(sorted(timeline, key=lambda entry: entry[0])) + for start_s, value in entries: + if start_s < 0: + raise ValueError("timeline start_s must be >= 0.") + modality.value(value) + self._entries = tuple( + (start_s, modality.value(value)) for start_s, value in entries + ) + self._modality = modality + self._schema = DeviceConverterSchema( + name=name or f"scripted-{modality.name}", + produces=modality, + device_kind=device_kind, + priority=priority, + ) + + @property + def schema(self) -> DeviceConverterSchema: + return self._schema + + def reset(self) -> None: + # The timeline is a pure function of the window, so replay is + # deterministic without any state to clear. + return None + + def convert( + self, + user_inputs: UserInputs, + window: TimeWindow, + ) -> Mapping[str, Any] | None: + del user_inputs + current: Mapping[str, Any] | None = None + for start_s, value in self._entries: + if start_s < window.end_s: + current = value + else: + break + return current + + +class InputCanonicalizer: + """Registry of device converters plus the raw-to-canonical rewrite. + + Registration is the whole extension point: a new device is a converter + registered against an existing modality, and a new modality is a converter + registered with a new :class:`CanonicalModality`. + """ + + def __init__(self, converters: Iterable[DeviceConverter] = ()) -> None: + self._converters: list[DeviceConverter] = [] + for converter in converters: + self.register(converter) + + def register(self, converter: DeviceConverter) -> None: + """Register one device converter.""" + if not isinstance(converter, DeviceConverter): + raise TypeError("converter must implement the DeviceConverter protocol.") + name = converter.schema.name + if any(existing.schema.name == name for existing in self._converters): + raise ValueError( + f"A device converter named {name!r} is already registered." + ) + self._converters.append(converter) + + @property + def converters(self) -> tuple[DeviceConverter, ...]: + """Return every registered converter.""" + return tuple(self._converters) + + def reset(self) -> None: + """Reset every registered converter's device state.""" + for converter in self._converters: + converter.reset() + + def converters_for( + self, + source_schema: UserInputSchema, + ) -> tuple[DeviceConverter, ...]: + """Return converters this source can feed, highest priority first.""" + feedable = [ + converter + for converter in self._converters + if all( + source_schema.supports(capability) + for capability in converter.schema.consumes + ) + ] + # Sort is stable, so equal-priority converters keep registration order. + return tuple(sorted(feedable, key=lambda each: -each.schema.priority)) + + def unavailable_converters( + self, + source_schema: UserInputSchema, + ) -> tuple[DeviceConverter, ...]: + """Return converters this source cannot feed, for diagnostics.""" + feedable = {id(converter) for converter in self.converters_for(source_schema)} + return tuple( + converter for converter in self._converters if id(converter) not in feedable + ) + + def canonical_schema( + self, + source_schema: UserInputSchema, + ) -> CanonicalInputSchema: + """Return the canonical modalities this raw source can supply. + + This is the boundary an application declares against. A mapping that + consumes ``driver_command`` then matches a keyboard source, a wheel + source, or any device registered later. + """ + modalities: list[CanonicalModality] = [] + for converter in self.converters_for(source_schema): + modality = converter.schema.produces + if modality not in modalities: + modalities.append(modality) + return CanonicalInputSchema( + modalities=tuple(modalities), + description=source_schema.description, + ) + + def canonicalize( + self, + user_inputs: UserInputs, + *, + window: TimeWindow, + source_schema: UserInputSchema, + ) -> CanonicalInputs: + """Convert one raw window into canonical inputs. + + Every feedable converter sees the window so its device state stays + current even while another device has precedence; that way unplugging + the higher-priority device does not resume from stale state. Among + converters producing the same modality, the highest-priority one that + returned a value wins. + """ + windowed = user_inputs.window(window) + values: dict[str, Any] = {} + sources: dict[str, str] = {} + for converter in self.converters_for(source_schema): + value = converter.convert(windowed, window) + modality = converter.schema.produces + if value is not None and modality.name not in values: + values[modality.name] = value + if converter.schema.device_kind is not None: + sources[modality.name] = converter.schema.device_kind + + metadata: dict[str, Any] = {} + if sources: + metadata["canonical_sources"] = freeze_mapping(sources) + return CanonicalInputs(values=values, metadata=metadata) diff --git a/flashdreams/flashdreams/runtime/inputs.py b/flashdreams/flashdreams/runtime/inputs.py index e14b35722..f0be31bea 100644 --- a/flashdreams/flashdreams/runtime/inputs.py +++ b/flashdreams/flashdreams/runtime/inputs.py @@ -8,10 +8,29 @@ import math from collections.abc import Iterable, Mapping from dataclasses import dataclass, field -from typing import Any +from typing import Any, Literal, cast from flashdreams.runtime._utils import freeze_mapping +InputPhase = Literal["global", "step"] + +INPUT_PHASES: tuple[InputPhase, ...] = ("global", "step") + +SESSION_START_ONLY = "session_start" +"""``InputField.update_policy`` value meaning "supply at session start only". + +``update_policy`` is otherwise an open, adapter-owned vocabulary. This is the +one reserved token, because the runtime needs to distinguish a conditioning +value that can be swapped mid-rollout from one that cannot. +""" + + +def validate_phase(value: str) -> InputPhase: + """Return ``value`` as a validated :data:`InputPhase`.""" + if value not in INPUT_PHASES: + raise ValueError(f"phase must be 'global' or 'step', got {value!r}.") + return cast(InputPhase, value) + @dataclass(frozen=True, kw_only=True, slots=True) class TimeWindow: @@ -35,16 +54,70 @@ def contains(self, timestamp_s: float) -> bool: @dataclass(frozen=True, kw_only=True, slots=True) class InputField: - """Lightweight schema field for user snapshots or model inputs.""" + """Lightweight schema field for user snapshots or model inputs. + + ``update_policy`` and ``lifecycle`` are plain query metadata. They let a + model advertise facts such as "prompt updates land at step boundaries" or + "this value is consumed at cache init" without making this layer + responsible for implementing or deeply validating that behavior. + """ name: str required: bool = True semantic_type: str | None = None + update_policy: str | None = None + lifecycle: str | None = None + metadata: Mapping[str, Any] = field( + default_factory=dict, + compare=False, + hash=False, + ) description: str = "" def __post_init__(self) -> None: if not self.name.strip(): raise ValueError("InputField.name must be non-empty.") + object.__setattr__(self, "metadata", freeze_mapping(self.metadata)) + + +@dataclass(frozen=True, kw_only=True, slots=True) +class UserInputCapability: + """One user event a source or mapping can provide, at payload granularity. + + ``UserInputSchema.event_types`` declares only that an event type exists. A + capability additionally pins the payload fields carried by that event, so a + mapping can state that it needs ``key_down`` events that actually carry a + ``key``. + """ + + event_type: str + semantic_type: str | None = None + payload_fields: frozenset[str] = field(default_factory=frozenset) + metadata: Mapping[str, Any] = field( + default_factory=dict, + compare=False, + hash=False, + ) + description: str = "" + + def __post_init__(self) -> None: + if not self.event_type.strip(): + raise ValueError("UserInputCapability.event_type must be non-empty.") + for payload_field in self.payload_fields: + if not payload_field.strip(): + raise ValueError("payload field names must be non-empty.") + object.__setattr__(self, "metadata", freeze_mapping(self.metadata)) + + def is_satisfied_by(self, provider: "UserInputCapability") -> bool: + """Return whether ``provider`` can satisfy this consumed capability.""" + if self.event_type != provider.event_type: + return False + semantic_ok = ( + self.semantic_type is None + or provider.semantic_type is None + or self.semantic_type == provider.semantic_type + ) + return semantic_ok and self.payload_fields.issubset(provider.payload_fields) @dataclass(frozen=True, kw_only=True, slots=True) @@ -53,6 +126,7 @@ class UserInputSchema: event_types: frozenset[str] = field(default_factory=frozenset) snapshot_fields: tuple[InputField, ...] = () + capabilities: tuple[UserInputCapability, ...] = () description: str = "" def supports_event_types(self, event_types: Iterable[str]) -> bool: @@ -60,7 +134,63 @@ def supports_event_types(self, event_types: Iterable[str]) -> bool: requested = frozenset(event_types) if not requested: return True - return requested.issubset(self.event_types) + return requested.issubset(self.declared_event_types()) + + def declared_event_types(self) -> frozenset[str]: + """Return event types from ``event_types`` and from ``capabilities``.""" + return self.event_types | frozenset( + capability.event_type for capability in self.capabilities + ) + + def declared_capabilities(self) -> tuple[UserInputCapability, ...]: + """Return capabilities, widened with bare ``event_types`` entries. + + A plain ``event_types`` entry carries no payload promise, so it is + modeled as a capability with no payload fields. Coarse schemas written + before capabilities existed therefore still satisfy any consumer that + does not require specific payload fields. + """ + declared = list(self.capabilities) + covered = {capability.event_type for capability in declared} + declared.extend( + UserInputCapability(event_type=event_type) + for event_type in sorted(self.event_types - covered) + ) + return tuple(declared) + + def supports(self, capability: UserInputCapability) -> bool: + """Return whether this source can satisfy ``capability``.""" + return any( + capability.is_satisfied_by(provider) + for provider in self.declared_capabilities() + ) + + def validate_event(self, event: "UserInputEvent") -> None: + """Validate one event against the event types this source declares.""" + matching = [ + capability + for capability in self.declared_capabilities() + if capability.event_type == event.event_type + ] + if not matching: + raise ValueError( + f"User input source does not provide event type {event.event_type!r}." + ) + payload_keys = set(event.payload) + if not any( + capability.payload_fields.issubset(payload_keys) for capability in matching + ): + expected = sorted( + { + payload_field + for capability in matching + for payload_field in capability.payload_fields + } + ) + raise ValueError( + f"Event {event.event_type!r} payload is missing required " + f"fields: {expected}." + ) def missing_snapshot(self, inputs: "UserInputs") -> tuple[str, ...]: """Return required snapshot fields absent from ``inputs``.""" @@ -74,10 +204,10 @@ def require_snapshot(self, inputs: "UserInputs") -> None: @dataclass(frozen=True, kw_only=True, slots=True) -class ModelInputSchema: +class InferenceInputSchema: """Minimal metadata for model-facing initial and per-step inputs.""" - initial_fields: tuple[InputField, ...] = () + global_fields: tuple[InputField, ...] = () """Model inputs required before starting the initial generation/session.""" step_fields: tuple[InputField, ...] = () @@ -85,21 +215,81 @@ class ModelInputSchema: description: str = "" - def missing_initial(self, inputs: "ModelInputs") -> tuple[str, ...]: + def fields_for(self, phase: InputPhase) -> tuple[InputField, ...]: + """Return every declared field for ``phase``.""" + return ( + self.global_fields + if validate_phase(phase) == "global" + else self.step_fields + ) + + def required_fields( + self, + phase: InputPhase | None = None, + ) -> tuple[tuple[InputPhase, InputField], ...]: + """Return required fields as ``(phase, field)``, optionally filtered.""" + return self._select(phase, required=True) + + def optional_fields( + self, + phase: InputPhase | None = None, + ) -> tuple[tuple[InputPhase, InputField], ...]: + """Return optional fields as ``(phase, field)``, optionally filtered.""" + return self._select(phase, required=False) + + def field_for(self, *, name: str, phase: InputPhase) -> InputField | None: + """Return one declared field, if present.""" + for input_field in self.fields_for(phase): + if input_field.name == name: + return input_field + return None + + def _select( + self, + phase: InputPhase | None, + *, + required: bool, + ) -> tuple[tuple[InputPhase, InputField], ...]: + phases = INPUT_PHASES if phase is None else (validate_phase(phase),) + return tuple( + (each_phase, input_field) + for each_phase in phases + for input_field in self.fields_for(each_phase) + if input_field.required is required + ) + + def unsupported_global_updates(self, inputs: "InferenceInput") -> tuple[str, ...]: + """Return requested conditioning updates this model cannot apply. + + A field whose ``update_policy`` is :data:`SESSION_START_ONLY` can be + supplied when the session starts but not changed mid-rollout. Any other + policy, including ``None``, is treated as permissive here; the adapter + still owns whether the swap actually succeeds. + """ + return tuple( + name + for name in inputs.global_conditioning + if (declared := self.field_for(name=name, phase="global")) is not None + and declared.update_policy == SESSION_START_ONLY + ) + + def missing_global(self, inputs: "InferenceInput") -> tuple[str, ...]: """Return required initial fields absent from ``inputs``.""" - return _missing_required(self.initial_fields, inputs.initial) + return _missing_required(self.global_fields, inputs.global_conditioning) - def missing_step(self, inputs: "ModelInputs") -> tuple[str, ...]: + def missing_step(self, inputs: "InferenceInput") -> tuple[str, ...]: """Return required per-step fields absent from ``inputs``.""" return _missing_required(self.step_fields, inputs.step) - def require_initial(self, inputs: "ModelInputs") -> None: + def require_global(self, inputs: "InferenceInput") -> None: """Raise if required initial fields are absent.""" - missing = self.missing_initial(inputs) + missing = self.missing_global(inputs) if missing: - raise ValueError(f"Missing required initial model input(s): {missing}") + raise ValueError( + f"Missing required global conditioning input(s): {missing}" + ) - def require_step(self, inputs: "ModelInputs") -> None: + def require_step(self, inputs: "InferenceInput") -> None: """Raise if required per-step fields are absent.""" missing = self.missing_step(inputs) if missing: @@ -171,23 +361,154 @@ def window(self, time_window: TimeWindow) -> "UserInputs": @dataclass(frozen=True, kw_only=True, slots=True) -class ModelInputs: - """Model-facing payloads split by initial and per-step use.""" +class CanonicalModality: + """A device-independent user input an application consumes. + + This is the middle layer of ``raw input -> canonicalized input -> encoded + inference input``. Applications and benchmarks declare and consume + modalities; they never read raw device events, so adding a new device is a + converter registration rather than an application change. + + Modalities describe live user control only. Global conditioning such as a + prompt or conditioning frame is application-owned and reaches + :class:`InferenceInput` directly, without passing through this layer. + """ + + name: str + payload_fields: frozenset[str] = field(default_factory=frozenset) + metadata: Mapping[str, Any] = field( + default_factory=dict, + compare=False, + hash=False, + ) + description: str = "" + + def __post_init__(self) -> None: + if not self.name.strip(): + raise ValueError("CanonicalModality.name must be non-empty.") + for payload_field in self.payload_fields: + if not payload_field.strip(): + raise ValueError("payload field names must be non-empty.") + object.__setattr__(self, "metadata", freeze_mapping(self.metadata)) + + def is_satisfied_by(self, provider: "CanonicalModality") -> bool: + """Return whether ``provider`` can satisfy this consumed modality.""" + return self.name == provider.name and self.payload_fields.issubset( + provider.payload_fields + ) + + def value(self, payload: Mapping[str, Any]) -> Mapping[str, Any]: + """Return ``payload`` frozen, checking it covers this modality.""" + missing = sorted(self.payload_fields - set(payload)) + if missing: + raise ValueError( + f"Canonical modality {self.name!r} requires payload fields " + f"{missing}, which the converter did not produce." + ) + return freeze_mapping(payload) + + +@dataclass(frozen=True, kw_only=True, slots=True) +class CanonicalInputSchema: + """Canonical modalities an application can be fed by a given source.""" + + modalities: tuple[CanonicalModality, ...] = () + description: str = "" + + def supports(self, modality: CanonicalModality) -> bool: + """Return whether this source can supply ``modality``.""" + return any(modality.is_satisfied_by(provided) for provided in self.modalities) + + +@dataclass(frozen=True, kw_only=True, slots=True) +class CanonicalInputs: + """Canonicalized user input for one step, keyed by modality name. + + Values are level-triggered and normally present every step: a key held down + emits no events but still means full throttle. Global conditioning does not + appear here; it is application-owned and reaches :class:`InferenceInput` + directly. + """ __hash__ = None - initial: Mapping[str, Any] = field(default_factory=dict) + values: Mapping[str, Any] = field(default_factory=dict) + metadata: Mapping[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + object.__setattr__(self, "values", freeze_mapping(self.values)) + object.__setattr__(self, "metadata", freeze_mapping(self.metadata)) + + +@dataclass(frozen=True, kw_only=True, slots=True) +class InferenceInput: + """Encoded inputs for one :class:`InferenceSession` call. + + Two conditioning slots: + + - ``global_conditioning``: values that condition the whole rollout, such as + the conditioning frame or prompt. Normally supplied when the session + starts. + - ``step``: values needed to generate the next chunk or frame. + + A non-empty ``global_conditioning`` on a mid-rollout input is an *update + request*, not a reset. The session should apply it when the model supports + that; resetting rollout state is a separate, explicit + :meth:`InferenceSession.reset` call. Whether a given value can be updated + mid-rollout is declared per field by ``InputField.update_policy``; see + :meth:`InferenceInputSchema.unsupported_global_updates`. + """ + + __hash__ = None + + global_conditioning: Mapping[str, Any] = field(default_factory=dict) step: Mapping[str, Any] = field(default_factory=dict) metadata: Mapping[str, Any] = field(default_factory=dict) def __post_init__(self) -> None: - object.__setattr__(self, "initial", freeze_mapping(self.initial)) + object.__setattr__( + self, "global_conditioning", freeze_mapping(self.global_conditioning) + ) object.__setattr__(self, "step", freeze_mapping(self.step)) object.__setattr__(self, "metadata", freeze_mapping(self.metadata)) - def with_step(self, step: Mapping[str, Any]) -> "ModelInputs": - """Return a copy with replaced per-step payload.""" - return ModelInputs(initial=self.initial, step=step, metadata=self.metadata) + @property + def requests_global_update(self) -> bool: + """Return whether this input asks the session to update conditioning.""" + return bool(self.global_conditioning) + + def with_step(self, step: Mapping[str, Any]) -> "InferenceInput": + """Return a copy with replaced per-step payload. + + The global slot is carried through unchanged, so a mid-rollout input + built this way keeps whatever update request it already had. Use + :meth:`without_global_update` for the common steady-state case. + """ + return InferenceInput( + global_conditioning=self.global_conditioning, + step=step, + metadata=self.metadata, + ) + + def with_global_update( + self, global_conditioning: Mapping[str, Any] + ) -> "InferenceInput": + """Return a copy requesting a mid-rollout conditioning update.""" + return InferenceInput( + global_conditioning=global_conditioning, + step=self.step, + metadata=self.metadata, + ) + + def without_global_update(self) -> "InferenceInput": + """Return a copy that requests no conditioning update.""" + return InferenceInput(step=self.step, metadata=self.metadata) + + def for_phase(self, phase: InputPhase) -> Mapping[str, Any]: + """Return the payload mapping for ``phase``.""" + return ( + self.global_conditioning if validate_phase(phase) == "global" else self.step + ) def _missing_required( diff --git a/flashdreams/flashdreams/runtime/interfaces.py b/flashdreams/flashdreams/runtime/interfaces.py index 9b6a064fd..852a77f1c 100644 --- a/flashdreams/flashdreams/runtime/interfaces.py +++ b/flashdreams/flashdreams/runtime/interfaces.py @@ -9,9 +9,9 @@ from flashdreams.runtime.config import InferenceConfig from flashdreams.runtime.inputs import ( - ModelInputs, - ModelInputSchema, - UserInputSchema, + CanonicalInputSchema, + InferenceInput, + InferenceInputSchema, ) from flashdreams.runtime.mapping import InputMapping from flashdreams.runtime.types import StepRequest, StepResult @@ -25,11 +25,11 @@ def next_step_request(self) -> StepRequest | None: """Describe the next step's inputs, or return ``None`` when complete.""" ... - def step(self, inputs: ModelInputs) -> StepResult: + def step(self, inputs: InferenceInput) -> StepResult: """Run one sequential inference step.""" ... - def reset(self, inputs: ModelInputs | None = None) -> None: + def reset(self, inputs: InferenceInput | None = None) -> None: """Reset this session's rollout state when the backend supports it.""" ... @@ -42,8 +42,8 @@ def close(self) -> None: class InferenceRuntime(Protocol): """Heavyweight reusable runtime created from :class:`InferenceConfig`.""" - def start_session(self, inputs: ModelInputs) -> InferenceSession: - """Create an isolated session from initial model inputs.""" + def start_session(self, inputs: InferenceInput) -> InferenceSession: + """Create an isolated session from global conditioning inputs.""" ... def close(self) -> None: @@ -56,10 +56,10 @@ def close(self) -> None: class ModelAdapter(Protocol): """Model-specific boundary that declares defaults and creates runtimes. - Adapters declare model-facing input requirements, optional user-input - capabilities, and an optional default mapping between the two. Runtime, - application, or benchmark code may override that mapping while preserving the - same ``UserInputs`` to ``ModelInputs`` boundary. + Adapters declare model-facing input requirements, the canonical modalities + their default mapping consumes, and an optional default mapping between the + two. Runtime, application, or benchmark code may override that mapping while + preserving the same ``CanonicalInputs`` to ``InferenceInput`` boundary. """ @property @@ -68,17 +68,17 @@ def model_id(self) -> str: ... @property - def model_input_schema(self) -> ModelInputSchema: + def inference_input_schema(self) -> InferenceInputSchema: """Model-facing initial and per-step input requirements.""" ... @property - def user_input_schema(self) -> UserInputSchema | None: - """User inputs supported by the adapter's default mapping, if any.""" + def canonical_input_schema(self) -> CanonicalInputSchema | None: + """Canonical modalities the adapter's default mapping consumes.""" ... def default_input_mapping(self) -> InputMapping | None: - """Return the model-provided default user-to-model mapping, if any.""" + """Return the model-provided default canonical-to-model mapping.""" ... def validate_config(self, config: InferenceConfig) -> None: diff --git a/flashdreams/flashdreams/runtime/mapping.py b/flashdreams/flashdreams/runtime/mapping.py index 756351081..94f481406 100644 --- a/flashdreams/flashdreams/runtime/mapping.py +++ b/flashdreams/flashdreams/runtime/mapping.py @@ -1,17 +1,24 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Input mapping boundary from user input windows to model inputs.""" +"""Input mapping boundary from canonical inputs to encoded inference inputs.""" from __future__ import annotations -from typing import Protocol, runtime_checkable +from collections.abc import Mapping, Sequence +from dataclasses import dataclass, field, replace +from typing import Any, Protocol, runtime_checkable +from flashdreams.runtime._utils import freeze_mapping from flashdreams.runtime.inputs import ( - ModelInputs, - ModelInputSchema, - UserInputs, - UserInputSchema, + INPUT_PHASES, + CanonicalInputs, + CanonicalInputSchema, + CanonicalModality, + InferenceInput, + InferenceInputSchema, + InputField, + InputPhase, ) from flashdreams.runtime.types import StepRequest @@ -28,28 +35,28 @@ class InputMapping(Protocol): def validate( self, *, - user_schema: UserInputSchema | None = None, - model_schema: ModelInputSchema | None = None, + canonical_schema: CanonicalInputSchema | None = None, + inference_input_schema: InferenceInputSchema | None = None, ) -> None: """Fail early for obvious app, event-source, and model mismatches.""" ... - def map_initial_inputs( + def map_global_inputs( self, *, - user_inputs: UserInputs, - model_inputs: ModelInputs, - ) -> ModelInputs: - """Build initial model inputs before a session starts.""" + canonical_inputs: CanonicalInputs, + inference_input: InferenceInput, + ) -> InferenceInput: + """Build global conditioning inputs before a session starts.""" ... def map_step_inputs( self, *, - user_inputs: UserInputs, - model_inputs: ModelInputs, + canonical_inputs: CanonicalInputs, + inference_input: InferenceInput, request: StepRequest, - ) -> ModelInputs: + ) -> InferenceInput: """Build model inputs for one session step from the current input window.""" ... @@ -60,26 +67,315 @@ class IdentityInputMapping: def validate( self, *, - user_schema: UserInputSchema | None = None, - model_schema: ModelInputSchema | None = None, + canonical_schema: CanonicalInputSchema | None = None, + inference_input_schema: InferenceInputSchema | None = None, ) -> None: - del user_schema, model_schema + del canonical_schema, inference_input_schema - def map_initial_inputs( + def map_global_inputs( self, *, - user_inputs: UserInputs, - model_inputs: ModelInputs, - ) -> ModelInputs: - del user_inputs - return model_inputs + canonical_inputs: CanonicalInputs, + inference_input: InferenceInput, + ) -> InferenceInput: + del canonical_inputs + return inference_input def map_step_inputs( self, *, - user_inputs: UserInputs, - model_inputs: ModelInputs, + canonical_inputs: CanonicalInputs, + inference_input: InferenceInput, request: StepRequest, - ) -> ModelInputs: - del user_inputs, request - return model_inputs + ) -> InferenceInput: + del canonical_inputs, request + return inference_input + + +@dataclass(frozen=True, kw_only=True, slots=True) +class InputMappingSchema: + """Declarative compatibility surface for one mapping. + + ``InputMapping.validate`` fails a run late and opaquely: it raises, but it + cannot answer which optional model inputs a source would enable, or which + missing user capability is responsible for an unreachable model input. This + schema makes those questions answerable before runtime initialization. + """ + + name: str = "input-mapping" + consumes: tuple[CanonicalModality, ...] = () + produces_global: tuple[InputField, ...] = () + produces_step: tuple[InputField, ...] = () + metadata: Mapping[str, Any] = field( + default_factory=dict, + compare=False, + hash=False, + ) + + def __post_init__(self) -> None: + if not self.name.strip(): + raise ValueError("InputMappingSchema.name must be non-empty.") + object.__setattr__(self, "metadata", freeze_mapping(self.metadata)) + + def produces_for(self, phase: InputPhase) -> tuple[InputField, ...]: + """Return the fields this mapping produces for ``phase``.""" + return self.produces_global if phase == "global" else self.produces_step + + def can_produce(self, phase: InputPhase, required: InputField) -> bool: + """Return whether this mapping can produce ``required`` in ``phase``.""" + return any( + _field_matches(produced, required) for produced in self.produces_for(phase) + ) + + +def _field_matches(produced: InputField, required: InputField) -> bool: + if produced.name != required.name: + return False + semantic_ok = ( + produced.semantic_type is None + or required.semantic_type is None + or produced.semantic_type == required.semantic_type + ) + lifecycle_ok = ( + produced.lifecycle is None + or required.lifecycle is None + or produced.lifecycle == required.lifecycle + ) + return semantic_ok and lifecycle_ok + + +@dataclass(frozen=True, kw_only=True, slots=True) +class MappingCompatibility: + """Compatibility report for one source, model schema, and mapping set. + + Mappings whose consumed capabilities the source cannot provide are reported + in ``unavailable_mapping_schemas`` and excluded from the satisfied/available + reports, so those lists only name model inputs that can really be produced. + """ + + __hash__ = None + + canonical_schema: CanonicalInputSchema + inference_input_schema: InferenceInputSchema + mapping_schema: InputMappingSchema + missing_modalities: tuple[CanonicalModality, ...] = () + missing_required_model_fields: tuple[tuple[InputPhase, InputField], ...] = () + satisfied_required_model_fields: tuple[tuple[InputPhase, InputField], ...] = () + available_optional_model_fields: tuple[tuple[InputPhase, InputField], ...] = () + unavailable_mapping_schemas: tuple[InputMappingSchema, ...] = () + + @property + def can_drive(self) -> bool: + """Return whether this source can drive this model through the mapping. + + A mapping the source cannot feed does not block the run unless it was + the only way to produce a required model input. + """ + return not (self.missing_required_model_fields or self.missing_modalities) + + @property + def unavailable_mapping_names(self) -> tuple[str, ...]: + """Return names of mappings dropped because the source cannot feed them.""" + return tuple(schema.name for schema in self.unavailable_mapping_schemas) + + def raise_if_incompatible(self) -> None: + """Raise a compact error when this mapping cannot drive the model.""" + if self.can_drive: + return + problems: list[str] = [] + if self.missing_modalities: + missing = ", ".join(modality.name for modality in self.missing_modalities) + problems.append(f"missing canonical modalities: {missing}") + if self.missing_required_model_fields: + missing = ", ".join( + f"{phase}:{input_field.name}" + for phase, input_field in self.missing_required_model_fields + ) + problems.append(f"missing required model inputs: {missing}") + if self.unavailable_mapping_schemas: + problems.append( + "unavailable mappings: " + ", ".join(self.unavailable_mapping_names) + ) + raise ValueError( + f"Input mapping {self.mapping_schema.name!r} cannot drive this model " + f"from the selected source: " + "; ".join(problems) + ) + + +def _source_can_feed( + canonical_schema: CanonicalInputSchema, + mapping_schema: InputMappingSchema, +) -> bool: + return all( + canonical_schema.supports(modality) for modality in mapping_schema.consumes + ) + + +def combine_mapping_schemas( + mapping_schemas: Sequence[InputMappingSchema], + *, + name: str = "input-mapping-set", +) -> InputMappingSchema: + """Combine independently declared mappings into one compatibility surface. + + Duplicates are collapsed. Because ``metadata`` is excluded from equality, + the metadata of collapsed duplicates is merged rather than dropped, with the + first declaration winning on conflicting keys. + """ + consumes: list[CanonicalModality] = [] + produces: dict[InputPhase, list[InputField]] = {"global": [], "step": []} + + def _merge(target: list[Any], value: Any) -> None: + for index, existing in enumerate(target): + if existing == value: + if value.metadata: + target[index] = replace( + existing, + metadata={**dict(value.metadata), **dict(existing.metadata)}, + ) + return + target.append(value) + + for mapping_schema in mapping_schemas: + if not isinstance(mapping_schema, InputMappingSchema): + raise TypeError("mapping_schemas must contain InputMappingSchema objects.") + for modality in mapping_schema.consumes: + _merge(consumes, modality) + for phase in INPUT_PHASES: + for input_field in mapping_schema.produces_for(phase): + _merge(produces[phase], input_field) + + return InputMappingSchema( + name=name, + consumes=tuple(consumes), + produces_global=tuple(produces["global"]), + produces_step=tuple(produces["step"]), + ) + + +def _build_compatibility( + *, + canonical_schema: CanonicalInputSchema, + inference_input_schema: InferenceInputSchema, + mapping_schemas: Sequence[InputMappingSchema], + reported_schema: InputMappingSchema, +) -> MappingCompatibility: + feedable: list[InputMappingSchema] = [] + unavailable: list[InputMappingSchema] = [] + for mapping_schema in mapping_schemas: + if _source_can_feed(canonical_schema, mapping_schema): + feedable.append(mapping_schema) + else: + unavailable.append(mapping_schema) + + usable = combine_mapping_schemas(feedable, name=reported_schema.name) + required = inference_input_schema.required_fields() + missing_required = tuple( + (phase, input_field) + for phase, input_field in required + if not usable.can_produce(phase, input_field) + ) + satisfied_required = tuple( + (phase, input_field) + for phase, input_field in required + if usable.can_produce(phase, input_field) + ) + available_optional = tuple( + (phase, input_field) + for phase, input_field in inference_input_schema.optional_fields() + if usable.can_produce(phase, input_field) + ) + + # Only capabilities that block a required model input make the mapping + # unusable. A dropped mapping that fed nothing but optional fields degrades + # the run instead of vetoing it. + missing_modalities: list[CanonicalModality] = [] + for mapping_schema in unavailable: + if not any( + mapping_schema.can_produce(phase, input_field) + for phase, input_field in missing_required + ): + continue + for modality in mapping_schema.consumes: + if canonical_schema.supports(modality) or modality in missing_modalities: + continue + missing_modalities.append(modality) + + return MappingCompatibility( + canonical_schema=canonical_schema, + inference_input_schema=inference_input_schema, + mapping_schema=reported_schema, + missing_modalities=tuple(missing_modalities), + missing_required_model_fields=missing_required, + satisfied_required_model_fields=satisfied_required, + available_optional_model_fields=available_optional, + unavailable_mapping_schemas=tuple(unavailable), + ) + + +def check_mapping_compatibility( + *, + canonical_schema: CanonicalInputSchema, + inference_input_schema: InferenceInputSchema, + mapping_schema: InputMappingSchema, +) -> MappingCompatibility: + """Check whether a user-input source can drive a model through a mapping.""" + if not isinstance(mapping_schema, InputMappingSchema): + raise TypeError("mapping_schema must be an InputMappingSchema object.") + return _build_compatibility( + canonical_schema=canonical_schema, + inference_input_schema=inference_input_schema, + mapping_schemas=(mapping_schema,), + reported_schema=mapping_schema, + ) + + +def check_mapping_set_compatibility( + *, + canonical_schema: CanonicalInputSchema, + inference_input_schema: InferenceInputSchema, + mapping_schemas: Sequence[InputMappingSchema], + name: str = "input-mapping-set", +) -> MappingCompatibility: + """Check compatibility for a composed set of mappings. + + Each mapping keeps its own consumes/produces link, so a mapping the source + cannot feed only costs the model inputs that mapping produced. + """ + mapping_schemas = tuple(mapping_schemas) + return _build_compatibility( + canonical_schema=canonical_schema, + inference_input_schema=inference_input_schema, + mapping_schemas=mapping_schemas, + reported_schema=combine_mapping_schemas(mapping_schemas, name=name), + ) + + +def undeclared_inference_inputs( + inputs: InferenceInput, + mapping_schema: InputMappingSchema, +) -> tuple[tuple[InputPhase, str], ...]: + """Return payload keys a mapping produced but did not declare. + + Mapping schemas are hand-written, so they drift from what + ``map_global_inputs``/``map_step_inputs`` actually return. Mapping tests + can use this to keep the declared compatibility surface honest. + """ + return tuple( + (phase, key) + for phase in INPUT_PHASES + for key in inputs.for_phase(phase) + if not any( + declared.name == key for declared in mapping_schema.produces_for(phase) + ) + ) + + +@runtime_checkable +class DeclaresMappingSchema(Protocol): + """Optional refinement of :class:`InputMapping` that declares its surface.""" + + @property + def mapping_schema(self) -> InputMappingSchema: + """Return the declarative compatibility surface for this mapping.""" + ... diff --git a/flashdreams/flashdreams/runtime/types.py b/flashdreams/flashdreams/runtime/types.py index 52bf82166..467753026 100644 --- a/flashdreams/flashdreams/runtime/types.py +++ b/flashdreams/flashdreams/runtime/types.py @@ -10,7 +10,7 @@ from typing import Any from flashdreams.runtime._utils import freeze_mapping -from flashdreams.runtime.inputs import ModelInputSchema, TimeWindow +from flashdreams.runtime.inputs import InferenceInputSchema, TimeWindow @dataclass(frozen=True, kw_only=True, slots=True) @@ -24,7 +24,7 @@ class StepRequest: __hash__ = None step_index: int - model_input_schema: ModelInputSchema | None = None + inference_input_schema: InferenceInputSchema | None = None user_input_window: TimeWindow | None = None metadata: Mapping[str, Any] = field(default_factory=dict) diff --git a/flashdreams/tests/test_inference_runtime_api.py b/flashdreams/tests/test_inference_runtime_api.py index 1474383a0..edfafa634 100644 --- a/flashdreams/tests/test_inference_runtime_api.py +++ b/flashdreams/tests/test_inference_runtime_api.py @@ -9,17 +9,20 @@ import pytest from flashdreams.runtime import ( + CanonicalInputs, + CanonicalInputSchema, IdentityInputMapping, InferenceConfig, + InferenceInput, + InferenceInputSchema, InferenceRuntime, InferenceSession, InMemoryMetricsRecorder, + InputCanonicalizer, InputField, InputMapping, MetricsRecorder, ModelAdapter, - ModelInputs, - ModelInputSchema, NullOutputTarget, OutputArtifact, OutputTarget, @@ -27,6 +30,7 @@ StepRequest, StepResult, TimeWindow, + UserInputCapability, UserInputEvent, UserInputs, UserInputSchema, @@ -35,6 +39,18 @@ pytestmark = pytest.mark.ci_cpu +_SESSION_HORIZON_S = 3600.0 + +_KEYBOARD_SOURCE = UserInputSchema( + capabilities=( + UserInputCapability( + event_type="keyboard.keydown", payload_fields=frozenset({"key"}) + ), + ) +) +_KEYBOARD_CANONICALIZER = InputCanonicalizer() + + def test_inference_config_keeps_runtime_settings_separate() -> None: denied_app_fields = {"prompt", "output_dir", "browser_settings"} config = InferenceConfig( @@ -90,17 +106,19 @@ def test_runtime_metric_sample_rejects_bool_values() -> None: RuntimeMetricSample(name="sample", value=True) -def test_model_input_schema_validates_initial_and_step_payloads() -> None: - schema = ModelInputSchema( - initial_fields=( +def test_inference_input_schema_validates_initial_and_step_payloads() -> None: + schema = InferenceInputSchema( + global_fields=( InputField(name="prompt"), - InputField(name="first_frame"), + InputField(name="global_conditioning_frame"), ), step_fields=(InputField(name="camera_poses"),), ) - inputs = ModelInputs(initial={"prompt": "drive", "first_frame": object()}) + inputs = InferenceInput( + global_conditioning={"prompt": "drive", "global_conditioning_frame": object()} + ) - schema.require_initial(inputs) + schema.require_global(inputs) assert schema.missing_step(inputs) == ("camera_poses",) with pytest.raises(ValueError, match="camera_poses"): @@ -164,25 +182,27 @@ def test_user_input_schema_validates_required_snapshot_fields() -> None: schema.require_snapshot(UserInputs()) -def test_identity_input_mapping_leaves_model_inputs_unchanged() -> None: +def test_identity_input_mapping_leaves_inference_input_unchanged() -> None: mapping = IdentityInputMapping() - model_inputs = ModelInputs(initial={"prompt": "fixed"}, step={"hdmap": object()}) + inference_input = InferenceInput( + global_conditioning={"prompt": "fixed"}, step={"hdmap": object()} + ) request = StepRequest(step_index=0) assert ( - mapping.map_initial_inputs( - user_inputs=UserInputs(), - model_inputs=model_inputs, + mapping.map_global_inputs( + canonical_inputs=CanonicalInputs(), + inference_input=inference_input, ) - is model_inputs + is inference_input ) assert ( mapping.map_step_inputs( - user_inputs=UserInputs(), - model_inputs=model_inputs, + canonical_inputs=CanonicalInputs(), + inference_input=inference_input, request=request, ) - is model_inputs + is inference_input ) @@ -258,7 +278,7 @@ def test_runtime_api_components_compose_for_sequential_session() -> None: ), ) ) - model_inputs = ModelInputs(initial={"prompt": "drive forward"}) + inference_input = InferenceInput(global_conditioning={"prompt": "drive forward"}) output = NullOutputTarget(store_results=True) metrics = InMemoryMetricsRecorder() @@ -269,8 +289,10 @@ def test_runtime_api_components_compose_for_sequential_session() -> None: adapter=adapter, config=config, mapping=mapping, + canonicalizer=_KEYBOARD_CANONICALIZER, + source_schema=_KEYBOARD_SOURCE, user_inputs=user_inputs, - model_inputs=model_inputs, + inference_input=inference_input, output=output, metrics=metrics, ) @@ -291,8 +313,10 @@ def test_reference_loop_validates_mapping_before_runtime_creation() -> None: adapter=adapter, config=InferenceConfig(model_id="fake-model"), mapping=mapping, + canonicalizer=_KEYBOARD_CANONICALIZER, + source_schema=_KEYBOARD_SOURCE, user_inputs=UserInputs(), - model_inputs=ModelInputs(initial={"prompt": "drive forward"}), + inference_input=InferenceInput(global_conditioning={"prompt": "drive forward"}), output=NullOutputTarget(), metrics=InMemoryMetricsRecorder(), ) @@ -311,8 +335,12 @@ def test_reference_loop_closes_runtime_when_session_start_fails() -> None: adapter=adapter, config=InferenceConfig(model_id="fake-model"), mapping=IdentityInputMapping(), + canonicalizer=_KEYBOARD_CANONICALIZER, + source_schema=_KEYBOARD_SOURCE, user_inputs=UserInputs(), - model_inputs=ModelInputs(initial={"prompt": "drive forward"}), + inference_input=InferenceInput( + global_conditioning={"prompt": "drive forward"} + ), output=output, metrics=metrics, ) @@ -328,18 +356,24 @@ def _drive_two_step_session( adapter: ModelAdapter, config: InferenceConfig, mapping: InputMapping, + canonicalizer: InputCanonicalizer, + source_schema: UserInputSchema, user_inputs: UserInputs, - model_inputs: ModelInputs, + inference_input: InferenceInput, output: OutputTarget, metrics: MetricsRecorder, ) -> None: mapping.validate( - user_schema=adapter.user_input_schema, - model_schema=adapter.model_input_schema, + canonical_schema=adapter.canonical_input_schema, + inference_input_schema=adapter.inference_input_schema, ) - initial_inputs = mapping.map_initial_inputs( - user_inputs=user_inputs, - model_inputs=model_inputs, + initial_inputs = mapping.map_global_inputs( + canonical_inputs=canonicalizer.canonicalize( + user_inputs, + window=TimeWindow(start_s=0.0, end_s=_SESSION_HORIZON_S), + source_schema=source_schema, + ), + inference_input=inference_input, ) runtime = adapter.create_runtime(config) session: InferenceSession | None = None @@ -350,13 +384,16 @@ def _drive_two_step_session( output_opened = True while (request := session.next_step_request()) is not None: step_inputs = mapping.map_step_inputs( - user_inputs=( - user_inputs.window(request.user_input_window) - if request.user_input_window is not None - else user_inputs + canonical_inputs=canonicalizer.canonicalize( + user_inputs, + window=request.user_input_window + or TimeWindow(start_s=0.0, end_s=_SESSION_HORIZON_S), + source_schema=source_schema, ), - model_inputs=ModelInputs( - initial=initial_inputs.initial, + # The global slot stays empty in steady state. A mapping that + # sees ``canonical_inputs.has_global_change`` fills it via + # ``with_global_update`` to request a mid-rollout swap. + inference_input=InferenceInput( step={"chunk_index": request.step_index}, ), request=request, @@ -379,11 +416,11 @@ def _drive_two_step_session( class _FakeAdapter: model_id = "fake-model" - model_input_schema = ModelInputSchema( - initial_fields=(InputField(name="prompt"),), + inference_input_schema = InferenceInputSchema( + global_fields=(InputField(name="prompt"),), step_fields=(InputField(name="chunk_index"),), ) - user_input_schema = UserInputSchema(event_types=frozenset({"keyboard.keydown"})) + canonical_input_schema = CanonicalInputSchema() def default_input_mapping(self) -> InputMapping: return IdentityInputMapping() @@ -394,31 +431,31 @@ def validate_config(self, config: InferenceConfig) -> None: def create_runtime(self, config: InferenceConfig) -> InferenceRuntime: self.validate_config(config) - return _FakeRuntime(model_input_schema=self.model_input_schema) + return _FakeRuntime(inference_input_schema=self.inference_input_schema) class _FakeRuntime: - def __init__(self, *, model_input_schema: ModelInputSchema) -> None: - self._model_input_schema = model_input_schema + def __init__(self, *, inference_input_schema: InferenceInputSchema) -> None: + self._inference_input_schema = inference_input_schema self.closed = False - def start_session(self, inputs: ModelInputs) -> InferenceSession: - self._model_input_schema.require_initial(inputs) - return _FakeSession(model_input_schema=self._model_input_schema) + def start_session(self, inputs: InferenceInput) -> InferenceSession: + self._inference_input_schema.require_global(inputs) + return _FakeSession(inference_input_schema=self._inference_input_schema) def close(self) -> None: self.closed = True class _FailingRuntime(_FakeRuntime): - def start_session(self, inputs: ModelInputs) -> InferenceSession: + def start_session(self, inputs: InferenceInput) -> InferenceSession: del inputs raise RuntimeError("start failed") class _FakeSession: - def __init__(self, *, model_input_schema: ModelInputSchema) -> None: - self._model_input_schema = model_input_schema + def __init__(self, *, inference_input_schema: InferenceInputSchema) -> None: + self._inference_input_schema = inference_input_schema self.step_index = 0 self.closed = False @@ -427,15 +464,15 @@ def next_step_request(self) -> StepRequest | None: return None return StepRequest( step_index=self.step_index, - model_input_schema=self._model_input_schema, + inference_input_schema=self._inference_input_schema, user_input_window=TimeWindow( start_s=0.5 * self.step_index, end_s=0.5 * (self.step_index + 1), ), ) - def step(self, inputs: ModelInputs) -> StepResult: - self._model_input_schema.require_step(inputs) + def step(self, inputs: InferenceInput) -> StepResult: + self._inference_input_schema.require_step(inputs) result = StepResult( step_index=self.step_index, output=f"chunk-{self.step_index}", @@ -449,7 +486,7 @@ def step(self, inputs: ModelInputs) -> StepResult: self.step_index += 1 return result - def reset(self, inputs: ModelInputs | None = None) -> None: + def reset(self, inputs: InferenceInput | None = None) -> None: del inputs self.step_index = 0 @@ -464,14 +501,19 @@ def __init__(self) -> None: def validate( self, *, - user_schema: UserInputSchema | None = None, - model_schema: ModelInputSchema | None = None, + canonical_schema: CanonicalInputSchema | None = None, + inference_input_schema: InferenceInputSchema | None = None, ) -> None: - super().validate(user_schema=user_schema, model_schema=model_schema) + super().validate( + canonical_schema=canonical_schema, + inference_input_schema=inference_input_schema, + ) self.validated = True class _OrderCheckingAdapter(_FakeAdapter): + canonical_input_schema = CanonicalInputSchema() + def __init__(self, *, mapping: _OrderCheckingMapping) -> None: self._mapping = mapping self.created_runtime_after_validate = False @@ -479,14 +521,18 @@ def __init__(self, *, mapping: _OrderCheckingMapping) -> None: def create_runtime(self, config: InferenceConfig) -> InferenceRuntime: self.validate_config(config) self.created_runtime_after_validate = self._mapping.validated - return _FakeRuntime(model_input_schema=self.model_input_schema) + return _FakeRuntime(inference_input_schema=self.inference_input_schema) class _FailingStartAdapter(_FakeAdapter): + canonical_input_schema = CanonicalInputSchema() + def __init__(self) -> None: self.runtime: _FailingRuntime | None = None def create_runtime(self, config: InferenceConfig) -> InferenceRuntime: self.validate_config(config) - self.runtime = _FailingRuntime(model_input_schema=self.model_input_schema) + self.runtime = _FailingRuntime( + inference_input_schema=self.inference_input_schema + ) return self.runtime diff --git a/flashdreams/tests/test_runtime_canonical.py b/flashdreams/tests/test_runtime_canonical.py new file mode 100644 index 000000000..1ad48d39e --- /dev/null +++ b/flashdreams/tests/test_runtime_canonical.py @@ -0,0 +1,590 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for the raw-input to canonical-modality layer. + +These cover the middle leg of ``raw input -> canonicalized input -> encoded +inference input``: applications consume canonical modalities, never raw device +events, so adding a device is a registration rather than an application change. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +import pytest + +from flashdreams.runtime import ( + DRIVER_COMMAND, + CanonicalInputs, + CanonicalModality, + DeviceConverterSchema, + InferenceInput, + InferenceInputSchema, + InputCanonicalizer, + InputField, + InputMappingSchema, + KeyboardToDriverCommand, + ScriptedModality, + TimeWindow, + UserInputCapability, + UserInputEvent, + UserInputs, + UserInputSchema, + check_mapping_compatibility, +) + +pytestmark = pytest.mark.ci_cpu + +KEYBOARD_SOURCE = UserInputSchema( + capabilities=( + UserInputCapability(event_type="key_down", payload_fields=frozenset({"key"})), + UserInputCapability(event_type="key_up", payload_fields=frozenset({"key"})), + ) +) +WHEEL_SOURCE = UserInputSchema( + capabilities=( + UserInputCapability( + event_type="wheel_axis", payload_fields=frozenset({"axis", "value"}) + ), + ) +) +PROMPT_SOURCE = UserInputSchema( + capabilities=( + UserInputCapability( + event_type="prompt_set", payload_fields=frozenset({"prompt"}) + ), + ) +) + +# Written once against the canonical modality. It names no key and no axis. +STEERING_MAPPING = InputMappingSchema( + name="driver-command-to-steering", + consumes=(DRIVER_COMMAND,), + produces_step=(InputField(name="steering"),), +) +STEERING_MODEL = InferenceInputSchema(step_fields=(InputField(name="steering"),)) + +WINDOW = TimeWindow(start_s=0.0, end_s=1.0) +NEXT_WINDOW = TimeWindow(start_s=1.0, end_s=2.0) + + +class WheelToDriverCommand: + """Minimal wheel converter standing in for a real evdev profile.""" + + def __init__(self, *, priority: int = 10) -> None: + self._steer = 0.0 + self._seen = False + self._schema = DeviceConverterSchema( + name="wheel-to-driver-command", + produces=DRIVER_COMMAND, + device_kind="wheel", + priority=priority, + consumes=( + UserInputCapability( + event_type="wheel_axis", + payload_fields=frozenset({"axis", "value"}), + ), + ), + ) + + @property + def schema(self) -> DeviceConverterSchema: + return self._schema + + def reset(self) -> None: + self._steer = 0.0 + self._seen = False + + def convert( + self, user_inputs: UserInputs, window: TimeWindow + ) -> Mapping[str, Any] | None: + del window + for event in user_inputs.events: + if event.event_type == "wheel_axis" and event.payload["axis"] == "steer": + self._seen = True + self._steer = float(event.payload["value"]) + if not self._seen: + return None + return DRIVER_COMMAND.value( + { + "throttle": 0.0, + "brake": 0.0, + "steer": self._steer, + "stop": False, + "reverse": False, + } + ) + + +def _key(event_type: str, key: str, timestamp_s: float) -> UserInputEvent: + return UserInputEvent( + timestamp_s=timestamp_s, event_type=event_type, payload={"key": key} + ) + + +def _command(canonical: CanonicalInputs) -> Mapping[str, Any]: + assert DRIVER_COMMAND.name in canonical.values + return canonical.values[DRIVER_COMMAND.name] + + +# --- per-step conditioning ---------------------------------------------- + + +def test_keyboard_edges_become_canonical_driver_command() -> None: + canonicalizer = InputCanonicalizer([KeyboardToDriverCommand()]) + inputs = UserInputs(events=(_key("key_down", "w", 0.1),)) + + canonical = canonicalizer.canonicalize( + inputs, window=WINDOW, source_schema=KEYBOARD_SOURCE + ) + + assert _command(canonical)["throttle"] == 1.0 + assert _command(canonical)["steer"] == 0.0 + assert canonical.metadata["canonical_sources"]["driver_command"] == "keyboard" + + +def test_key_aliases_are_normalized() -> None: + canonicalizer = InputCanonicalizer([KeyboardToDriverCommand()]) + inputs = UserInputs(events=(_key("key_down", "ArrowLeft", 0.1),)) + + canonical = canonicalizer.canonicalize( + inputs, window=WINDOW, source_schema=KEYBOARD_SOURCE + ) + + assert _command(canonical)["steer"] == 1.0 + + +def test_held_key_still_emits_in_a_window_with_no_events() -> None: + """Edge-triggered HID must become level-triggered per-step conditioning.""" + canonicalizer = InputCanonicalizer([KeyboardToDriverCommand()]) + inputs = UserInputs(events=(_key("key_down", "w", 0.1),)) + canonicalizer.canonicalize(inputs, window=WINDOW, source_schema=KEYBOARD_SOURCE) + + quiet = canonicalizer.canonicalize( + inputs, window=NEXT_WINDOW, source_schema=KEYBOARD_SOURCE + ) + + assert _command(quiet)["throttle"] == 1.0 + + +def test_key_release_returns_to_neutral() -> None: + canonicalizer = InputCanonicalizer([KeyboardToDriverCommand()]) + inputs = UserInputs(events=(_key("key_down", "a", 0.1), _key("key_up", "a", 1.5))) + canonicalizer.canonicalize(inputs, window=WINDOW, source_schema=KEYBOARD_SOURCE) + + released = canonicalizer.canonicalize( + inputs, window=NEXT_WINDOW, source_schema=KEYBOARD_SOURCE + ) + + assert _command(released)["steer"] == 0.0 + + +def test_reset_drops_device_state() -> None: + canonicalizer = InputCanonicalizer([KeyboardToDriverCommand()]) + inputs = UserInputs(events=(_key("key_down", "w", 0.1),)) + canonicalizer.canonicalize(inputs, window=WINDOW, source_schema=KEYBOARD_SOURCE) + + canonicalizer.reset() + after = canonicalizer.canonicalize( + UserInputs(), window=NEXT_WINDOW, source_schema=KEYBOARD_SOURCE + ) + + assert _command(after)["throttle"] == 0.0 + + +# --- boundary: global conditioning is not canonicalized ----------------- + + +def test_canonical_inputs_carry_live_control_only() -> None: + """Global conditioning is application-owned and bypasses this layer.""" + canonicalizer = InputCanonicalizer([KeyboardToDriverCommand()]) + inputs = UserInputs( + events=( + _key("key_down", "w", 0.1), + UserInputEvent( + timestamp_s=0.2, event_type="prompt_set", payload={"prompt": "rain"} + ), + ) + ) + + canonical = canonicalizer.canonicalize( + inputs, window=WINDOW, source_schema=KEYBOARD_SOURCE + ) + + assert set(canonical.values) == {"driver_command"} + + +def test_application_supplies_global_conditioning_directly() -> None: + """A prompt swap reaches the session without touching canonicalization.""" + update = InferenceInput(step={"steering": 0.0}).with_global_update( + {"prompt": "heavy rain"} + ) + + assert update.requests_global_update + assert update.global_conditioning["prompt"] == "heavy rain" + + +# --- device independence ------------------------------------------------ + + +def test_mapping_written_against_a_modality_accepts_a_keyboard() -> None: + canonicalizer = InputCanonicalizer([KeyboardToDriverCommand()]) + + compatibility = check_mapping_compatibility( + canonical_schema=canonicalizer.canonical_schema(KEYBOARD_SOURCE), + inference_input_schema=STEERING_MODEL, + mapping_schema=STEERING_MAPPING, + ) + + assert compatibility.can_drive + + +def test_adding_a_device_needs_no_application_or_model_change() -> None: + """A wheel is one register() call; mapping and model schemas are untouched.""" + canonicalizer = InputCanonicalizer([KeyboardToDriverCommand()]) + canonicalizer.register(WheelToDriverCommand()) + + compatibility = check_mapping_compatibility( + canonical_schema=canonicalizer.canonical_schema(WHEEL_SOURCE), + inference_input_schema=STEERING_MODEL, + mapping_schema=STEERING_MAPPING, + ) + assert compatibility.can_drive + + canonical = canonicalizer.canonicalize( + UserInputs( + events=( + UserInputEvent( + timestamp_s=0.5, + event_type="wheel_axis", + payload={"axis": "steer", "value": -0.4}, + ), + ) + ), + window=WINDOW, + source_schema=WHEEL_SOURCE, + ) + assert _command(canonical)["steer"] == pytest.approx(-0.4) + + +def test_source_with_no_feedable_converter_supplies_no_modalities() -> None: + canonicalizer = InputCanonicalizer([WheelToDriverCommand()]) + + schema = canonicalizer.canonical_schema(KEYBOARD_SOURCE) + + assert schema.modalities == () + assert not schema.supports(DRIVER_COMMAND) + assert canonicalizer.unavailable_converters(KEYBOARD_SOURCE) + + +def test_highest_priority_device_wins_when_both_are_present() -> None: + canonicalizer = InputCanonicalizer( + [KeyboardToDriverCommand(), WheelToDriverCommand()] + ) + both = UserInputSchema( + capabilities=KEYBOARD_SOURCE.capabilities + WHEEL_SOURCE.capabilities + ) + + canonical = canonicalizer.canonicalize( + UserInputs( + events=( + _key("key_down", "a", 0.2), + UserInputEvent( + timestamp_s=0.5, + event_type="wheel_axis", + payload={"axis": "steer", "value": -0.4}, + ), + ) + ), + window=WINDOW, + source_schema=both, + ) + + assert canonical.metadata["canonical_sources"]["driver_command"] == "wheel" + assert _command(canonical)["steer"] == pytest.approx(-0.4) + + +def test_preempted_device_keeps_its_state_current() -> None: + """Keyboard state must not be stale when the wheel disappears.""" + canonicalizer = InputCanonicalizer( + [KeyboardToDriverCommand(), WheelToDriverCommand()] + ) + both = UserInputSchema( + capabilities=KEYBOARD_SOURCE.capabilities + WHEEL_SOURCE.capabilities + ) + inputs = UserInputs( + events=( + _key("key_down", "w", 0.2), + UserInputEvent( + timestamp_s=0.5, + event_type="wheel_axis", + payload={"axis": "steer", "value": -0.4}, + ), + ) + ) + preempted = canonicalizer.canonicalize(inputs, window=WINDOW, source_schema=both) + assert preempted.metadata["canonical_sources"]["driver_command"] == "wheel" + + keyboard_only = canonicalizer.canonicalize( + inputs, window=NEXT_WINDOW, source_schema=KEYBOARD_SOURCE + ) + + assert keyboard_only.metadata["canonical_sources"]["driver_command"] == "keyboard" + assert _command(keyboard_only)["throttle"] == 1.0 + + +# --- registry ----------------------------------------------------------- + + +def test_duplicate_converter_names_are_rejected() -> None: + canonicalizer = InputCanonicalizer([KeyboardToDriverCommand()]) + + with pytest.raises(ValueError, match="already registered"): + canonicalizer.register(KeyboardToDriverCommand()) + + +def test_converter_must_fill_the_declared_modality_payload() -> None: + modality = CanonicalModality( + name="steering_wheel", payload_fields=frozenset({"steer", "throttle"}) + ) + + with pytest.raises(ValueError, match="requires payload fields"): + modality.value({"steer": 0.0}) + + +def test_new_modality_is_a_registration_not_a_core_change() -> None: + pedals = CanonicalModality( + name="pedal_state", payload_fields=frozenset({"throttle"}) + ) + + class PedalsConverter: + schema = DeviceConverterSchema( + name="pedals", + produces=pedals, + device_kind="pedals", + consumes=( + UserInputCapability( + event_type="pedal_axis", + payload_fields=frozenset({"value"}), + ), + ), + ) + + def reset(self) -> None: + return None + + def convert( + self, user_inputs: UserInputs, window: TimeWindow + ) -> Mapping[str, Any] | None: + del window + if not user_inputs.events: + return None + return pedals.value( + {"throttle": float(user_inputs.events[-1].payload["value"])} + ) + + source = UserInputSchema( + capabilities=( + UserInputCapability( + event_type="pedal_axis", payload_fields=frozenset({"value"}) + ), + ) + ) + canonicalizer = InputCanonicalizer([PedalsConverter()]) + + assert canonicalizer.canonical_schema(source).modalities == (pedals,) + canonical = canonicalizer.canonicalize( + UserInputs( + events=( + UserInputEvent( + timestamp_s=0.5, event_type="pedal_axis", payload={"value": 0.75} + ), + ) + ), + window=WINDOW, + source_schema=source, + ) + assert canonical.values["pedal_state"]["throttle"] == pytest.approx(0.75) + + +def test_replaying_the_same_windows_reproduces_the_same_canonical_inputs() -> None: + inputs = UserInputs(events=(_key("key_down", "w", 0.1), _key("key_down", "a", 1.2))) + + def run() -> list[dict[str, Any]]: + canonicalizer = InputCanonicalizer([KeyboardToDriverCommand()]) + return [ + dict( + _command( + canonicalizer.canonicalize( + inputs, window=window, source_schema=KEYBOARD_SOURCE + ) + ) + ) + for window in (WINDOW, NEXT_WINDOW) + ] + + assert run() == run() + + +# --- key bindings ------------------------------------------------------- + + +def test_bindings_are_data_and_can_be_rebound() -> None: + """A layout change must not require editing the converter.""" + azerty = InputCanonicalizer( + [ + KeyboardToDriverCommand( + bindings={ + "throttle": frozenset({"z"}), + "brake": frozenset({"s"}), + "steer_left": frozenset({"q"}), + "steer_right": frozenset({"d"}), + "stop": frozenset({"space"}), + } + ) + ] + ) + + canonical = azerty.canonicalize( + UserInputs(events=(_key("key_down", "z", 0.1),)), + window=WINDOW, + source_schema=KEYBOARD_SOURCE, + ) + + assert _command(canonical)["throttle"] == 1.0 + + +def test_tracked_keys_are_derived_so_an_action_cannot_go_unreachable() -> None: + """Declaring bindings and tracked keys separately used to disagree.""" + converter = KeyboardToDriverCommand( + bindings={"stop": frozenset({"escape"}), "throttle": frozenset({"w"})} + ) + canonicalizer = InputCanonicalizer([converter]) + + canonical = canonicalizer.canonicalize( + UserInputs(events=(_key("key_down", "escape", 0.1),)), + window=WINDOW, + source_schema=KEYBOARD_SOURCE, + ) + + assert _command(canonical)["stop"] is True + + +def test_unknown_driver_action_is_rejected() -> None: + with pytest.raises(ValueError, match="Unknown driver actions"): + KeyboardToDriverCommand(bindings={"turbo": frozenset({"t"})}) + + +def test_reverse_is_bindable() -> None: + canonicalizer = InputCanonicalizer( + [KeyboardToDriverCommand(bindings={"reverse": frozenset({"r"})})] + ) + + canonical = canonicalizer.canonicalize( + UserInputs(events=(_key("key_down", "r", 0.1),)), + window=WINDOW, + source_schema=KEYBOARD_SOURCE, + ) + + assert _command(canonical)["reverse"] is True + + +# --- scripted / mock input ---------------------------------------------- + + +def _scripted() -> InputCanonicalizer: + return InputCanonicalizer( + [ + ScriptedModality( + modality=DRIVER_COMMAND, + timeline=[ + ( + 0.0, + { + "throttle": 1.0, + "brake": 0.0, + "steer": 0.0, + "stop": False, + "reverse": False, + }, + ), + ( + 2.0, + { + "throttle": 0.0, + "brake": 0.0, + "steer": 1.0, + "stop": False, + "reverse": False, + }, + ), + ], + ) + ] + ) + + +def test_mock_input_needs_no_raw_events_or_source_schema() -> None: + """Authoring a benchmark scenario must not require raw device vocabulary.""" + canonical = _scripted().canonicalize( + UserInputs(), window=WINDOW, source_schema=UserInputSchema() + ) + + assert _command(canonical)["throttle"] == 1.0 + + +def test_scripted_values_hold_until_the_next_entry() -> None: + canonicalizer = _scripted() + windows = [TimeWindow(start_s=t, end_s=t + 1.0) for t in (0.0, 1.0, 2.0)] + + steer = [ + _command( + canonicalizer.canonicalize( + UserInputs(), window=w, source_schema=UserInputSchema() + ) + )["steer"] + for w in windows + ] + + assert steer == [0.0, 0.0, 1.0] + + +def test_scripted_converter_is_silent_before_its_first_entry() -> None: + canonicalizer = InputCanonicalizer( + [ + ScriptedModality( + modality=CanonicalModality(name="late", payload_fields=frozenset()), + timeline=[(5.0, {})], + ) + ] + ) + + canonical = canonicalizer.canonicalize( + UserInputs(), window=WINDOW, source_schema=UserInputSchema() + ) + + assert canonical.values == {} + + +def test_scripted_timeline_is_validated_against_the_modality() -> None: + with pytest.raises(ValueError, match="requires payload fields"): + ScriptedModality(modality=DRIVER_COMMAND, timeline=[(0.0, {"throttle": 1.0})]) + + +def test_scripted_replay_is_deterministic() -> None: + def run() -> list[float]: + canonicalizer = _scripted() + return [ + _command( + canonicalizer.canonicalize( + UserInputs(), + window=TimeWindow(start_s=t, end_s=t + 1.0), + source_schema=UserInputSchema(), + ) + )["steer"] + for t in (0.0, 1.0, 2.0) + ] + + assert run() == run() diff --git a/flashdreams/tests/test_runtime_input_mapping.py b/flashdreams/tests/test_runtime_input_mapping.py new file mode 100644 index 000000000..00cd9758f --- /dev/null +++ b/flashdreams/tests/test_runtime_input_mapping.py @@ -0,0 +1,573 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for declarative input-mapping compatibility in the runtime API. + +These cover the T2/T3 contract: sources declare what user events they can +provide at payload granularity, models declare required and optional +initial/per-step inputs, and a mapping declares what it consumes and produces so +compatibility can be answered before expensive runtime initialization. +""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from flashdreams.runtime import ( + DRIVER_COMMAND, + SESSION_START_ONLY, + CanonicalInputs, + CanonicalInputSchema, + CanonicalModality, + IdentityInputMapping, + InferenceInput, + InferenceInputSchema, + InputField, + InputMappingSchema, + StepRequest, + TimeWindow, + UserInputCapability, + UserInputEvent, + UserInputs, + UserInputSchema, + check_mapping_compatibility, + check_mapping_set_compatibility, + combine_mapping_schemas, + undeclared_inference_inputs, +) + +pytestmark = pytest.mark.ci_cpu + +KEY_DOWN = UserInputCapability(event_type="key_down", payload_fields=frozenset({"key"})) +KEY_UP = UserInputCapability(event_type="key_up", payload_fields=frozenset({"key"})) +PROMPT_SET = UserInputCapability( + event_type="prompt_set", + semantic_type="text", + payload_fields=frozenset({"prompt"}), +) +FRAME_SET = UserInputCapability( + event_type="initial_frame_set", payload_fields=frozenset({"image"}) +) + +BROWSER_SOURCE = UserInputSchema( + capabilities=(KEY_DOWN, KEY_UP, PROMPT_SET, FRAME_SET), + description="browser webrtc client", +) + +CAMERA_LOOK = CanonicalModality( + name="camera_look", payload_fields=frozenset({"yaw", "pitch"}) +) + +CANONICAL_ALL = CanonicalInputSchema(modalities=(DRIVER_COMMAND, CAMERA_LOOK)) + +# Global conditioning is application-owned and does not come from a canonical +# modality, so this mapping consumes nothing and only declares what it produces. +PROMPT_MAPPING = InputMappingSchema( + name="prompt", + produces_global=(InputField(name="prompt", semantic_type="text"),), +) +FRAME_MAPPING = InputMappingSchema( + name="conditioning-frame", + produces_global=(InputField(name="global_conditioning_frame", required=False),), +) +STEERING_MAPPING = InputMappingSchema( + name="driver-command-to-steering", + consumes=(DRIVER_COMMAND,), + produces_step=(InputField(name="steering"),), +) +LOOK_MAPPING = InputMappingSchema( + name="camera-look", + consumes=(CAMERA_LOOK,), + produces_step=(InputField(name="camera_delta", required=False),), +) + +DRIVING_MODEL = InferenceInputSchema( + global_fields=( + InputField(name="prompt", semantic_type="text", lifecycle="cache_init"), + ), + step_fields=( + InputField(name="steering", lifecycle="step_input"), + InputField(name="camera_delta", required=False, lifecycle="step_input"), + ), +) + + +# --- user input events and windowing ------------------------------------ + + +def test_startup_values_are_represented_as_events() -> None: + inputs = UserInputs( + events=( + UserInputEvent( + timestamp_s=0.0, event_type="prompt_set", payload={"prompt": "drive"} + ), + UserInputEvent( + timestamp_s=0.5, event_type="key_down", payload={"key": "w"} + ), + ) + ) + + assert inputs.events[0].event_type == "prompt_set" + assert inputs.events[0].payload["prompt"] == "drive" + + +def test_windowing_is_half_open_and_deterministic() -> None: + inputs = UserInputs( + events=tuple( + UserInputEvent(timestamp_s=t, event_type="key_down", payload={"key": "w"}) + for t in (0.0, 0.5, 1.0, 1.5) + ) + ) + + windowed = inputs.window(TimeWindow(start_s=0.5, end_s=1.5)) + + assert [event.timestamp_s for event in windowed.events] == [0.5, 1.0] + + +def test_out_of_order_events_are_rejected() -> None: + with pytest.raises(ValueError, match="non-decreasing"): + UserInputs( + events=( + UserInputEvent(timestamp_s=1.0, event_type="key_down"), + UserInputEvent(timestamp_s=0.5, event_type="key_up"), + ) + ) + + +# --- user input schemas ------------------------------------------------- + + +def test_source_declares_capabilities_at_payload_granularity() -> None: + assert BROWSER_SOURCE.supports(KEY_DOWN) + assert not BROWSER_SOURCE.supports( + UserInputCapability( + event_type="key_down", payload_fields=frozenset({"key", "modifiers"}) + ) + ) + + +def test_bare_event_types_still_satisfy_payload_free_consumers() -> None: + """Coarse pre-capability schemas keep working against the finer query.""" + coarse = UserInputSchema(event_types=frozenset({"reset"})) + + assert coarse.supports(UserInputCapability(event_type="reset")) + assert not coarse.supports( + UserInputCapability(event_type="reset", payload_fields=frozenset({"reason"})) + ) + assert coarse.supports_event_types({"reset"}) + + +def test_capabilities_widen_declared_event_types() -> None: + assert "key_down" in BROWSER_SOURCE.declared_event_types() + assert BROWSER_SOURCE.supports_event_types({"key_down", "prompt_set"}) + + +def test_semantic_type_mismatch_blocks_capability_match() -> None: + source = UserInputSchema( + capabilities=( + UserInputCapability(event_type="prompt_set", semantic_type="embedding"), + ) + ) + + assert not source.supports( + UserInputCapability(event_type="prompt_set", semantic_type="text") + ) + + +def test_event_validation_reports_missing_payload_fields() -> None: + event = UserInputEvent(timestamp_s=0.0, event_type="key_down", payload={}) + + with pytest.raises(ValueError, match="missing required"): + BROWSER_SOURCE.validate_event(event) + + +def test_event_validation_rejects_undeclared_event_type() -> None: + event = UserInputEvent(timestamp_s=0.0, event_type="wheel_axis") + + with pytest.raises(ValueError, match="does not provide event type"): + BROWSER_SOURCE.validate_event(event) + + +# --- model input schemas ------------------------------------------------ + + +def test_model_declares_required_and_optional_fields_per_phase() -> None: + required = DRIVING_MODEL.required_fields() + optional = DRIVING_MODEL.optional_fields() + + assert {(phase, f.name) for phase, f in required} == { + ("global", "prompt"), + ("step", "steering"), + } + assert {(phase, f.name) for phase, f in optional} == {("step", "camera_delta")} + + +def test_required_fields_can_be_filtered_by_phase() -> None: + step_only = DRIVING_MODEL.required_fields("step") + + assert [f.name for _, f in step_only] == ["steering"] + + +def test_field_lookup_is_phase_scoped() -> None: + assert DRIVING_MODEL.field_for(name="prompt", phase="global") is not None + assert DRIVING_MODEL.field_for(name="prompt", phase="step") is None + + +def test_invalid_phase_is_rejected() -> None: + bad_phase: Any = "final" + + with pytest.raises(ValueError, match="phase must be"): + DRIVING_MODEL.fields_for(bad_phase) + + +def test_inference_input_expose_payload_per_phase() -> None: + inputs = InferenceInput( + global_conditioning={"prompt": "drive"}, step={"steering": 0.25} + ) + + assert inputs.for_phase("global")["prompt"] == "drive" + assert inputs.for_phase("step")["steering"] == 0.25 + + +def test_lifecycle_and_update_policy_are_queryable_metadata() -> None: + field = InputField( + name="prompt", + update_policy="step_boundary", + lifecycle="cache_init", + metadata={"coordinates": "opencv_c2w"}, + ) + + assert field.update_policy == "step_boundary" + assert field.lifecycle == "cache_init" + assert field.metadata["coordinates"] == "opencv_c2w" + + +def test_metadata_is_excluded_from_field_equality() -> None: + plain = InputField(name="prompt") + annotated = InputField(name="prompt", metadata={"note": "hint"}) + + assert plain == annotated + + +# --- mapping compatibility ---------------------------------------------- + + +def test_compatible_source_model_and_mapping_can_drive() -> None: + compatibility = check_mapping_set_compatibility( + canonical_schema=CANONICAL_ALL, + inference_input_schema=DRIVING_MODEL, + mapping_schemas=(PROMPT_MAPPING, STEERING_MAPPING, LOOK_MAPPING), + ) + + assert compatibility.can_drive + assert {(p, f.name) for p, f in compatibility.satisfied_required_model_fields} == { + ("global", "prompt"), + ("step", "steering"), + } + assert {(p, f.name) for p, f in compatibility.available_optional_model_fields} == { + ("step", "camera_delta") + } + + +def test_missing_required_model_field_blocks_the_run() -> None: + compatibility = check_mapping_set_compatibility( + canonical_schema=CANONICAL_ALL, + inference_input_schema=DRIVING_MODEL, + mapping_schemas=(PROMPT_MAPPING,), + ) + + assert not compatibility.can_drive + assert [f.name for _, f in compatibility.missing_required_model_fields] == [ + "steering" + ] + + +def test_missing_source_capability_is_reported_when_it_blocks() -> None: + no_wheel = CanonicalInputSchema(modalities=(CAMERA_LOOK,)) + + compatibility = check_mapping_set_compatibility( + canonical_schema=no_wheel, + inference_input_schema=DRIVING_MODEL, + mapping_schemas=(PROMPT_MAPPING, STEERING_MAPPING), + ) + + assert not compatibility.can_drive + assert compatibility.unavailable_mapping_names == ("driver-command-to-steering",) + assert {m.name for m in compatibility.missing_modalities} == {"driver_command"} + + +def test_unfeedable_optional_mapping_degrades_instead_of_vetoing() -> None: + """Losing a mapping that fed only optional fields must not block the run.""" + no_look = CanonicalInputSchema(modalities=(DRIVER_COMMAND,)) + + compatibility = check_mapping_set_compatibility( + canonical_schema=no_look, + inference_input_schema=DRIVING_MODEL, + mapping_schemas=(PROMPT_MAPPING, STEERING_MAPPING, LOOK_MAPPING), + ) + + assert compatibility.can_drive + assert compatibility.unavailable_mapping_names == ("camera-look",) + # The dropped mapping's field must not be advertised as available. + assert compatibility.available_optional_model_fields == () + + +def test_optional_field_needs_mapping_support_to_be_available() -> None: + compatibility = check_mapping_set_compatibility( + canonical_schema=CANONICAL_ALL, + inference_input_schema=DRIVING_MODEL, + mapping_schemas=(PROMPT_MAPPING, STEERING_MAPPING), + ) + + assert compatibility.can_drive + assert compatibility.available_optional_model_fields == () + + +def test_lifecycle_disagreement_blocks_a_field_match() -> None: + model = InferenceInputSchema( + global_fields=(InputField(name="prompt", lifecycle="rollout_binding"),) + ) + mapping = InputMappingSchema( + name="prompt", + produces_global=(InputField(name="prompt", lifecycle="cache_init"),), + ) + + compatibility = check_mapping_compatibility( + canonical_schema=CANONICAL_ALL, + inference_input_schema=model, + mapping_schema=mapping, + ) + + assert not compatibility.can_drive + + +def test_unspecified_lifecycle_stays_permissive() -> None: + model = InferenceInputSchema(global_fields=(InputField(name="prompt"),)) + + compatibility = check_mapping_compatibility( + canonical_schema=CANONICAL_ALL, + inference_input_schema=model, + mapping_schema=PROMPT_MAPPING, + ) + + assert compatibility.can_drive + + +def test_raise_if_incompatible_names_both_failure_kinds() -> None: + compatibility = check_mapping_set_compatibility( + canonical_schema=CanonicalInputSchema(modalities=(CAMERA_LOOK,)), + inference_input_schema=DRIVING_MODEL, + mapping_schemas=(PROMPT_MAPPING, STEERING_MAPPING), + ) + + with pytest.raises(ValueError) as excinfo: + compatibility.raise_if_incompatible() + + message = str(excinfo.value) + assert "missing canonical modalities" in message + assert "missing required model inputs" in message + + +def test_raise_if_incompatible_is_a_no_op_when_compatible() -> None: + compatibility = check_mapping_set_compatibility( + canonical_schema=CANONICAL_ALL, + inference_input_schema=DRIVING_MODEL, + mapping_schemas=(PROMPT_MAPPING, FRAME_MAPPING, STEERING_MAPPING), + ) + + compatibility.raise_if_incompatible() + + +def test_check_mapping_compatibility_rejects_a_non_schema() -> None: + not_a_schema: Any = object() + + with pytest.raises(TypeError, match="InputMappingSchema"): + check_mapping_compatibility( + canonical_schema=CANONICAL_ALL, + inference_input_schema=DRIVING_MODEL, + mapping_schema=not_a_schema, + ) + + +# --- mapping schema composition ----------------------------------------- + + +def test_combining_mappings_unions_their_surfaces() -> None: + combined = combine_mapping_schemas((PROMPT_MAPPING, STEERING_MAPPING)) + + assert {m.name for m in combined.consumes} == {"driver_command"} + assert [f.name for f in combined.produces_global] == ["prompt"] + assert [f.name for f in combined.produces_step] == ["steering"] + + +def test_duplicate_declarations_collapse_and_merge_metadata() -> None: + first = InputMappingSchema( + name="a", + produces_global=(InputField(name="prompt", metadata={"source": "a"}),), + ) + second = InputMappingSchema( + name="b", + produces_global=( + InputField(name="prompt", metadata={"source": "b", "extra": "kept"}), + ), + ) + + combined = combine_mapping_schemas((first, second)) + + assert len(combined.produces_global) == 1 + metadata = combined.produces_global[0].metadata + assert metadata["source"] == "a" + assert metadata["extra"] == "kept" + + +def test_combine_rejects_non_schema_entries() -> None: + not_a_schema: Any = object() + + with pytest.raises(TypeError, match="InputMappingSchema"): + combine_mapping_schemas((PROMPT_MAPPING, not_a_schema)) + + +# --- declaration drift -------------------------------------------------- + + +def test_undeclared_inference_input_catches_schema_drift() -> None: + produced = InferenceInput( + global_conditioning={"prompt": "drive"}, step={"steering": 0.0} + ) + + undeclared = undeclared_inference_inputs(produced, PROMPT_MAPPING) + + assert undeclared == (("step", "steering"),) + + +def test_declared_outputs_report_no_drift() -> None: + combined = combine_mapping_schemas((PROMPT_MAPPING, STEERING_MAPPING)) + produced = InferenceInput( + global_conditioning={"prompt": "drive"}, step={"steering": 0.0} + ) + + assert undeclared_inference_inputs(produced, combined) == () + + +# --- interoperability with the T1 envelope ------------------------------ + + +def test_identity_mapping_needs_no_declared_surface() -> None: + """Fixed-input runs stay possible without any schema declaration.""" + mapping = IdentityInputMapping() + fixed = InferenceInput( + global_conditioning={"prompt": "fixed"}, step={"steering": 0.0} + ) + + mapped = mapping.map_step_inputs( + canonical_inputs=CanonicalInputs(), + inference_input=fixed, + request=StepRequest(step_index=0), + ) + + assert mapped.step["steering"] == 0.0 + + +def test_empty_mapping_set_cannot_satisfy_a_required_field() -> None: + compatibility = check_mapping_set_compatibility( + canonical_schema=CANONICAL_ALL, + inference_input_schema=DRIVING_MODEL, + mapping_schemas=(), + ) + + assert not compatibility.can_drive + assert len(compatibility.missing_required_model_fields) == 2 + + +def test_model_with_no_requirements_is_always_drivable() -> None: + compatibility = check_mapping_set_compatibility( + canonical_schema=CanonicalInputSchema(), + inference_input_schema=InferenceInputSchema(), + mapping_schemas=(), + ) + + assert compatibility.can_drive + + +# --- global conditioning updates vs reset ------------------------------- + + +def test_empty_global_slot_requests_no_update() -> None: + steady_state = InferenceInput(step={"steering": 0.25}) + + assert not steady_state.requests_global_update + + +def test_non_empty_global_slot_mid_rollout_is_an_update_request() -> None: + """Changing weather mid-run updates conditioning; it is not a reset.""" + updated = InferenceInput(step={"steering": 0.0}).with_global_update( + {"prompt": "heavy rain"} + ) + + assert updated.requests_global_update + assert updated.global_conditioning["prompt"] == "heavy rain" + assert updated.step["steering"] == 0.0 + + +def test_with_step_carries_the_global_slot_through() -> None: + started = InferenceInput(global_conditioning={"prompt": "drive"}) + + stepped = started.with_step({"steering": 0.5}) + + assert stepped.global_conditioning["prompt"] == "drive" + + +def test_without_global_update_clears_the_request() -> None: + started = InferenceInput( + global_conditioning={"prompt": "drive"}, step={"steering": 0.5} + ) + + steady_state = started.without_global_update() + + assert not steady_state.requests_global_update + assert steady_state.step["steering"] == 0.5 + + +def test_model_can_declare_conditioning_it_cannot_swap_mid_rollout() -> None: + schema = InferenceInputSchema( + global_fields=( + InputField(name="prompt", update_policy="step_boundary"), + InputField(name="scene_id", update_policy=SESSION_START_ONLY), + ) + ) + update = InferenceInput( + global_conditioning={"prompt": "heavy rain", "scene_id": "town_02"} + ) + + assert schema.unsupported_global_updates(update) == ("scene_id",) + + +def test_permissive_when_no_update_policy_is_declared() -> None: + schema = InferenceInputSchema(global_fields=(InputField(name="prompt"),)) + update = InferenceInput(global_conditioning={"prompt": "heavy rain"}) + + assert schema.unsupported_global_updates(update) == () + + +def test_undeclared_global_values_are_left_to_the_adapter() -> None: + schema = InferenceInputSchema(global_fields=(InputField(name="prompt"),)) + update = InferenceInput(global_conditioning={"mystery": 1}) + + assert schema.unsupported_global_updates(update) == () + + +def test_steady_state_steps_do_not_request_a_global_update() -> None: + """Carrying session-start conditioning forward would look like an update.""" + started = InferenceInput(global_conditioning={"prompt": "drive"}) + + steady_state = InferenceInput(step={"chunk_index": 1}) + + assert started.requests_global_update + assert not steady_state.requests_global_update + assert ( + not started.with_step({"chunk_index": 1}) + .without_global_update() + .requests_global_update + ) From 637b38cf3d0a0cc6d400919347a86394c467b7ae Mon Sep 17 00:00:00 2001 From: Fangjun Zhou Date: Wed, 5 Aug 2026 11:22:40 -0700 Subject: [PATCH 06/30] Create InferenceSession and moved InferenceInput to inference_session --- ...inference_runtime_inputs_implementation.md | 5 +- flashdreams/flashdreams/runtime/__init__.py | 2 +- .../flashdreams/runtime/inference_session.py | 135 ++++++++++++++++++ flashdreams/flashdreams/runtime/inputs.py | 95 ++---------- flashdreams/flashdreams/runtime/interfaces.py | 7 +- flashdreams/flashdreams/runtime/mapping.py | 2 +- 6 files changed, 157 insertions(+), 89 deletions(-) create mode 100644 flashdreams/flashdreams/runtime/inference_session.py diff --git a/docs/inference_runtime_inputs_implementation.md b/docs/inference_runtime_inputs_implementation.md index 8d485768a..f7db05644 100644 --- a/docs/inference_runtime_inputs_implementation.md +++ b/docs/inference_runtime_inputs_implementation.md @@ -11,7 +11,10 @@ what is intentionally still outside this layer. Implementation lives in `flashdreams.runtime`: -- `flashdreams/flashdreams/runtime/inputs.py` — the input types and schemas +- `flashdreams/flashdreams/runtime/inputs.py` — user/canonical input types + and schemas +- `flashdreams/flashdreams/runtime/inference_session.py` — model-ready + `InferenceInput` and session lifecycle - `flashdreams/flashdreams/runtime/canonical.py` — raw device to canonical modality conversion - `flashdreams/flashdreams/runtime/mapping.py` — canonical to encoded mapping diff --git a/flashdreams/flashdreams/runtime/__init__.py b/flashdreams/flashdreams/runtime/__init__.py index ab303c745..9ce9d4ee2 100644 --- a/flashdreams/flashdreams/runtime/__init__.py +++ b/flashdreams/flashdreams/runtime/__init__.py @@ -17,13 +17,13 @@ ScriptedModality, ) from flashdreams.runtime.config import ExecutionBackend, InferenceConfig, Precision +from flashdreams.runtime.inference_session import InferenceInput from flashdreams.runtime.inputs import ( INPUT_PHASES, SESSION_START_ONLY, CanonicalInputs, CanonicalInputSchema, CanonicalModality, - InferenceInput, InferenceInputSchema, InputField, InputPhase, diff --git a/flashdreams/flashdreams/runtime/inference_session.py b/flashdreams/flashdreams/runtime/inference_session.py new file mode 100644 index 000000000..6ce8b48d0 --- /dev/null +++ b/flashdreams/flashdreams/runtime/inference_session.py @@ -0,0 +1,135 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Inference session lifecycle and model-input envelope.""" + +from collections.abc import Mapping +from dataclasses import dataclass, field +from typing import Any, TypedDict + +from typing_extensions import Unpack + +from flashdreams.infra.pipeline import ( + StreamInferencePipeline, + StreamInferencePipelineConfig, +) +from flashdreams.runtime._utils import freeze_mapping +from flashdreams.runtime.inputs import InputPhase, validate_phase + + +@dataclass(frozen=True, kw_only=True, slots=True) +class InferenceInput: + """Encoded inputs for one :class:`InferenceSession` call. + + Two conditioning slots: + + - ``global_conditioning``: values that condition the whole rollout, such as + the conditioning frame or prompt. Normally supplied when the session + starts. + - ``step``: values needed to generate the next chunk or frame. + + A non-empty ``global_conditioning`` on a mid-rollout input is an *update + request*, not a reset. The session should apply it when the model supports + that; resetting rollout state is a separate, explicit + :meth:`InferenceSession.reset` call. Whether a given value can be updated + mid-rollout is declared by ``InputField.update_policy``; see + ``InferenceInputSchema.unsupported_global_updates``. + """ + + __hash__ = None + + global_conditioning: Mapping[str, Any] = field(default_factory=dict) + step: Mapping[str, Any] = field(default_factory=dict) + metadata: Mapping[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + object.__setattr__( + self, "global_conditioning", freeze_mapping(self.global_conditioning) + ) + object.__setattr__(self, "step", freeze_mapping(self.step)) + object.__setattr__(self, "metadata", freeze_mapping(self.metadata)) + + @property + def requests_global_update(self) -> bool: + """Return whether this input asks the session to update conditioning.""" + return bool(self.global_conditioning) + + def with_step(self, step: Mapping[str, Any]) -> "InferenceInput": + """Return a copy with replaced per-step payload. + + The global slot is carried through unchanged, so a mid-rollout input + built this way keeps whatever update request it already had. Use + :meth:`without_global_update` for the common steady-state case. + """ + return InferenceInput( + global_conditioning=self.global_conditioning, + step=step, + metadata=self.metadata, + ) + + def with_global_update( + self, global_conditioning: Mapping[str, Any] + ) -> "InferenceInput": + """Return a copy requesting a mid-rollout conditioning update.""" + return InferenceInput( + global_conditioning=global_conditioning, + step=self.step, + metadata=self.metadata, + ) + + def without_global_update(self) -> "InferenceInput": + """Return a copy that requests no conditioning update.""" + return InferenceInput(step=self.step, metadata=self.metadata) + + def for_phase(self, phase: InputPhase) -> Mapping[str, Any]: + """Return the payload mapping for ``phase``.""" + return ( + self.global_conditioning if validate_phase(phase) == "global" else self.step + ) + + +class InferenceSessionConfig(TypedDict): + """Configuration for constructing an inference session.""" + + pipeline: StreamInferencePipelineConfig + """Pipeline configuration to instantiate.""" + + +class InferenceSession: + """Stateful inference pipeline session.""" + + def __init__(self, **kwargs: Unpack[InferenceSessionConfig]) -> None: + """Initialize the inference pipeline. + + Args: + **kwargs: Session construction keyword arguments. + """ + # Initialize the inference pipeline from the provided configuration. + self.pipeline: StreamInferencePipeline = kwargs["pipeline"].setup() + + def __del__(self) -> None: + """Release session resources.""" + if hasattr(self, "pipeline"): + del self.pipeline + + def reset(self) -> None: + """Reset the inference session.""" + + def step(self, inference_input: InferenceInput) -> None: + """Run one inference step. + + Args: + inference_input: Model-ready inputs for the step. + """ diff --git a/flashdreams/flashdreams/runtime/inputs.py b/flashdreams/flashdreams/runtime/inputs.py index f0be31bea..66a4432c9 100644 --- a/flashdreams/flashdreams/runtime/inputs.py +++ b/flashdreams/flashdreams/runtime/inputs.py @@ -1,17 +1,20 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""User- and model-input envelopes for the experimental runtime API.""" +"""User, canonical, and model-input schemas for the experimental runtime API.""" from __future__ import annotations import math from collections.abc import Iterable, Mapping from dataclasses import dataclass, field -from typing import Any, Literal, cast +from typing import TYPE_CHECKING, Any, Literal, cast from flashdreams.runtime._utils import freeze_mapping +if TYPE_CHECKING: + from flashdreams.runtime.inference_session import InferenceInput + InputPhase = Literal["global", "step"] INPUT_PHASES: tuple[InputPhase, ...] = ("global", "step") @@ -258,7 +261,7 @@ def _select( if input_field.required is required ) - def unsupported_global_updates(self, inputs: "InferenceInput") -> tuple[str, ...]: + def unsupported_global_updates(self, inputs: InferenceInput) -> tuple[str, ...]: """Return requested conditioning updates this model cannot apply. A field whose ``update_policy`` is :data:`SESSION_START_ONLY` can be @@ -273,15 +276,15 @@ def unsupported_global_updates(self, inputs: "InferenceInput") -> tuple[str, ... and declared.update_policy == SESSION_START_ONLY ) - def missing_global(self, inputs: "InferenceInput") -> tuple[str, ...]: + def missing_global(self, inputs: InferenceInput) -> tuple[str, ...]: """Return required initial fields absent from ``inputs``.""" return _missing_required(self.global_fields, inputs.global_conditioning) - def missing_step(self, inputs: "InferenceInput") -> tuple[str, ...]: + def missing_step(self, inputs: InferenceInput) -> tuple[str, ...]: """Return required per-step fields absent from ``inputs``.""" return _missing_required(self.step_fields, inputs.step) - def require_global(self, inputs: "InferenceInput") -> None: + def require_global(self, inputs: InferenceInput) -> None: """Raise if required initial fields are absent.""" missing = self.missing_global(inputs) if missing: @@ -289,7 +292,7 @@ def require_global(self, inputs: "InferenceInput") -> None: f"Missing required global conditioning input(s): {missing}" ) - def require_step(self, inputs: "InferenceInput") -> None: + def require_step(self, inputs: InferenceInput) -> None: """Raise if required per-step fields are absent.""" missing = self.missing_step(inputs) if missing: @@ -371,7 +374,8 @@ class CanonicalModality: Modalities describe live user control only. Global conditioning such as a prompt or conditioning frame is application-owned and reaches - :class:`InferenceInput` directly, without passing through this layer. + :class:`flashdreams.runtime.inference_session.InferenceInput` directly, + without passing through this layer. """ name: str @@ -426,8 +430,8 @@ class CanonicalInputs: Values are level-triggered and normally present every step: a key held down emits no events but still means full throttle. Global conditioning does not - appear here; it is application-owned and reaches :class:`InferenceInput` - directly. + appear here; it is application-owned and reaches + :class:`flashdreams.runtime.inference_session.InferenceInput` directly. """ __hash__ = None @@ -440,77 +444,6 @@ def __post_init__(self) -> None: object.__setattr__(self, "metadata", freeze_mapping(self.metadata)) -@dataclass(frozen=True, kw_only=True, slots=True) -class InferenceInput: - """Encoded inputs for one :class:`InferenceSession` call. - - Two conditioning slots: - - - ``global_conditioning``: values that condition the whole rollout, such as - the conditioning frame or prompt. Normally supplied when the session - starts. - - ``step``: values needed to generate the next chunk or frame. - - A non-empty ``global_conditioning`` on a mid-rollout input is an *update - request*, not a reset. The session should apply it when the model supports - that; resetting rollout state is a separate, explicit - :meth:`InferenceSession.reset` call. Whether a given value can be updated - mid-rollout is declared per field by ``InputField.update_policy``; see - :meth:`InferenceInputSchema.unsupported_global_updates`. - """ - - __hash__ = None - - global_conditioning: Mapping[str, Any] = field(default_factory=dict) - step: Mapping[str, Any] = field(default_factory=dict) - metadata: Mapping[str, Any] = field(default_factory=dict) - - def __post_init__(self) -> None: - object.__setattr__( - self, "global_conditioning", freeze_mapping(self.global_conditioning) - ) - object.__setattr__(self, "step", freeze_mapping(self.step)) - object.__setattr__(self, "metadata", freeze_mapping(self.metadata)) - - @property - def requests_global_update(self) -> bool: - """Return whether this input asks the session to update conditioning.""" - return bool(self.global_conditioning) - - def with_step(self, step: Mapping[str, Any]) -> "InferenceInput": - """Return a copy with replaced per-step payload. - - The global slot is carried through unchanged, so a mid-rollout input - built this way keeps whatever update request it already had. Use - :meth:`without_global_update` for the common steady-state case. - """ - return InferenceInput( - global_conditioning=self.global_conditioning, - step=step, - metadata=self.metadata, - ) - - def with_global_update( - self, global_conditioning: Mapping[str, Any] - ) -> "InferenceInput": - """Return a copy requesting a mid-rollout conditioning update.""" - return InferenceInput( - global_conditioning=global_conditioning, - step=self.step, - metadata=self.metadata, - ) - - def without_global_update(self) -> "InferenceInput": - """Return a copy that requests no conditioning update.""" - return InferenceInput(step=self.step, metadata=self.metadata) - - def for_phase(self, phase: InputPhase) -> Mapping[str, Any]: - """Return the payload mapping for ``phase``.""" - return ( - self.global_conditioning if validate_phase(phase) == "global" else self.step - ) - - def _missing_required( fields: tuple[InputField, ...], payload: Mapping[str, Any] ) -> tuple[str, ...]: diff --git a/flashdreams/flashdreams/runtime/interfaces.py b/flashdreams/flashdreams/runtime/interfaces.py index 852a77f1c..0ff276e46 100644 --- a/flashdreams/flashdreams/runtime/interfaces.py +++ b/flashdreams/flashdreams/runtime/interfaces.py @@ -8,11 +8,8 @@ from typing import Protocol, runtime_checkable from flashdreams.runtime.config import InferenceConfig -from flashdreams.runtime.inputs import ( - CanonicalInputSchema, - InferenceInput, - InferenceInputSchema, -) +from flashdreams.runtime.inference_session import InferenceInput +from flashdreams.runtime.inputs import CanonicalInputSchema, InferenceInputSchema from flashdreams.runtime.mapping import InputMapping from flashdreams.runtime.types import StepRequest, StepResult diff --git a/flashdreams/flashdreams/runtime/mapping.py b/flashdreams/flashdreams/runtime/mapping.py index 94f481406..4527c3fdd 100644 --- a/flashdreams/flashdreams/runtime/mapping.py +++ b/flashdreams/flashdreams/runtime/mapping.py @@ -10,12 +10,12 @@ from typing import Any, Protocol, runtime_checkable from flashdreams.runtime._utils import freeze_mapping +from flashdreams.runtime.inference_session import InferenceInput from flashdreams.runtime.inputs import ( INPUT_PHASES, CanonicalInputs, CanonicalInputSchema, CanonicalModality, - InferenceInput, InferenceInputSchema, InputField, InputPhase, From 901d8d0d7aacf3a6a89e6ae551674368a8f40106 Mon Sep 17 00:00:00 2001 From: Jesse Archer Date: Thu, 30 Jul 2026 21:25:10 +0000 Subject: [PATCH 07/30] Add inference runtime API design proposal --- docs/inference_runtime_api_design.md | 640 +++++++++++++++++++++++++++ 1 file changed, 640 insertions(+) create mode 100644 docs/inference_runtime_api_design.md diff --git a/docs/inference_runtime_api_design.md b/docs/inference_runtime_api_design.md new file mode 100644 index 000000000..6a4eea9dc --- /dev/null +++ b/docs/inference_runtime_api_design.md @@ -0,0 +1,640 @@ + + +# FlashDreams Inference Runtime API Design Proposal + +Date: July 30, 2026 + +## Summary + +This proposal defines a standard inference runtime API for FlashDreams +integrations. The goal is to make world-model integrations easier to build, +benchmark, and run without forcing every model into the same input shape or +optimization stack. + +The proposed API separates the pieces that are currently mixed together in +integration-specific runner code: + +- `InferenceConfig`: how the model and inference stack should run; +- `UserInputs`: controls or events from an app, replay trace, or benchmark; +- `ModelInputs`: prompts, frames, videos, trajectories, maps, scene data, and + other values required by a specific model; +- input mapping: model/application-specific conversion from user-facing inputs + into model-facing inputs; +- runtime/session execution: model setup, warmup, per-rollout state, and + stepping; +- output targets: WebRTC, native display, MP4, benchmark artifacts, or headless + runs; +- metrics/profiling: timings, memory, traces, NVTX ranges, and benchmark + outputs. + +The API should standardize the envelope and lifecycle. It should not pretend +that all world models have the same inputs, that all models use the same +optimization stack, or that a raw checkpoint can fully describe how to run the +model. + +## Current Implementation Plan + +Implementation should happen on an experimental integration branch. PRs for this +work should target that branch until the API shape, LingBot migration, and +OmniDreams migration are all working well enough to merge to `main` together. + +The experimental branch can temporarily break or simplify command-line options +while the demos are being moved to the new API. The required outcome is that the +LingBot and OmniDreams demos still run through the new runtime path, and that +benchmark tooling can confirm they are at least broadly healthy before the +branch is merged back to `main`. + +Initial scope: + +- define the minimal runtime API envelope; +- migrate LingBot and OmniDreams to use it; +- support selectable output modes such as MP4, JPEG/MJPEG stream, WebRTC, and + headless/null where appropriate; +- use or update benchmark tooling to verify the migrated demos; +- defer broader model migrations, hosted execution, full autotune, and polished + metrics until the first branch proves the API shape. + +## Task Tracker + +| ID | Workstream | Can run in parallel? | Depends on | Done when | +| --- | --- | --- | --- | --- | +| T0 | Create experimental branch and contribution rules. | No, this starts the work. | None. | Branch exists, PR target is agreed, and main merge criteria are written down. | +| T1 | Minimal API envelope and naming. | Partly. | T0. | `InferenceConfig`, `UserInputs`, `ModelInputs`, runtime/session, output target, and mapping boundaries are defined well enough for demos to use. | +| T2 | Event-based `UserInputs`. | Yes, after T1 direction is agreed. | T1. | User inputs are primarily timestamped events; replay traces and derived snapshots are supported where needed. | +| T3 | `ModelInputs`, schemas, and mapping boundary. | Yes, after T1 direction is agreed. | T1. | Models can declare required initial/per-step inputs, and mappings can convert user events into model inputs. | +| T4 | `ModelRunner`, `InferenceRuntime`, and `InferenceSession` skeleton. | Partly. | T1. | A minimal standard loop can initialize a runtime, run at least one sequential session, and close cleanly. | +| T5 | Output mode selection. | Yes, after the result/output shape is agreed. | T1, T4. | A run can choose output behavior such as MP4, JPEG/MJPEG stream, WebRTC, benchmark artifact, or headless/null without changing model code. | +| T6 | LingBot migration. | Yes, once T2-T4 have a usable skeleton. | T2, T3, T4. | LingBot runs through the new API path with its event inputs mapped into model inputs. | +| T7 | OmniDreams migration. | Yes, once T2-T4 have a usable skeleton. | T2, T3, T4. | OmniDreams runs through the new API path with its model-specific inputs and mapping preserved. | +| T8 | Benchmark/smoke verification for LingBot and OmniDreams. | Preparation can run early; final gate is late. | T5, T6, T7. | Existing or updated benchmark tooling can run both migrated demos and produce enough evidence that they still work. | +| T9 | Metrics and profiling normalization for the branch. | Yes, but final integration is late. | T4, T5, T8. | Basic canonical metrics are emitted for migrated demos; deeper metrics can remain follow-up work. | +| T10 | CLI compatibility and migration cleanup. | Yes, after demo migrations start. | T6, T7. | Required demo commands are restored or replaced, temporary hacks are removed, and user-facing docs/notes match the branch behavior. | +| T11 | Stabilize and merge experimental branch to `main`. | No, final integration step. | T6-T10. | LingBot and OmniDreams pass agreed smoke/benchmark checks, review feedback is addressed, and the branch can merge as one API transition. | + +Suggested parallel split: + +- one person owns T1/T4, because the API envelope and standard loop are the + critical path; +- one person owns T2/T3, because event inputs, schemas, and mapping need to + stay coherent; +- one person owns T5/T8/T9, because outputs, benchmarks, and metrics are tightly + related; +- LingBot and OmniDreams can be assigned separately once the skeleton is usable; +- one person should track branch health, CLI compatibility, and merge readiness. + +## Architecture + +```text +Optional discovery for CLI, benchmark, hosted, or installed-package flows: + Model/preset registry + -> adapter/preset/default setup/scenario metadata + -> contributes defaults to the app-supplied run setup + +Main runtime flow: +App / integration / benchmark / transport + chooses how the run is driven and where output goes + supplies run setup: + InferenceConfig + UserInputs + ModelInputs + output/metrics options + | + v +ModelRunner / standard loop + orchestrates validation, lifecycle, stepping, output, and metrics + uses input mapping to: + validate that user/app inputs can drive the model + build initial and per-step ModelInputs during the run + | + v +InferenceRuntime + reusable heavyweight lifecycle: distributed init, model load, compile, warmup + load once; create sessions sequentially unless the backend supports concurrency + | + v +InferenceSession + one rollout/stream: prompt/initial inputs, cache/state, current step, reset + keeps per-run state from leaking across prompts, clients, or benchmark repeats + | + v +Model implementation / inference pipeline + hot path: encode -> model step -> decode -> cache/finalize + | + v +Output target + WebRTC | native window | MP4 | benchmark | headless/null + | + v +Metrics / artifacts / logs / reports / traces +``` + +## Example Sequential Session Flow + +The runtime/session split is primarily about reusing expensive model setup while +keeping each rollout's state isolated. The default mental model should be +sequential sessions, not required concurrent sessions. + +```text +ModelRunner / standard loop + | + v +Create InferenceRuntime from InferenceConfig + load checkpoint/model + initialize distributed/backend state + compile/capture/warm up if configured + | + v +Start InferenceSession A + initial ModelInputs: prompt/frame/scene/etc. + per-session state: cache, current step, reset state + step 0 -> step 1 -> ... -> done + outputs -> Output target + metrics -> Metrics recorder + close session A + | + v +Start InferenceSession B + new initial ModelInputs or replay scenario + independent cache/state + step 0 -> step 1 -> ... -> done + outputs -> Output target + metrics -> Metrics recorder + close session B + | + v +Close InferenceRuntime + release model/backend resources +``` + +For v0, an `InferenceRuntime` may support only one active session at a time. +Concurrent sessions should be treated as an optional backend/model capability, +not a baseline API requirement. + +`StreamInferencePipeline` should remain an important local implementation path +for models that already use it, but it should not be treated as the only +possible model boundary. A session may call `StreamInferencePipeline`, another +local model implementation, a Dynamo-like backend, or a hosted service. + +## System Components + +| Component | Role | Boundary | +| --- | --- | --- | +| Model/preset registry | Lists what can run: model/preset slugs, scenarios, capabilities, resource hints, and supported output modes. | Must remain cheap to query and must not load checkpoints. | +| App / integration / benchmark / transport | Owns the user-facing mode: CLI, native integration, WebRTC, benchmark, hosted request, or replay. | Supplies run setup, user inputs, model inputs, and output target selection. | +| User input library | Normalizes live or replayed controls into FlashDreams-supported user input events/windows. | Shared primitives for keyboard, reset, prompt/image updates, traces, and future scalar controls. | +| Input mapping | Converts user/app inputs plus initial model inputs into the model-specific inputs needed by the session. | Owned by model/application code; may be a no-op for simple runs. | +| ModelRunner / standard loop | Orchestrates one run from setup through runtime initialization, stepping, output, metrics, and teardown. | Shared orchestration layer used by CLIs, benchmarks, MP4 runs, and simple realtime flows. | +| InferenceRuntime | Owns heavyweight lifecycle: distributed init, model construction, checkpoint loading, compile/capture, warmup, hosted-service connection, and teardown. | Long-lived reusable runtime created from `InferenceConfig`; lets FlashDreams load/warm once and create sessions sequentially unless the backend supports concurrency. | +| InferenceSession | Owns one rollout or stream: initial inputs, cache state, current step, reset behavior, step requirements, and step execution. | Per-rollout interface consumed by the standard loop; keeps state isolated across prompts, browser clients, replay scenarios, or benchmark repeats. | +| Model implementation / inference pipeline | Implements encode, model step, decode, cache updates, and model-specific optimizations. | FlashDreams wraps this boundary; it should not replace every model implementation. | +| Output target | Consumes generated outputs and handles presentation or persistence. | Separate from model execution so the same session can feed WebRTC, MP4, benchmark, or headless output. | +| Metrics, artifacts, and profiling | Records timings, memory, quality data, logs, reports, traces, and optional NVTX ranges. | Shared observation layer for local runs, benchmarks, CI smoke, and hosted runs. | + +## API Layers + +FlashDreams should expose layered APIs rather than a single all-or-nothing +interface: + +```text +High-level runtime API + run setup -> standard loop -> output targets -> metrics/artifacts + +Adapter/runtime API + model adapter -> InferenceRuntime -> InferenceSession + +Low-level inference API + StreamInferencePipeline -> encoders/decoders -> cache/perf/profiling helpers +``` + +| Layer | Intended user | Provides | +| --- | --- | --- | +| High-level runtime API | Users who want FlashDreams to own the run loop. | Run setup, input mapping, runtime/session lifecycle, output targets, metrics, profiling, and benchmark artifacts. | +| Adapter/runtime API | Model owners who want their model to plug into the standard loop. | Model adapter, input requirements, runtime/session implementation, and model-specific mapping or validation. | +| Low-level inference API | Users who want to own their own loop while reusing FlashDreams building blocks. | `StreamInferencePipeline`, encoders, decoders, cache helpers, profiling tools, and optimization utilities. | + +These layers should remain compatible. The new runtime API sits above the +existing lower-level pieces; it does not replace them. + +## Goals + +- Make FlashDreams easier to use for new world-model integrations. +- Keep model-specific input semantics explicit instead of hiding them in runner + code. +- Avoid a single monolithic inference stack; different models should be able to + validate and use different optimization features. +- Separate model execution from presentation and persistence. +- Support both live input and deterministic replay through the same + runtime/session boundary. +- Make metrics, benchmark artifacts, and profiling first-class without forcing + profiling overhead into normal runs. +- Preserve room for local single-GPU, local distributed, Dynamo-like, and hosted + execution. + +## Non-Goals + +- Do not infer arbitrary model semantics from a raw checkpoint. +- Do not require every model to use the same encoder, decoder, scheduler, + control representation, transport, or optimization set. +- Do not make WebRTC or native display part of the model API. +- Do not make autotuning part of normal inference startup. +- Do not require users to use the high-level standard loop when they only need + lower-level inference building blocks. +- Do not require every existing integration to migrate in one large change. + +## API Placement + +The new API should sit above the existing `flashdreams.infra` layer. Existing +pipelines, encoders, decoders, runner configs, realtime input helpers, WebRTC +code, and quality/benchmark utilities should be reused where possible. + +The exact package layout and class definitions can be decided during +implementation. This document should define responsibilities and boundaries, not +the final Python shape. + +## InferenceConfig + +`InferenceConfig` describes how to run the model/runtime. It should cover: + +- model or preset identity; +- checkpoint or model asset selection; +- execution backend, such as local single GPU, local multi-GPU, Dynamo-like, or + hosted/external execution; +- device placement, precision, and resource hints; +- optimization choices such as compile, CUDA graph capture, attention backend, + cache policy, overlap, prefetch, and native extensions; +- runtime-affecting profiling or tracing options. + +It should not contain prompts, keyboard state, browser settings, MP4 paths, +benchmark output directories, or other app/output settings. Those belong in the +run setup around `InferenceConfig`. + +Existing `StreamInferencePipelineConfig` and `InstantiateConfig` style configs +can remain valid model references behind this layer. The model adapter should +validate which execution and optimization choices are supported. Unsupported +choices should fail clearly or be explicitly handled only when the user selected +an automatic mode. + +## UserInputs + +`UserInputs` describes user-facing controls produced by a live UI, browser, +native app, replay trace, synthetic benchmark driver, or no-op source. + +User inputs should primarily be represented as timestamped events. This gives +live apps, replay traces, and benchmarks the same basic shape, and lets +FlashDreams resample or window those events when a model session asks for the +next chunk of inputs. + +Initial supported user input types should stay close to what FlashDreams already +uses: + +- keyboard keydown/keyup events; +- reset requests; +- prompt update requests; +- image update requests; +- future scalar controls such as throttle, brake, steer, or camera axes once an + integration needs them. + +Snapshot-style inputs, such as current key state, can still be supported when +useful. They should be treated as a derived or compatibility form rather than +the primary user-input abstraction. + +User inputs are not model inputs. A keyboard event does not have one universal +meaning. One model may map it to pose segments, another to steering commands, +and another may ignore it. + +## ModelInputs + +`ModelInputs` describes the data the model or inference pipeline actually +requires. It should distinguish: + +- initial inputs: values needed to start or reset a rollout; +- per-step inputs: values needed for one generated chunk or frame window. + +Examples of initial model inputs include prompt, negative prompt, first frame, +input video, scene id, HD map asset, camera calibration, initial camera pose, +seed, or model-specific fields. + +Examples of per-step model inputs include frame timestamps, pose segments, +camera trajectory chunks, rendered HD map frames, conditioning video windows, +control tensors, event markers, or model-specific fields. + +Model input payloads should use semantic names, not only modality names. For +example, a first frame and an HD map frame should be distinct inputs even if +both are image-like values. + +For interactive runs, most `ModelInputs` will be initial values plus per-step +inputs produced by input mapping. For MP4 generation and benchmarking, the API +should also support fixed per-step model inputs so runs can be deterministic. + +## Schemas + +The API should support lightweight `UserInputSchema` and `ModelInputSchema` +metadata. + +These schemas are not meant to be a rich type system or a replacement for +model-specific validation. They should be just enough to answer: + +- what can this app, transport, trace, or benchmark source provide? +- what does this model require before startup and at each step? +- can this event source drive this model with the selected mapping? + +The purpose is to fail early before expensive model initialization, produce +clearer errors, make fixed scenarios easier to validate, and avoid ambiguous +dict payloads where keys only describe modality. + +For simple CLI text-to-video or image-to-video runs, `UserInputSchema` can be +trivial or omitted because there may be no live controls. `ModelInputSchema` is +more important because each supported model still needs to declare the +model-facing values it expects. + +## Model Requirements + +A raw checkpoint should not be treated as self-describing. It may imply tensor +shapes or architecture details, but it usually does not fully define: + +- required semantic inputs; +- initial versus per-step inputs; +- units for timestamps, poses, or calibration values; +- how user controls become model controls; +- preprocessing, encoder, decoder, mask, prompt, or cache rules. + +Therefore, a FlashDreams-supported model should have an adapter or integration +layer that declares its model input requirements and prepares inputs for the +underlying model implementation. + +Users running an existing FlashDreams-supported model should not need to write +that adapter. Developers bringing a new world model to FlashDreams should expect +to provide one. + +## External Model Usage + +Users should be able to run their own models without adding those models to the +FlashDreams repository. The flow depends on which API layer they use: + +```text +High-level runtime API + user supplies or installs model adapter + FlashDreams owns standard loop, outputs, metrics, benchmarks + +Adapter/runtime API + model owner implements adapter/runtime/session + adapter can be passed directly or registered by an installed package + +Low-level inference API + user owns loop and lifecycle + user reuses pipeline, encoder/decoder, cache, profiling, or optimization tools +``` + +| Flow | Registry needed? | Who provides model-specific code? | Result | +| --- | --- | --- | --- | +| Direct Python | No. | User or model owner passes an adapter/setup directly. | FlashDreams can run the standard loop without the model living in the repo. | +| Installed package | Yes, for discovery. | External or internal package registers adapters/presets. | CLIs, benchmarks, and hosted schedulers can discover the model cheaply. | +| Low-level only | No. | User owns the loop and calls lower-level FlashDreams pieces directly. | Useful when the user wants optimizations or pipeline helpers but not the standard loop. | + +The model adapter is a role/boundary, not necessarily a concrete class. It is +the model-specific code that declares input requirements, validates supported +configs, creates the runtime/session, and connects FlashDreams to the actual +model implementation. + +The registry should not be treated as a central FlashDreams-owned catalog of all +possible models. It is a discovery mechanism for installed adapters. Built-in +public integrations, internal GitLab-only integrations, and third-party packages +can all participate through the same mechanism. + +FlashDreams should not claim to run an arbitrary checkpoint with no adapter +unless the checkpoint already matches a supported generic adapter. + +## Input Mapping + +Input mapping is required whenever `UserInputs` need to become per-step +`ModelInputs`. The exact implementation does not need to be a required top-level +object. It could be: + +- a method on the model adapter; +- a method on an app/runtime adapter; +- a separate mapper object; +- a default no-op or identity mapping for simple T2V/I2V/fixed-input runs. + +There are two separate moments to keep clear: + +- before runtime initialization, FlashDreams should select the mapping and check + obvious compatibility between the app event source and the model; +- during the standard loop, the runner uses the mapping to build initial or + per-step `ModelInputs` from the relevant event window, often after the session + reports what it needs next. + +Examples: + +- T2V mapping validates a prompt and creates no per-step control inputs. +- I2V mapping validates a prompt plus first frame and creates no live controls. +- A keyboard-driven integration maps key events or event windows into pose + segments or steering controls. +- OmniDreams-like integrations may map driving commands into camera poses, HD + map frames, and dynamic actor state. +- Benchmark mapping can read fixed event traces and produce identical step + inputs each run. + +The compatibility check should be treated as early validation, not a guarantee +that the run will succeed. It can catch obvious mismatches, but the model +adapter/runtime still owns deep tensor validation and model semantics. + +## Runtime And Standard Loop + +The standard loop should be shared by CLI generation, headless playback, MP4 +generation, benchmarks, and simple realtime applications. + +A run should: + +1. Discover the model or preset without loading checkpoints. +2. Resolve inference config, user inputs, model inputs, output target, metrics, + profiling, and optional scenario setup. +3. Validate that the event source and mapping can drive the selected model. +4. Initialize the runtime. +5. Start a session from initial model inputs. +6. For each step, ask the session what it needs, gather live or fixed inputs, + build step model inputs, run the session step, route outputs, and record + metrics. +7. Finalize output artifacts, metrics, logs, reports, and traces. + +Realtime transports may need an async variant, backpressure, and explicit flow +control, but the conceptual boundary should remain the same: event/input source, +input mapping, session, output target, metrics. + +The session should expose what it needs for the next step rather than requiring +the app or output layer to guess. This matters because AR step 0 can differ from +steady-state steps, and encoder/decoder temporal compression can produce +different input and output frame windows. + +## Output Targets + +Output handling should be separate from model execution. The model session +returns generated outputs and metadata; the output target decides what to do +with them. + +Expected output targets include: + +- WebRTC streaming; +- native window display; +- MJPEG or lightweight remote preview; +- MP4 writing; +- benchmark artifact writing; +- headless playback; +- null output for pure throughput measurements. + +Display and transport can still affect measured performance through copies, +encoding, queueing, backpressure, and presentation timing. Those costs should be +measured as output-target or end-to-end metrics instead of being mixed into core +model-stage timings. + +## Fixed Inputs, Benchmarks + +The API should support fixed runs as a first-class case. This is needed for MP4 +generation, benchmarks, regression testing, and autotune. + +Two replay levels should be supported: + +- user-event replay: records timestamped key events, prompt updates, image + updates, reset events, and timing, then runs normal input mapping; +- model-input replay: records or defines already-mapped per-step model inputs + for stricter model-level regression tests. + +User-event replay tests more of the application stack. Model-input replay is +better for isolating model runtime performance and reproducibility. + +## Metrics And Profiling + +Metrics should have a small canonical baseline plus optional extras. + +The baseline should cover: + +- lifecycle timing: startup, load, warmup, first-step latency; +- model-stage timing: encode, model step, decode, finalize/cache update; +- memory: allocated, reserved, peak, and per-rank where applicable; +- throughput: frames per second, chunks per second, real-time factor. + +Realtime runs may add input-to-present latency, jitter, missed deadlines, queue +depth, dropped frames, WebRTC stats, encoder bitrate, and client stats. +Benchmark runs may add quality metrics, logs, MP4/image previews, and reports. + +Persisted timing metrics should use seconds as the canonical unit because +seconds compose cleanly across Python timers, traces, and long-running +durations. Reports and UIs can display milliseconds for short latencies. + +Profiling should be optional and controlled separately from normal metrics. +NVTX ranges should be supported for Nsight profiling, but profiling should not +be required for normal inference or benchmark runs. + +## Autotune + +Autotune should be a separate harness that evaluates candidate +`InferenceConfig` variants against fixed scenarios. It should not be part of +normal startup. + +Autotune may search over compile, CUDA graph capture, attention backend, +precision, cache policy, overlap, prefetch, native extensions, and chunk size +when the model supports those knobs. + +Results are only valid for a specific model, checkpoint, hardware, driver, +FlashDreams commit, and scenario. First-run compile/capture cost should be +separated from steady-state metrics. Agent assistance could help propose search +spaces or summarize results, but the measured selection process should be +deterministic code. + +## Distributed And Hosted Execution + +The API should leave room for local single-GPU, local multi-GPU, Dynamo-like +execution, and hosted execution such as a Reactor-style platform. + +At this stage, the proposal should not define Reactor- or Dynamo-specific +contracts in detail. It should preserve the right boundary: execution backend +selection belongs in `InferenceConfig`, while backend-specific scheduling, +authentication, asset access, output streaming, artifact handling, and failure +behavior belong behind the runtime/backend implementation. + +The practical order should be local first, then local distributed, then +hosted/distributed backends once concrete backend owners can validate the +requirements. + +## Existing Code And Migration + +The new API should reuse existing code instead of replacing everything: + +- keep `flashdreams.infra.pipeline` as the common local encode/model/decode + implementation path; +- keep existing encoder and decoder contracts and reuse temporal size helpers; +- keep existing runner configs and CLI compatibility during migration; +- reuse `KeyboardResampler` and realtime input helpers behind the new input + boundary; +- treat WebRTC as a transport/output adapter and bridge it gradually; +- reuse existing quality and benchmark utilities where applicable; +- keep internal-only integrations registered only in the GitLab/internal + workspace. + +The task tracker near the start of this document is the source of truth for the +first implementation branch. The first milestone is intentionally narrower than +the full design: prove the API with LingBot and OmniDreams, selectable output +modes, and enough benchmark/smoke coverage to merge the experimental branch +back to `main` safely. + +## Design Risks + +- `InferenceConfig` could become too broad if prompts, controls, output paths, + browser settings, and benchmark settings are added to it. Keep it focused on + model/runtime execution. +- Dict-like model inputs are flexible but can fail late. Keep dict payloads for + flexibility, but require lightweight schemas and adapter validation for + supported models. +- Schemas could become too heavy. Keep them minimal and role-oriented. +- User inputs are not model inputs. Keep input mapping explicit and + model/application-owned. +- Per-frame, per-chunk, and AR-step clocks are easy to confuse. The session + should expose step requirements instead of making app code guess. +- Output separation is necessary but not free. Measure output and transport + costs separately from core model timings. +- Hosted/distributed execution is still under-specified. Keep the API boundary + open until backend owners validate concrete requirements. +- Existing WebRTC behavior is nontrivial. Bridge it gradually to avoid + regressions. +- Public/internal boundaries must remain clean. Internal adapters, slugs, and + scenarios should not leak into the public repo. + +## Decisions To Make Before Implementation + +- What should the top-level package/API be called? +- Should the main registered object be called an adapter, integration, runtime + factory, or something else? +- What direct-Python API should let users pass an external adapter without + registering it? +- What package registration mechanism should third-party and internal adapters + use for CLI discovery and benchmarks? +- How lightweight should `UserInputSchema` and `ModelInputSchema` be? +- Where should input mapping live: model adapter, app adapter, separate object, + or a mix? +- What should the output abstraction be called? +- What is the minimum v0 set of supported user input events? +- What is the first public model to migrate? +- What metrics are required for every benchmark run? +- What metadata must be discoverable without loading checkpoints? +- What requirements do Dynamo/Reactor-style backends need before we commit to + hosted execution details? + +The document currently uses "integration" for model-specific packages and app +entrypoints. If the team prefers "model" as the public term, that can be changed +later without changing the architecture. + +## Recommendation + +Proceed with the proposed split: + +- `InferenceConfig` for model/runtime execution; +- `UserInputs` for app-facing controls and replay traces; +- `ModelInputs` for model-facing initial and per-step inputs; +- input mapping for model/application-specific conversion; +- runtime/session boundaries for lifecycle and stepping; +- output targets for display, streaming, files, and benchmarks; +- shared metrics and optional profiling. + +The main constraint is that arbitrary world-model inputs cannot be standardized +away. FlashDreams can provide the shared envelope, loop, metrics, replay, and +output tools, but each supported model still needs an adapter that declares and +validates its own input contract. From 69299ecdedad280c5d5089e5b95fc22f345c1db5 Mon Sep 17 00:00:00 2001 From: jarcherNV Date: Tue, 4 Aug 2026 02:11:54 -0700 Subject: [PATCH 08/30] Add experimental inference runtime API envelope (#403) Define the initial flashdreams.runtime package with minimal T1 boundaries for runtime config, user/model inputs, schemas, input mapping, model adapters, runtime/session protocols, output targets, and metrics. Add focused CPU tests for the new API surface without migrating existing runners. --- docs/inference_runtime_api_design.md | 112 ++-- flashdreams/flashdreams/runtime/__init__.py | 60 +++ flashdreams/flashdreams/runtime/_utils.py | 17 + flashdreams/flashdreams/runtime/config.py | 76 +++ flashdreams/flashdreams/runtime/inputs.py | 200 +++++++ flashdreams/flashdreams/runtime/interfaces.py | 90 ++++ flashdreams/flashdreams/runtime/mapping.py | 85 +++ flashdreams/flashdreams/runtime/metrics.py | 124 +++++ flashdreams/flashdreams/runtime/output.py | 78 +++ flashdreams/flashdreams/runtime/types.py | 56 ++ .../tests/test_inference_runtime_api.py | 492 ++++++++++++++++++ 11 files changed, 1350 insertions(+), 40 deletions(-) create mode 100644 flashdreams/flashdreams/runtime/__init__.py create mode 100644 flashdreams/flashdreams/runtime/_utils.py create mode 100644 flashdreams/flashdreams/runtime/config.py create mode 100644 flashdreams/flashdreams/runtime/inputs.py create mode 100644 flashdreams/flashdreams/runtime/interfaces.py create mode 100644 flashdreams/flashdreams/runtime/mapping.py create mode 100644 flashdreams/flashdreams/runtime/metrics.py create mode 100644 flashdreams/flashdreams/runtime/output.py create mode 100644 flashdreams/flashdreams/runtime/types.py create mode 100644 flashdreams/tests/test_inference_runtime_api.py diff --git a/docs/inference_runtime_api_design.md b/docs/inference_runtime_api_design.md index 6a4eea9dc..2f0ba19f8 100644 --- a/docs/inference_runtime_api_design.md +++ b/docs/inference_runtime_api_design.md @@ -59,25 +59,25 @@ Initial scope: ## Task Tracker -| ID | Workstream | Can run in parallel? | Depends on | Done when | -| --- | --- | --- | --- | --- | -| T0 | Create experimental branch and contribution rules. | No, this starts the work. | None. | Branch exists, PR target is agreed, and main merge criteria are written down. | -| T1 | Minimal API envelope and naming. | Partly. | T0. | `InferenceConfig`, `UserInputs`, `ModelInputs`, runtime/session, output target, and mapping boundaries are defined well enough for demos to use. | -| T2 | Event-based `UserInputs`. | Yes, after T1 direction is agreed. | T1. | User inputs are primarily timestamped events; replay traces and derived snapshots are supported where needed. | -| T3 | `ModelInputs`, schemas, and mapping boundary. | Yes, after T1 direction is agreed. | T1. | Models can declare required initial/per-step inputs, and mappings can convert user events into model inputs. | -| T4 | `ModelRunner`, `InferenceRuntime`, and `InferenceSession` skeleton. | Partly. | T1. | A minimal standard loop can initialize a runtime, run at least one sequential session, and close cleanly. | -| T5 | Output mode selection. | Yes, after the result/output shape is agreed. | T1, T4. | A run can choose output behavior such as MP4, JPEG/MJPEG stream, WebRTC, benchmark artifact, or headless/null without changing model code. | -| T6 | LingBot migration. | Yes, once T2-T4 have a usable skeleton. | T2, T3, T4. | LingBot runs through the new API path with its event inputs mapped into model inputs. | -| T7 | OmniDreams migration. | Yes, once T2-T4 have a usable skeleton. | T2, T3, T4. | OmniDreams runs through the new API path with its model-specific inputs and mapping preserved. | -| T8 | Benchmark/smoke verification for LingBot and OmniDreams. | Preparation can run early; final gate is late. | T5, T6, T7. | Existing or updated benchmark tooling can run both migrated demos and produce enough evidence that they still work. | -| T9 | Metrics and profiling normalization for the branch. | Yes, but final integration is late. | T4, T5, T8. | Basic canonical metrics are emitted for migrated demos; deeper metrics can remain follow-up work. | -| T10 | CLI compatibility and migration cleanup. | Yes, after demo migrations start. | T6, T7. | Required demo commands are restored or replaced, temporary hacks are removed, and user-facing docs/notes match the branch behavior. | -| T11 | Stabilize and merge experimental branch to `main`. | No, final integration step. | T6-T10. | LingBot and OmniDreams pass agreed smoke/benchmark checks, review feedback is addressed, and the branch can merge as one API transition. | +| ID | Status | Workstream | Can run in parallel? | Depends on | Done when | +| --- | --- | --- | --- | --- | --- | +| T0 | Complete | Create experimental branch and contribution rules. | No, this starts the work. | None. | Branch exists, PR target is agreed, and main merge criteria are written down. | +| T1 | Complete | Minimal API envelope and naming. | Partly. | T0. | `InferenceConfig`, `UserInputs`, `ModelInputs`, runtime/session, output target, and mapping boundaries are defined well enough for demos to use. | +| T2 | Planned | Event-based `UserInputs`. | Yes, after T1 direction is agreed. | T1. | User inputs are primarily timestamped events; replay traces and derived snapshots are supported where needed. | +| T3 | Planned | `ModelInputs`, schemas, and mapping boundary. | Yes, after T1 direction is agreed. | T1. | Models can declare required initial/per-step inputs, and mappings can convert user events into model inputs. | +| T4 | Planned | `ModelRunner`, `InferenceRuntime`, and `InferenceSession` skeleton. | Partly. | T1. | A minimal standard loop can initialize a runtime, run at least one sequential session, and close cleanly. | +| T5 | Planned | Output mode selection. | Yes, after the result/output shape is agreed. | T1, T4. | A run can choose output behavior such as MP4, JPEG/MJPEG stream, WebRTC, benchmark artifact, or headless/null without changing model code. | +| T6 | Planned | LingBot migration. | Yes, once T2-T4 have a usable skeleton. | T2, T3, T4. | LingBot runs through the new API path with its event inputs mapped into model inputs. | +| T7 | Planned | OmniDreams migration. | Yes, once T2-T4 have a usable skeleton. | T2, T3, T4. | OmniDreams runs through the new API path with its model-specific inputs and mapping preserved. | +| T8 | Planned | Benchmark/smoke verification for LingBot and OmniDreams. | Preparation can run early; final gate is late. | T5, T6, T7. | Existing or updated benchmark tooling can run both migrated demos and produce enough evidence that they still work. | +| T9 | Planned | Metrics and profiling normalization for the branch. | Yes, but final integration is late. | T4, T5, T8. | Basic canonical metrics are emitted for migrated demos; deeper metrics can remain follow-up work. | +| T10 | Planned | CLI compatibility and migration cleanup. | Yes, after demo migrations start. | T6, T7. | Required demo commands are restored or replaced, temporary hacks are removed, and user-facing docs/notes match the branch behavior. | +| T11 | Planned | Stabilize and merge experimental branch to `main`. | No, final integration step. | T6-T10. | LingBot and OmniDreams pass agreed smoke/benchmark checks, review feedback is addressed, and the branch can merge as one API transition. | Suggested parallel split: -- one person owns T1/T4, because the API envelope and standard loop are the - critical path; +- one person owns T4 and keeps it aligned with the completed T1 envelope, + because the standard loop is now the critical path; - one person owns T2/T3, because event inputs, schemas, and mapping need to stay coherent; - one person owns T5/T8/T9, because outputs, benchmarks, and metrics are tightly @@ -182,7 +182,7 @@ local model implementation, a Dynamo-like backend, or a hosted service. | Model/preset registry | Lists what can run: model/preset slugs, scenarios, capabilities, resource hints, and supported output modes. | Must remain cheap to query and must not load checkpoints. | | App / integration / benchmark / transport | Owns the user-facing mode: CLI, native integration, WebRTC, benchmark, hosted request, or replay. | Supplies run setup, user inputs, model inputs, and output target selection. | | User input library | Normalizes live or replayed controls into FlashDreams-supported user input events/windows. | Shared primitives for keyboard, reset, prompt/image updates, traces, and future scalar controls. | -| Input mapping | Converts user/app inputs plus initial model inputs into the model-specific inputs needed by the session. | Owned by model/application code; may be a no-op for simple runs. | +| Input mapping | Converts user/app inputs plus initial model inputs into the model-specific inputs needed by the session. | A model adapter may provide a default mapping; runtimes, applications, benchmarks, and replay tools may override it without changing the model step. | | ModelRunner / standard loop | Orchestrates one run from setup through runtime initialization, stepping, output, metrics, and teardown. | Shared orchestration layer used by CLIs, benchmarks, MP4 runs, and simple realtime flows. | | InferenceRuntime | Owns heavyweight lifecycle: distributed init, model construction, checkpoint loading, compile/capture, warmup, hosted-service connection, and teardown. | Long-lived reusable runtime created from `InferenceConfig`; lets FlashDreams load/warm once and create sessions sequentially unless the backend supports concurrency. | | InferenceSession | Owns one rollout or stream: initial inputs, cache state, current step, reset behavior, step requirements, and step execution. | Per-rollout interface consumed by the standard loop; keeps state isolated across prompts, browser clients, replay scenarios, or benchmark repeats. | @@ -281,8 +281,11 @@ native app, replay trace, synthetic benchmark driver, or no-op source. User inputs should primarily be represented as timestamped events. This gives live apps, replay traces, and benchmarks the same basic shape, and lets -FlashDreams resample or window those events when a model session asks for the -next chunk of inputs. +FlashDreams route, drain, or window those events when a model session asks for +the next chunk of inputs. Resampling and interpolation should remain +input-specific mapping or helper behavior, because controls such as rotations, +poses, or controller state may need semantics that generic runtime code cannot +infer safely. Initial supported user input types should stay close to what FlashDreams already uses: @@ -359,8 +362,8 @@ shapes or architecture details, but it usually does not fully define: - preprocessing, encoder, decoder, mask, prompt, or cache rules. Therefore, a FlashDreams-supported model should have an adapter or integration -layer that declares its model input requirements and prepares inputs for the -underlying model implementation. +layer that declares its model input requirements, declares any user inputs it can +map by default, and prepares inputs for the underlying model implementation. Users running an existing FlashDreams-supported model should not need to write that adapter. Developers bringing a new world model to FlashDreams should expect @@ -407,21 +410,25 @@ unless the checkpoint already matches a supported generic adapter. ## Input Mapping Input mapping is required whenever `UserInputs` need to become per-step -`ModelInputs`. The exact implementation does not need to be a required top-level -object. It could be: - -- a method on the model adapter; -- a method on an app/runtime adapter; -- a separate mapper object; -- a default no-op or identity mapping for simple T2V/I2V/fixed-input runs. +`ModelInputs`. In the T1 envelope this boundary is represented by a separate +`InputMapping` protocol. A model adapter may provide the default mapper because +it knows how its supported user controls affect model-facing inputs. Applications, +benchmarks, replay tools, or hosted runtimes may replace that mapper when they +need a different wire surface or aggregation policy. There are two separate moments to keep clear: - before runtime initialization, FlashDreams should select the mapping and check obvious compatibility between the app event source and the model; -- during the standard loop, the runner uses the mapping to build initial or - per-step `ModelInputs` from the relevant event window, often after the session - reports what it needs next. +- during the standard loop, the runtime or runner queues and timestamps user + events, then uses the selected mapping to build initial or per-step + `ModelInputs` from the relevant event window, often after the session reports + what it needs next. + +This keeps the Reactor-style contract intact: the model-side integration can +declare user inputs, declare model inputs, and provide a default mapping, while +the runtime owns transport, event validation, timestamping, input queue/window +selection, output delivery, and optional overrides. Examples: @@ -465,6 +472,11 @@ the app or output layer to guess. This matters because AR step 0 can differ from steady-state steps, and encoder/decoder temporal compression can produce different input and output frame windows. +Input and output timing should share a session timeline even when raw capture +rates and presentation rates differ. A session can request a user-input window +for mapping, then return an output window or equivalent metadata so an output +target can present the generated chunk at the intended cadence. + ## Output Targets Output handling should be separate from model execution. The model session @@ -598,20 +610,40 @@ back to `main` safely. - Public/internal boundaries must remain clean. Internal adapters, slugs, and scenarios should not leak into the public repo. -## Decisions To Make Before Implementation +## Decisions Made In T1 + +Task T1 settles the initial package and naming envelope without committing to a +registry, standard loop, concrete output modes, or model migrations: + +- The experimental API lives under `flashdreams.runtime`. +- The model-specific integration boundary is named `ModelAdapter`. +- Heavyweight lifecycle is split into `InferenceRuntime` and + `InferenceSession`. +- Step data carriers are named `StepRequest` and `StepResult`; a session returns + `None` from `next_step_request()` when the rollout is complete. +- User-facing inputs use `UserInputs`; model-facing inputs use `ModelInputs`. + Both remain lightweight payload envelopes with shallow read-only mappings. +- `UserInputSchema` and `ModelInputSchema` stay intentionally small: they + declare supported event types and required named fields for early validation, + not a full type system. +- Input mapping is represented by a separate `InputMapping` protocol. Model + adapters may provide a default mapping; runtimes and applications may override + it while preserving the `UserInputs` to `ModelInputs` boundary. Simple + fixed-input runs can use `IdentityInputMapping`. +- Output handling is represented by `OutputTarget`; `NullOutputTarget` is the + initial headless implementation. +- Metrics collection is represented by `MetricsRecorder`; timing samples use + seconds as the canonical unit. +- The minimum v0 user input shape is timestamped `UserInputEvent` records plus + optional snapshot data. Concrete event-type catalogs are left to T2 and demo + migrations. + +## Remaining Decisions -- What should the top-level package/API be called? -- Should the main registered object be called an adapter, integration, runtime - factory, or something else? - What direct-Python API should let users pass an external adapter without registering it? - What package registration mechanism should third-party and internal adapters use for CLI discovery and benchmarks? -- How lightweight should `UserInputSchema` and `ModelInputSchema` be? -- Where should input mapping live: model adapter, app adapter, separate object, - or a mix? -- What should the output abstraction be called? -- What is the minimum v0 set of supported user input events? - What is the first public model to migrate? - What metrics are required for every benchmark run? - What metadata must be discoverable without loading checkpoints? diff --git a/flashdreams/flashdreams/runtime/__init__.py b/flashdreams/flashdreams/runtime/__init__.py new file mode 100644 index 000000000..03e6202b0 --- /dev/null +++ b/flashdreams/flashdreams/runtime/__init__.py @@ -0,0 +1,60 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Experimental inference runtime API envelope. + +This package defines the small v0 boundary above ``flashdreams.infra``. It is +intentionally additive while integrations migrate onto it. +""" + +from flashdreams.runtime.config import ExecutionBackend, InferenceConfig, Precision +from flashdreams.runtime.inputs import ( + InputField, + ModelInputs, + ModelInputSchema, + TimeWindow, + UserInputEvent, + UserInputs, + UserInputSchema, +) +from flashdreams.runtime.interfaces import ( + InferenceRuntime, + InferenceSession, + ModelAdapter, +) +from flashdreams.runtime.mapping import IdentityInputMapping, InputMapping +from flashdreams.runtime.metrics import ( + InMemoryMetricsRecorder, + MetricsRecorder, + NullMetricsRecorder, + RuntimeMetricSample, +) +from flashdreams.runtime.output import NullOutputTarget, OutputArtifact, OutputTarget +from flashdreams.runtime.types import StepRequest, StepResult + +__all__ = [ + "ExecutionBackend", + "IdentityInputMapping", + "InferenceConfig", + "InferenceRuntime", + "InferenceSession", + "InMemoryMetricsRecorder", + "InputField", + "InputMapping", + "MetricsRecorder", + "ModelAdapter", + "ModelInputs", + "ModelInputSchema", + "NullMetricsRecorder", + "NullOutputTarget", + "OutputArtifact", + "OutputTarget", + "Precision", + "RuntimeMetricSample", + "StepRequest", + "StepResult", + "TimeWindow", + "UserInputEvent", + "UserInputs", + "UserInputSchema", +] diff --git a/flashdreams/flashdreams/runtime/_utils.py b/flashdreams/flashdreams/runtime/_utils.py new file mode 100644 index 000000000..d8016c6b7 --- /dev/null +++ b/flashdreams/flashdreams/runtime/_utils.py @@ -0,0 +1,17 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Small helpers shared by the experimental runtime API.""" + +from __future__ import annotations + +from collections.abc import Mapping +from types import MappingProxyType +from typing import TypeVar + +ValueT = TypeVar("ValueT") + + +def freeze_mapping(value: Mapping[str, ValueT]) -> Mapping[str, ValueT]: + """Return a read-only shallow copy of ``value``.""" + return MappingProxyType(dict(value)) diff --git a/flashdreams/flashdreams/runtime/config.py b/flashdreams/flashdreams/runtime/config.py new file mode 100644 index 000000000..4b8752f13 --- /dev/null +++ b/flashdreams/flashdreams/runtime/config.py @@ -0,0 +1,76 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Runtime-facing configuration envelope.""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Literal + +from flashdreams.runtime._utils import freeze_mapping + +ExecutionBackend = Literal["local", "local-distributed", "external", "hosted"] +"""Where and how inference compute is run.""" + +Precision = Literal["auto", "fp32", "fp16", "bf16"] +"""Coarse runtime precision choices.""" + + +@dataclass(frozen=True, kw_only=True, slots=True) +class InferenceConfig: + """Runtime settings that affect model execution. + + Prompts, user controls, browser settings, output paths, and benchmark + directories intentionally live outside this object. The typed optimization + fields cover common cross-backend knobs; open-ended adapter-specific choices + can use :attr:`runtime_options`. + """ + + __hash__ = None + + model_id: str + """Stable identity for the model adapter or runtime integration.""" + + preset_id: str | None = None + """Optional preset identity under :attr:`model_id`.""" + + checkpoint: str | Path | None = None + """Optional checkpoint or model-asset selector understood by the adapter.""" + + backend: ExecutionBackend = "local" + """Execution placement and backend family for inference compute.""" + + device: str | None = None + """Optional device selector such as ``cuda`` or ``cuda:0``; ``None`` leaves placement to the adapter/backend.""" + + precision: Precision = "auto" + """Preferred compute precision.""" + + compile: bool | None = None + """Optional - Whether model compilation is requested or disabled. `None` means left to the adapter to decide.""" + + cuda_graph: bool | None = None + """Optional - Whether CUDA graph capture is requested or disabled. `None` means left to the adapter to decide.""" + + attention_backend: str | None = None + """Optional attention implementation selector; ``None`` leaves the choice to the adapter.""" + + cache_policy: str | None = None + """Optional cache policy selector; ``None`` leaves the choice to the adapter.""" + + runtime_options: Mapping[str, Any] = field(default_factory=dict) + """Adapter/backend-specific runtime options.""" + + resource_hints: Mapping[str, Any] = field(default_factory=dict) + """Resource hints for launchers, schedulers, or hosted backends.""" + + def __post_init__(self) -> None: + if not self.model_id.strip(): + raise ValueError("InferenceConfig.model_id must be non-empty.") + object.__setattr__( + self, "runtime_options", freeze_mapping(self.runtime_options) + ) + object.__setattr__(self, "resource_hints", freeze_mapping(self.resource_hints)) diff --git a/flashdreams/flashdreams/runtime/inputs.py b/flashdreams/flashdreams/runtime/inputs.py new file mode 100644 index 000000000..e14b35722 --- /dev/null +++ b/flashdreams/flashdreams/runtime/inputs.py @@ -0,0 +1,200 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""User- and model-input envelopes for the experimental runtime API.""" + +from __future__ import annotations + +import math +from collections.abc import Iterable, Mapping +from dataclasses import dataclass, field +from typing import Any + +from flashdreams.runtime._utils import freeze_mapping + + +@dataclass(frozen=True, kw_only=True, slots=True) +class TimeWindow: + """Half-open time window in seconds since session start.""" + + start_s: float + end_s: float + + def __post_init__(self) -> None: + if not math.isfinite(self.start_s) or not math.isfinite(self.end_s): + raise ValueError("TimeWindow bounds must be finite seconds.") + if self.start_s < 0 or self.end_s < 0: + raise ValueError("TimeWindow bounds must be non-negative.") + if self.end_s < self.start_s: + raise ValueError("TimeWindow.end_s must be >= start_s.") + + def contains(self, timestamp_s: float) -> bool: + """Return whether ``timestamp_s`` falls within this half-open window.""" + return self.start_s <= timestamp_s < self.end_s + + +@dataclass(frozen=True, kw_only=True, slots=True) +class InputField: + """Lightweight schema field for user snapshots or model inputs.""" + + name: str + required: bool = True + semantic_type: str | None = None + description: str = "" + + def __post_init__(self) -> None: + if not self.name.strip(): + raise ValueError("InputField.name must be non-empty.") + + +@dataclass(frozen=True, kw_only=True, slots=True) +class UserInputSchema: + """Minimal metadata for user events a source or mapping can provide.""" + + event_types: frozenset[str] = field(default_factory=frozenset) + snapshot_fields: tuple[InputField, ...] = () + description: str = "" + + def supports_event_types(self, event_types: Iterable[str]) -> bool: + """Return whether every requested event type is declared supported.""" + requested = frozenset(event_types) + if not requested: + return True + return requested.issubset(self.event_types) + + def missing_snapshot(self, inputs: "UserInputs") -> tuple[str, ...]: + """Return required snapshot fields absent from ``inputs``.""" + return _missing_required(self.snapshot_fields, inputs.snapshot) + + def require_snapshot(self, inputs: "UserInputs") -> None: + """Raise if required snapshot fields are absent.""" + missing = self.missing_snapshot(inputs) + if missing: + raise ValueError(f"Missing required user snapshot field(s): {missing}") + + +@dataclass(frozen=True, kw_only=True, slots=True) +class ModelInputSchema: + """Minimal metadata for model-facing initial and per-step inputs.""" + + initial_fields: tuple[InputField, ...] = () + """Model inputs required before starting the initial generation/session.""" + + step_fields: tuple[InputField, ...] = () + """Per-step model inputs required after the session starts.""" + + description: str = "" + + def missing_initial(self, inputs: "ModelInputs") -> tuple[str, ...]: + """Return required initial fields absent from ``inputs``.""" + return _missing_required(self.initial_fields, inputs.initial) + + def missing_step(self, inputs: "ModelInputs") -> tuple[str, ...]: + """Return required per-step fields absent from ``inputs``.""" + return _missing_required(self.step_fields, inputs.step) + + def require_initial(self, inputs: "ModelInputs") -> None: + """Raise if required initial fields are absent.""" + missing = self.missing_initial(inputs) + if missing: + raise ValueError(f"Missing required initial model input(s): {missing}") + + def require_step(self, inputs: "ModelInputs") -> None: + """Raise if required per-step fields are absent.""" + missing = self.missing_step(inputs) + if missing: + raise ValueError(f"Missing required step model input(s): {missing}") + + +@dataclass(frozen=True, kw_only=True, slots=True) +class UserInputEvent: + """User-facing input event timestamped in seconds since session start. + + Live runtimes, transports, replay loaders, or benchmark drivers stamp events + before queuing them for input mapping. Payload schema is intentionally minimal + in T1; concrete event catalogs belong to follow-up input-mapping work. + """ + + __hash__ = None + + timestamp_s: float + event_type: str + payload: Mapping[str, Any] = field(default_factory=dict) + source: str | None = None + source_event_id: str | None = None + + def __post_init__(self) -> None: + if not math.isfinite(self.timestamp_s) or self.timestamp_s < 0: + raise ValueError("UserInputEvent.timestamp_s must be finite and >= 0.") + if not self.event_type.strip(): + raise ValueError("UserInputEvent.event_type must be non-empty.") + object.__setattr__(self, "payload", freeze_mapping(self.payload)) + + +@dataclass(frozen=True, kw_only=True, slots=True) +class UserInputs: + """Transport-neutral user input batch or window. + + Events must be in non-decreasing timestamp order. Runtimes can pass the full + input history, a drained queue batch, or a session-requested time window to an + ``InputMapping``. + """ + + __hash__ = None + + events: tuple[UserInputEvent, ...] = () + snapshot: Mapping[str, Any] = field(default_factory=dict) + metadata: Mapping[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + previous_timestamp_s = -math.inf + for event in self.events: + if event.timestamp_s < previous_timestamp_s: + raise ValueError( + "UserInputs.events must be sorted by non-decreasing timestamp_s." + ) + previous_timestamp_s = event.timestamp_s + object.__setattr__(self, "snapshot", freeze_mapping(self.snapshot)) + object.__setattr__(self, "metadata", freeze_mapping(self.metadata)) + + def window(self, time_window: TimeWindow) -> "UserInputs": + """Return inputs with events filtered to ``time_window``.""" + return UserInputs( + events=tuple( + event + for event in self.events + if time_window.contains(event.timestamp_s) + ), + snapshot=self.snapshot, + metadata=self.metadata, + ) + + +@dataclass(frozen=True, kw_only=True, slots=True) +class ModelInputs: + """Model-facing payloads split by initial and per-step use.""" + + __hash__ = None + + initial: Mapping[str, Any] = field(default_factory=dict) + step: Mapping[str, Any] = field(default_factory=dict) + metadata: Mapping[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + object.__setattr__(self, "initial", freeze_mapping(self.initial)) + object.__setattr__(self, "step", freeze_mapping(self.step)) + object.__setattr__(self, "metadata", freeze_mapping(self.metadata)) + + def with_step(self, step: Mapping[str, Any]) -> "ModelInputs": + """Return a copy with replaced per-step payload.""" + return ModelInputs(initial=self.initial, step=step, metadata=self.metadata) + + +def _missing_required( + fields: tuple[InputField, ...], payload: Mapping[str, Any] +) -> tuple[str, ...]: + return tuple( + input_field.name + for input_field in fields + if input_field.required and input_field.name not in payload + ) diff --git a/flashdreams/flashdreams/runtime/interfaces.py b/flashdreams/flashdreams/runtime/interfaces.py new file mode 100644 index 000000000..9b6a064fd --- /dev/null +++ b/flashdreams/flashdreams/runtime/interfaces.py @@ -0,0 +1,90 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Protocols for model adapters, reusable runtimes, and sessions.""" + +from __future__ import annotations + +from typing import Protocol, runtime_checkable + +from flashdreams.runtime.config import InferenceConfig +from flashdreams.runtime.inputs import ( + ModelInputs, + ModelInputSchema, + UserInputSchema, +) +from flashdreams.runtime.mapping import InputMapping +from flashdreams.runtime.types import StepRequest, StepResult + + +@runtime_checkable +class InferenceSession(Protocol): + """One rollout or stream with isolated model/cache state.""" + + def next_step_request(self) -> StepRequest | None: + """Describe the next step's inputs, or return ``None`` when complete.""" + ... + + def step(self, inputs: ModelInputs) -> StepResult: + """Run one sequential inference step.""" + ... + + def reset(self, inputs: ModelInputs | None = None) -> None: + """Reset this session's rollout state when the backend supports it.""" + ... + + def close(self) -> None: + """Release per-session resources.""" + ... + + +@runtime_checkable +class InferenceRuntime(Protocol): + """Heavyweight reusable runtime created from :class:`InferenceConfig`.""" + + def start_session(self, inputs: ModelInputs) -> InferenceSession: + """Create an isolated session from initial model inputs.""" + ... + + def close(self) -> None: + """Release model/backend resources.""" + ... + + +# Do not mark ModelAdapter runtime-checkable: properties make issubclass() +# unreliable, and isinstance() would only verify attribute presence. +class ModelAdapter(Protocol): + """Model-specific boundary that declares defaults and creates runtimes. + + Adapters declare model-facing input requirements, optional user-input + capabilities, and an optional default mapping between the two. Runtime, + application, or benchmark code may override that mapping while preserving the + same ``UserInputs`` to ``ModelInputs`` boundary. + """ + + @property + def model_id(self) -> str: + """Stable identity for the model adapter or runtime integration.""" + ... + + @property + def model_input_schema(self) -> ModelInputSchema: + """Model-facing initial and per-step input requirements.""" + ... + + @property + def user_input_schema(self) -> UserInputSchema | None: + """User inputs supported by the adapter's default mapping, if any.""" + ... + + def default_input_mapping(self) -> InputMapping | None: + """Return the model-provided default user-to-model mapping, if any.""" + ... + + def validate_config(self, config: InferenceConfig) -> None: + """Fail early for unsupported runtime settings.""" + ... + + def create_runtime(self, config: InferenceConfig) -> InferenceRuntime: + """Initialize and return the heavyweight runtime.""" + ... diff --git a/flashdreams/flashdreams/runtime/mapping.py b/flashdreams/flashdreams/runtime/mapping.py new file mode 100644 index 000000000..756351081 --- /dev/null +++ b/flashdreams/flashdreams/runtime/mapping.py @@ -0,0 +1,85 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Input mapping boundary from user input windows to model inputs.""" + +from __future__ import annotations + +from typing import Protocol, runtime_checkable + +from flashdreams.runtime.inputs import ( + ModelInputs, + ModelInputSchema, + UserInputs, + UserInputSchema, +) +from flashdreams.runtime.types import StepRequest + + +@runtime_checkable +class InputMapping(Protocol): + """Convert user-facing inputs into model-facing inputs. + + A mapping may be supplied by the model adapter as a default or by an + application/runtime override. Step mappings usually receive a timestamped + event window selected by the runner for the current model step or chunk. + """ + + def validate( + self, + *, + user_schema: UserInputSchema | None = None, + model_schema: ModelInputSchema | None = None, + ) -> None: + """Fail early for obvious app, event-source, and model mismatches.""" + ... + + def map_initial_inputs( + self, + *, + user_inputs: UserInputs, + model_inputs: ModelInputs, + ) -> ModelInputs: + """Build initial model inputs before a session starts.""" + ... + + def map_step_inputs( + self, + *, + user_inputs: UserInputs, + model_inputs: ModelInputs, + request: StepRequest, + ) -> ModelInputs: + """Build model inputs for one session step from the current input window.""" + ... + + +class IdentityInputMapping: + """No-op mapper for fixed model-input or simple generation flows.""" + + def validate( + self, + *, + user_schema: UserInputSchema | None = None, + model_schema: ModelInputSchema | None = None, + ) -> None: + del user_schema, model_schema + + def map_initial_inputs( + self, + *, + user_inputs: UserInputs, + model_inputs: ModelInputs, + ) -> ModelInputs: + del user_inputs + return model_inputs + + def map_step_inputs( + self, + *, + user_inputs: UserInputs, + model_inputs: ModelInputs, + request: StepRequest, + ) -> ModelInputs: + del user_inputs, request + return model_inputs diff --git a/flashdreams/flashdreams/runtime/metrics.py b/flashdreams/flashdreams/runtime/metrics.py new file mode 100644 index 000000000..4286204f6 --- /dev/null +++ b/flashdreams/flashdreams/runtime/metrics.py @@ -0,0 +1,124 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Runtime metrics boundary for inference sessions.""" + +from __future__ import annotations + +import math +from collections.abc import Mapping +from dataclasses import dataclass, field +from typing import Any, Protocol, runtime_checkable + +from flashdreams.runtime._utils import freeze_mapping + + +@dataclass(frozen=True, kw_only=True, slots=True) +class RuntimeMetricSample: + """One runtime metric sample. + + Timing samples should use seconds as their canonical unit. + """ + + __hash__ = None + + name: str + value: float | int + unit: str = "s" + step_index: int | None = None + category: str = "runtime" + metadata: Mapping[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + if not self.name.strip(): + raise ValueError("RuntimeMetricSample.name must be non-empty.") + if isinstance(self.value, bool) or not isinstance(self.value, (int, float)): + raise TypeError("RuntimeMetricSample.value must be numeric.") + if not math.isfinite(float(self.value)): + raise ValueError("RuntimeMetricSample.value must be finite.") + if self.step_index is not None and self.step_index < 0: + raise ValueError("RuntimeMetricSample.step_index must be >= 0.") + if not self.unit.strip(): + raise ValueError("RuntimeMetricSample.unit must be non-empty.") + if self.category == "timing" and self.unit != "s": + raise ValueError("Timing metric samples must use unit='s'.") + object.__setattr__(self, "metadata", freeze_mapping(self.metadata)) + + +@runtime_checkable +class MetricsRecorder(Protocol): + """Collector for runtime metrics.""" + + def record(self, sample: RuntimeMetricSample) -> None: + """Record one metric sample.""" + ... + + def record_timing( + self, + name: str, + duration_s: float, + *, + step_index: int | None = None, + metadata: Mapping[str, Any] | None = None, + ) -> None: + """Record one timing sample in seconds.""" + ... + + def close(self) -> None: + """Finalize metric collection.""" + ... + + +@dataclass(slots=True) +class InMemoryMetricsRecorder: + """Simple metrics recorder useful for tests, smoke runs, and adapters.""" + + samples: list[RuntimeMetricSample] = field(default_factory=list) + closed: bool = False + + def record(self, sample: RuntimeMetricSample) -> None: + if self.closed: + raise RuntimeError("Cannot record metrics after close().") + self.samples.append(sample) + + def record_timing( + self, + name: str, + duration_s: float, + *, + step_index: int | None = None, + metadata: Mapping[str, Any] | None = None, + ) -> None: + self.record( + RuntimeMetricSample( + name=name, + value=duration_s, + unit="s", + step_index=step_index, + category="timing", + metadata={} if metadata is None else metadata, + ) + ) + + def close(self) -> None: + self.closed = True + + +class NullMetricsRecorder: + """Metrics recorder that intentionally drops all samples.""" + + def record(self, sample: RuntimeMetricSample) -> None: + del sample + + def record_timing( + self, + name: str, + duration_s: float, + *, + step_index: int | None = None, + metadata: Mapping[str, Any] | None = None, + ) -> None: + del name, duration_s, step_index, metadata + + def close(self) -> None: + return None diff --git a/flashdreams/flashdreams/runtime/output.py b/flashdreams/flashdreams/runtime/output.py new file mode 100644 index 000000000..aac341ee1 --- /dev/null +++ b/flashdreams/flashdreams/runtime/output.py @@ -0,0 +1,78 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Output target boundary for generated inference results.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass, field +from typing import Any, Protocol, runtime_checkable + +from flashdreams.runtime._utils import freeze_mapping +from flashdreams.runtime.types import StepResult + + +@dataclass(frozen=True, kw_only=True, slots=True) +class OutputArtifact: + """Artifact produced by an output target.""" + + __hash__ = None + + kind: str + uri: str + metadata: Mapping[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + if not self.kind.strip(): + raise ValueError("OutputArtifact.kind must be non-empty.") + if not self.uri.strip(): + raise ValueError("OutputArtifact.uri must be non-empty.") + object.__setattr__(self, "metadata", freeze_mapping(self.metadata)) + + +@runtime_checkable +class OutputTarget(Protocol): + """Consumes generated session outputs for presentation or persistence.""" + + def open(self) -> None: + """Prepare the target for a new run.""" + ... + + def write(self, result: StepResult) -> None: + """Consume one generated step result.""" + ... + + def close(self) -> Sequence[OutputArtifact]: + """Finalize and return any produced artifacts.""" + ... + + +@dataclass(slots=True) +class NullOutputTarget: + """Output target for headless runs and throughput measurements.""" + + store_results: bool = False + output_count: int = field(default=0, init=False) + results: list[StepResult] = field(default_factory=list, init=False) + _opened: bool = field(default=False, init=False, repr=False) + + @property + def closed(self) -> bool: + return not self._opened + + def open(self) -> None: + self._opened = True + self.output_count = 0 + self.results.clear() + + def write(self, result: StepResult) -> None: + if not self._opened: + raise RuntimeError("Cannot write to a closed output target.") + self.output_count += 1 + if self.store_results: + self.results.append(result) + + def close(self) -> Sequence[OutputArtifact]: + self._opened = False + return () diff --git a/flashdreams/flashdreams/runtime/types.py b/flashdreams/flashdreams/runtime/types.py new file mode 100644 index 000000000..52bf82166 --- /dev/null +++ b/flashdreams/flashdreams/runtime/types.py @@ -0,0 +1,56 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Plain data carriers shared by runtime protocols and adapters.""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass, field +from typing import Any + +from flashdreams.runtime._utils import freeze_mapping +from flashdreams.runtime.inputs import ModelInputSchema, TimeWindow + + +@dataclass(frozen=True, kw_only=True, slots=True) +class StepRequest: + """Model-session request for the next step's inputs. + + ``user_input_window`` lets a runner drain or slice timestamped user events for + the current step before invoking the selected ``InputMapping``. + """ + + __hash__ = None + + step_index: int + model_input_schema: ModelInputSchema | None = None + user_input_window: TimeWindow | None = None + metadata: Mapping[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + if self.step_index < 0: + raise ValueError("StepRequest.step_index must be >= 0.") + object.__setattr__(self, "metadata", freeze_mapping(self.metadata)) + + +@dataclass(frozen=True, kw_only=True, slots=True) +class StepResult: + """Generated output and metadata for one inference step.""" + + __hash__ = None + + step_index: int + output: Any = None + frame_count: int | None = None + output_window: TimeWindow | None = None + metadata: Mapping[str, Any] = field(default_factory=dict) + metrics: Mapping[str, float | int] = field(default_factory=dict) + + def __post_init__(self) -> None: + if self.step_index < 0: + raise ValueError("StepResult.step_index must be >= 0.") + if self.frame_count is not None and self.frame_count < 0: + raise ValueError("StepResult.frame_count must be >= 0.") + object.__setattr__(self, "metadata", freeze_mapping(self.metadata)) + object.__setattr__(self, "metrics", freeze_mapping(self.metrics)) diff --git a/flashdreams/tests/test_inference_runtime_api.py b/flashdreams/tests/test_inference_runtime_api.py new file mode 100644 index 000000000..1474383a0 --- /dev/null +++ b/flashdreams/tests/test_inference_runtime_api.py @@ -0,0 +1,492 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from dataclasses import fields +from typing import Any, cast + +import pytest + +from flashdreams.runtime import ( + IdentityInputMapping, + InferenceConfig, + InferenceRuntime, + InferenceSession, + InMemoryMetricsRecorder, + InputField, + InputMapping, + MetricsRecorder, + ModelAdapter, + ModelInputs, + ModelInputSchema, + NullOutputTarget, + OutputArtifact, + OutputTarget, + RuntimeMetricSample, + StepRequest, + StepResult, + TimeWindow, + UserInputEvent, + UserInputs, + UserInputSchema, +) + +pytestmark = pytest.mark.ci_cpu + + +def test_inference_config_keeps_runtime_settings_separate() -> None: + denied_app_fields = {"prompt", "output_dir", "browser_settings"} + config = InferenceConfig( + model_id="lingbot-world", + preset_id="fast-taehv", + backend="local", + precision="bf16", + compile=False, + runtime_options={"chunk_size": 3}, + ) + + assert config.model_id == "lingbot-world" + assert config.preset_id == "fast-taehv" + assert config.runtime_options["chunk_size"] == 3 + assert denied_app_fields.isdisjoint(field.name for field in fields(InferenceConfig)) + with pytest.raises(TypeError): + cast(Any, config.runtime_options)["chunk_size"] = 4 + + +def test_inference_config_rejects_empty_model_id() -> None: + with pytest.raises(ValueError, match="model_id"): + InferenceConfig(model_id=" ") + + +@pytest.mark.parametrize( + ("factory", "match"), + [ + (lambda: InputField(name=" "), "InputField.name"), + (lambda: TimeWindow(start_s=1.0, end_s=0.0), "end_s"), + (lambda: TimeWindow(start_s=-1.0, end_s=0.0), "non-negative"), + (lambda: TimeWindow(start_s=0.0, end_s=float("nan")), "finite"), + ( + lambda: UserInputEvent(timestamp_s=-1.0, event_type="keydown"), + "timestamp_s", + ), + (lambda: UserInputEvent(timestamp_s=0.0, event_type=" "), "event_type"), + (lambda: StepRequest(step_index=-1), "step_index"), + (lambda: StepResult(step_index=-1), "step_index"), + (lambda: StepResult(step_index=0, frame_count=-1), "frame_count"), + (lambda: RuntimeMetricSample(name=" ", value=1.0), "name"), + (lambda: RuntimeMetricSample(name="sample", value=float("nan")), "finite"), + (lambda: OutputArtifact(kind=" ", uri="artifact://demo"), "kind"), + (lambda: OutputArtifact(kind="mp4", uri=" "), "uri"), + ], +) +def test_runtime_envelopes_reject_invalid_values(factory: object, match: str) -> None: + with pytest.raises(ValueError, match=match): + cast(Any, factory)() + + +def test_runtime_metric_sample_rejects_bool_values() -> None: + with pytest.raises(TypeError, match="numeric"): + RuntimeMetricSample(name="sample", value=True) + + +def test_model_input_schema_validates_initial_and_step_payloads() -> None: + schema = ModelInputSchema( + initial_fields=( + InputField(name="prompt"), + InputField(name="first_frame"), + ), + step_fields=(InputField(name="camera_poses"),), + ) + inputs = ModelInputs(initial={"prompt": "drive", "first_frame": object()}) + + schema.require_initial(inputs) + assert schema.missing_step(inputs) == ("camera_poses",) + + with pytest.raises(ValueError, match="camera_poses"): + schema.require_step(inputs) + + +def test_user_inputs_filter_timestamped_event_windows() -> None: + inputs = UserInputs( + events=( + UserInputEvent( + timestamp_s=0.1, + event_type="keyboard.keydown", + payload={"key": "w"}, + ), + UserInputEvent( + timestamp_s=0.4, + event_type="keyboard.keyup", + payload={"key": "w"}, + ), + UserInputEvent(timestamp_s=0.8, event_type="reset"), + ) + ) + + windowed = inputs.window(TimeWindow(start_s=0.25, end_s=0.75)) + + assert [event.event_type for event in windowed.events] == ["keyboard.keyup"] + + +def test_user_inputs_require_sorted_events() -> None: + with pytest.raises(ValueError, match="non-decreasing"): + UserInputs( + events=( + UserInputEvent(timestamp_s=1.0, event_type="late"), + UserInputEvent(timestamp_s=0.5, event_type="early"), + ) + ) + + +def test_user_input_schema_declares_event_capabilities() -> None: + schema = UserInputSchema( + event_types=frozenset({"keyboard.keydown", "keyboard.keyup", "reset"}) + ) + + assert schema.supports_event_types(["keyboard.keydown", "reset"]) + assert not schema.supports_event_types(["prompt.update"]) + + +def test_user_input_schema_validates_required_snapshot_fields() -> None: + schema = UserInputSchema( + snapshot_fields=( + InputField(name="pressed_keys"), + InputField(name="prompt", required=False), + ) + ) + inputs = UserInputs(snapshot={"pressed_keys": frozenset({"w"})}) + + schema.require_snapshot(inputs) + assert schema.missing_snapshot(UserInputs()) == ("pressed_keys",) + + with pytest.raises(ValueError, match="pressed_keys"): + schema.require_snapshot(UserInputs()) + + +def test_identity_input_mapping_leaves_model_inputs_unchanged() -> None: + mapping = IdentityInputMapping() + model_inputs = ModelInputs(initial={"prompt": "fixed"}, step={"hdmap": object()}) + request = StepRequest(step_index=0) + + assert ( + mapping.map_initial_inputs( + user_inputs=UserInputs(), + model_inputs=model_inputs, + ) + is model_inputs + ) + assert ( + mapping.map_step_inputs( + user_inputs=UserInputs(), + model_inputs=model_inputs, + request=request, + ) + is model_inputs + ) + + +def test_null_output_target_counts_and_optionally_stores_results() -> None: + target = NullOutputTarget(store_results=True) + result = StepResult(step_index=0, output=b"frame") + + assert target.closed + with pytest.raises(RuntimeError, match="closed output target"): + target.write(result) + + target.open() + assert not target.closed + target.write(result) + artifacts = target.close() + + assert target.closed + assert artifacts == () + assert target.output_count == 1 + assert target.results == [result] + with pytest.raises(RuntimeError, match="closed output target"): + target.write(StepResult(step_index=1)) + + +def test_null_output_target_open_resets_per_run_state() -> None: + target = NullOutputTarget(store_results=True) + + target.open() + target.write(StepResult(step_index=0, output=b"first")) + target.close() + target.open() + + assert target.output_count == 0 + assert target.results == [] + target.write(StepResult(step_index=0, output=b"second")) + assert target.output_count == 1 + assert target.results == [StepResult(step_index=0, output=b"second")] + + +def test_in_memory_metrics_recorder_uses_seconds_for_timing() -> None: + recorder = InMemoryMetricsRecorder() + + recorder.record_timing("model_step", 0.125, step_index=2) + + assert len(recorder.samples) == 1 + sample = recorder.samples[0] + assert sample.name == "model_step" + assert sample.value == pytest.approx(0.125) + assert sample.unit == "s" + assert sample.category == "timing" + assert sample.step_index == 2 + + +def test_timing_metric_samples_must_use_seconds() -> None: + with pytest.raises(ValueError, match="unit='s'"): + RuntimeMetricSample( + name="model_step", + value=12.5, + unit="ms", + category="timing", + ) + + +def test_runtime_api_components_compose_for_sequential_session() -> None: + adapter = _FakeAdapter() + config = InferenceConfig(model_id="fake-model") + user_inputs = UserInputs( + events=( + UserInputEvent( + timestamp_s=0.25, + event_type="keyboard.keydown", + payload={"key": "w"}, + ), + ) + ) + model_inputs = ModelInputs(initial={"prompt": "drive forward"}) + output = NullOutputTarget(store_results=True) + metrics = InMemoryMetricsRecorder() + + adapter.validate_config(config) + mapping = adapter.default_input_mapping() + assert mapping is not None + _drive_two_step_session( + adapter=adapter, + config=config, + mapping=mapping, + user_inputs=user_inputs, + model_inputs=model_inputs, + output=output, + metrics=metrics, + ) + + assert output.output_count == 2 + assert [result.output for result in output.results] == ["chunk-0", "chunk-1"] + assert [result.frame_count for result in output.results] == [3, 3] + assert output.results[0].output_window == TimeWindow(start_s=0.0, end_s=0.5) + assert [sample.step_index for sample in metrics.samples] == [0, 1] + assert metrics.closed + + +def test_reference_loop_validates_mapping_before_runtime_creation() -> None: + mapping = _OrderCheckingMapping() + adapter = _OrderCheckingAdapter(mapping=mapping) + + _drive_two_step_session( + adapter=adapter, + config=InferenceConfig(model_id="fake-model"), + mapping=mapping, + user_inputs=UserInputs(), + model_inputs=ModelInputs(initial={"prompt": "drive forward"}), + output=NullOutputTarget(), + metrics=InMemoryMetricsRecorder(), + ) + + assert mapping.validated + assert adapter.created_runtime_after_validate + + +def test_reference_loop_closes_runtime_when_session_start_fails() -> None: + adapter = _FailingStartAdapter() + output = NullOutputTarget() + metrics = InMemoryMetricsRecorder() + + with pytest.raises(RuntimeError, match="start failed"): + _drive_two_step_session( + adapter=adapter, + config=InferenceConfig(model_id="fake-model"), + mapping=IdentityInputMapping(), + user_inputs=UserInputs(), + model_inputs=ModelInputs(initial={"prompt": "drive forward"}), + output=output, + metrics=metrics, + ) + + assert adapter.runtime is not None + assert adapter.runtime.closed + assert output.closed + assert metrics.closed + + +def _drive_two_step_session( + *, + adapter: ModelAdapter, + config: InferenceConfig, + mapping: InputMapping, + user_inputs: UserInputs, + model_inputs: ModelInputs, + output: OutputTarget, + metrics: MetricsRecorder, +) -> None: + mapping.validate( + user_schema=adapter.user_input_schema, + model_schema=adapter.model_input_schema, + ) + initial_inputs = mapping.map_initial_inputs( + user_inputs=user_inputs, + model_inputs=model_inputs, + ) + runtime = adapter.create_runtime(config) + session: InferenceSession | None = None + output_opened = False + try: + session = runtime.start_session(initial_inputs) + output.open() + output_opened = True + while (request := session.next_step_request()) is not None: + step_inputs = mapping.map_step_inputs( + user_inputs=( + user_inputs.window(request.user_input_window) + if request.user_input_window is not None + else user_inputs + ), + model_inputs=ModelInputs( + initial=initial_inputs.initial, + step={"chunk_index": request.step_index}, + ), + request=request, + ) + result = session.step(step_inputs) + output.write(result) + metrics.record_timing( + "model_step", + float(result.metrics["model_step_s"]), + step_index=result.step_index, + ) + finally: + if output_opened: + output.close() + if session is not None: + session.close() + runtime.close() + metrics.close() + + +class _FakeAdapter: + model_id = "fake-model" + model_input_schema = ModelInputSchema( + initial_fields=(InputField(name="prompt"),), + step_fields=(InputField(name="chunk_index"),), + ) + user_input_schema = UserInputSchema(event_types=frozenset({"keyboard.keydown"})) + + def default_input_mapping(self) -> InputMapping: + return IdentityInputMapping() + + def validate_config(self, config: InferenceConfig) -> None: + if config.model_id != self.model_id: + raise ValueError(f"Unsupported model_id={config.model_id!r}.") + + def create_runtime(self, config: InferenceConfig) -> InferenceRuntime: + self.validate_config(config) + return _FakeRuntime(model_input_schema=self.model_input_schema) + + +class _FakeRuntime: + def __init__(self, *, model_input_schema: ModelInputSchema) -> None: + self._model_input_schema = model_input_schema + self.closed = False + + def start_session(self, inputs: ModelInputs) -> InferenceSession: + self._model_input_schema.require_initial(inputs) + return _FakeSession(model_input_schema=self._model_input_schema) + + def close(self) -> None: + self.closed = True + + +class _FailingRuntime(_FakeRuntime): + def start_session(self, inputs: ModelInputs) -> InferenceSession: + del inputs + raise RuntimeError("start failed") + + +class _FakeSession: + def __init__(self, *, model_input_schema: ModelInputSchema) -> None: + self._model_input_schema = model_input_schema + self.step_index = 0 + self.closed = False + + def next_step_request(self) -> StepRequest | None: + if self.step_index >= 2: + return None + return StepRequest( + step_index=self.step_index, + model_input_schema=self._model_input_schema, + user_input_window=TimeWindow( + start_s=0.5 * self.step_index, + end_s=0.5 * (self.step_index + 1), + ), + ) + + def step(self, inputs: ModelInputs) -> StepResult: + self._model_input_schema.require_step(inputs) + result = StepResult( + step_index=self.step_index, + output=f"chunk-{self.step_index}", + frame_count=3, + output_window=TimeWindow( + start_s=0.5 * self.step_index, + end_s=0.5 * (self.step_index + 1), + ), + metrics={"model_step_s": 0.01}, + ) + self.step_index += 1 + return result + + def reset(self, inputs: ModelInputs | None = None) -> None: + del inputs + self.step_index = 0 + + def close(self) -> None: + self.closed = True + + +class _OrderCheckingMapping(IdentityInputMapping): + def __init__(self) -> None: + self.validated = False + + def validate( + self, + *, + user_schema: UserInputSchema | None = None, + model_schema: ModelInputSchema | None = None, + ) -> None: + super().validate(user_schema=user_schema, model_schema=model_schema) + self.validated = True + + +class _OrderCheckingAdapter(_FakeAdapter): + def __init__(self, *, mapping: _OrderCheckingMapping) -> None: + self._mapping = mapping + self.created_runtime_after_validate = False + + def create_runtime(self, config: InferenceConfig) -> InferenceRuntime: + self.validate_config(config) + self.created_runtime_after_validate = self._mapping.validated + return _FakeRuntime(model_input_schema=self.model_input_schema) + + +class _FailingStartAdapter(_FakeAdapter): + def __init__(self) -> None: + self.runtime: _FailingRuntime | None = None + + def create_runtime(self, config: InferenceConfig) -> InferenceRuntime: + self.validate_config(config) + self.runtime = _FailingRuntime(model_input_schema=self.model_input_schema) + return self.runtime From 0941c8689ca30a35c20b0762d96e7a89cc86fd7d Mon Sep 17 00:00:00 2001 From: aidanfnv Date: Wed, 5 Aug 2026 09:58:16 -0700 Subject: [PATCH 09/30] WIP Implement T2, T3, and part of T4 from API refactor plan (#413) * WIP implementation of T2, T3, partial T4 * Fix issues found by Claude * Rewrite based on discussion, port after merge * doc update * doc updates * Update based on new diagrams * Align closer to diagrams --- docs/inference_runtime_api_design.md | 111 +++- ...inference_runtime_inputs_implementation.md | 287 +++++++++ ...ence_runtime_supported_inputs_inventory.md | 321 ++++++++++ flashdreams/flashdreams/runtime/__init__.py | 59 +- flashdreams/flashdreams/runtime/canonical.py | 387 ++++++++++++ flashdreams/flashdreams/runtime/inputs.py | 359 ++++++++++- flashdreams/flashdreams/runtime/interfaces.py | 30 +- flashdreams/flashdreams/runtime/mapping.py | 356 ++++++++++- flashdreams/flashdreams/runtime/types.py | 4 +- .../tests/test_inference_runtime_api.py | 154 +++-- flashdreams/tests/test_runtime_canonical.py | 590 ++++++++++++++++++ .../tests/test_runtime_input_mapping.py | 573 +++++++++++++++++ 12 files changed, 3076 insertions(+), 155 deletions(-) create mode 100644 docs/inference_runtime_inputs_implementation.md create mode 100644 docs/inference_runtime_supported_inputs_inventory.md create mode 100644 flashdreams/flashdreams/runtime/canonical.py create mode 100644 flashdreams/tests/test_runtime_canonical.py create mode 100644 flashdreams/tests/test_runtime_input_mapping.py diff --git a/docs/inference_runtime_api_design.md b/docs/inference_runtime_api_design.md index 2f0ba19f8..f70fbd890 100644 --- a/docs/inference_runtime_api_design.md +++ b/docs/inference_runtime_api_design.md @@ -19,7 +19,7 @@ integration-specific runner code: - `InferenceConfig`: how the model and inference stack should run; - `UserInputs`: controls or events from an app, replay trace, or benchmark; -- `ModelInputs`: prompts, frames, videos, trajectories, maps, scene data, and +- `InferenceInput`: prompts, frames, videos, trajectories, maps, scene data, and other values required by a specific model; - input mapping: model/application-specific conversion from user-facing inputs into model-facing inputs; @@ -30,6 +30,12 @@ integration-specific runner code: - metrics/profiling: timings, memory, traces, NVTX ranges, and benchmark outputs. +Current T2/T3 implementation notes are in +`docs/inference_runtime_inputs_implementation.md`. + +The supported-model input inventory used to revisit T2/T3 is in +`docs/inference_runtime_supported_inputs_inventory.md`. + The API should standardize the envelope and lifecycle. It should not pretend that all world models have the same inputs, that all models use the same optimization stack, or that a raw checkpoint can fully describe how to run the @@ -62,9 +68,9 @@ Initial scope: | ID | Status | Workstream | Can run in parallel? | Depends on | Done when | | --- | --- | --- | --- | --- | --- | | T0 | Complete | Create experimental branch and contribution rules. | No, this starts the work. | None. | Branch exists, PR target is agreed, and main merge criteria are written down. | -| T1 | Complete | Minimal API envelope and naming. | Partly. | T0. | `InferenceConfig`, `UserInputs`, `ModelInputs`, runtime/session, output target, and mapping boundaries are defined well enough for demos to use. | -| T2 | Planned | Event-based `UserInputs`. | Yes, after T1 direction is agreed. | T1. | User inputs are primarily timestamped events; replay traces and derived snapshots are supported where needed. | -| T3 | Planned | `ModelInputs`, schemas, and mapping boundary. | Yes, after T1 direction is agreed. | T1. | Models can declare required initial/per-step inputs, and mappings can convert user events into model inputs. | +| T1 | Complete | Minimal API envelope and naming. | Partly. | T0. | `InferenceConfig`, `UserInputs`, `InferenceInput`, runtime/session, output target, and mapping boundaries are defined well enough for demos to use. | +| T2 | Complete | Event-based `UserInputs`. | Yes, after T1 direction is agreed. | T1. | User inputs are primarily timestamped events; replay traces and derived snapshots are supported where needed. | +| T3 | Complete | `CanonicalInputs`, `InferenceInput`, schemas, and mapping boundary. | Yes, after T1 direction is agreed. | T1. | Models can declare required global/per-step inputs, and mappings can convert canonical inputs into inference inputs. | | T4 | Planned | `ModelRunner`, `InferenceRuntime`, and `InferenceSession` skeleton. | Partly. | T1. | A minimal standard loop can initialize a runtime, run at least one sequential session, and close cleanly. | | T5 | Planned | Output mode selection. | Yes, after the result/output shape is agreed. | T1, T4. | A run can choose output behavior such as MP4, JPEG/MJPEG stream, WebRTC, benchmark artifact, or headless/null without changing model code. | | T6 | Planned | LingBot migration. | Yes, once T2-T4 have a usable skeleton. | T2, T3, T4. | LingBot runs through the new API path with its event inputs mapped into model inputs. | @@ -97,14 +103,14 @@ Main runtime flow: App / integration / benchmark / transport chooses how the run is driven and where output goes supplies run setup: - InferenceConfig + UserInputs + ModelInputs + output/metrics options + InferenceConfig + UserInputs + InferenceInput + output/metrics options | v ModelRunner / standard loop orchestrates validation, lifecycle, stepping, output, and metrics uses input mapping to: validate that user/app inputs can drive the model - build initial and per-step ModelInputs during the run + build global and per-step InferenceInput during the run | v InferenceRuntime @@ -145,7 +151,7 @@ Create InferenceRuntime from InferenceConfig | v Start InferenceSession A - initial ModelInputs: prompt/frame/scene/etc. + global conditioning: prompt/frame/scene/etc. per-session state: cache, current step, reset state step 0 -> step 1 -> ... -> done outputs -> Output target @@ -154,7 +160,7 @@ Start InferenceSession A | v Start InferenceSession B - new initial ModelInputs or replay scenario + new global conditioning or replay scenario independent cache/state step 0 -> step 1 -> ... -> done outputs -> Output target @@ -305,33 +311,63 @@ User inputs are not model inputs. A keyboard event does not have one universal meaning. One model may map it to pose segments, another to steering commands, and another may ignore it. -## ModelInputs +## CanonicalInputs And InferenceInput + +Inputs move through three layers: + +```text +UserInputs -> CanonicalInputs -> InferenceInput + raw canonicalized encoded +``` + +Raw device events are canonicalized into device-independent modalities before an +application sees them, so adding a keyboard, gamepad, or wheel is a converter +registration rather than an application change. `InferenceInput` is what an +`InferenceSession` actually receives. + +`InferenceInput` describes the data the model or inference pipeline actually +requires. Both it and `CanonicalInputs` distinguish two conditioning slots: -`ModelInputs` describes the data the model or inference pipeline actually -requires. It should distinguish: +- global conditioning: values that condition the whole rollout; +- per-step conditioning: values needed for one generated chunk or frame window. -- initial inputs: values needed to start or reset a rollout; -- per-step inputs: values needed for one generated chunk or frame window. +Examples of global conditioning include prompt, negative prompt, conditioning +frame, input video, scene id, HD map asset, camera calibration, initial camera +pose, seed, or model-specific fields. -Examples of initial model inputs include prompt, negative prompt, first frame, -input video, scene id, HD map asset, camera calibration, initial camera pose, -seed, or model-specific fields. +Global conditioning is normally supplied when a session starts, but a non-empty +global slot on a mid-rollout input is an update request rather than a reset; +resetting rollout state is a separate `InferenceSession.reset()` call. Whether a +given value can be swapped mid-rollout is declared per field by +`InputField.update_policy`. -Examples of per-step model inputs include frame timestamps, pose segments, +Examples of per-step conditioning include frame timestamps, pose segments, camera trajectory chunks, rendered HD map frames, conditioning video windows, control tensors, event markers, or model-specific fields. -Model input payloads should use semantic names, not only modality names. For +Inference input payloads should use semantic names, not only modality names. For example, a first frame and an HD map frame should be distinct inputs even if both are image-like values. -For interactive runs, most `ModelInputs` will be initial values plus per-step -inputs produced by input mapping. For MP4 generation and benchmarking, the API +Model input metadata may also include a lightweight lifecycle label, such as +runtime config, cache initialization, rollout binding, per-step input, or +session update. This should remain query metadata, not model-specific tensor +validation. + +Model input names, payload kinds, lifecycle labels, and schema metadata should +be open-ended. Supported integrations such as SANA-WM, LingBot, Omnidreams, and +future external adapters may need different semantic fields. Adding a new model +should usually mean adding adapter-owned schema declarations and mappings, not +changing a central FlashDreams enum. + +For interactive runs, most `InferenceInput` values will be global conditioning +plus per-step inputs produced by input mapping. For MP4 generation and benchmarking, the API should also support fixed per-step model inputs so runs can be deterministic. ## Schemas -The API should support lightweight `UserInputSchema` and `ModelInputSchema` +The API should support lightweight `UserInputSchema`, `CanonicalInputSchema`, +and `InferenceInputSchema` metadata. These schemas are not meant to be a rich type system or a replacement for @@ -345,8 +381,15 @@ The purpose is to fail early before expensive model initialization, produce clearer errors, make fixed scenarios easier to validate, and avoid ambiguous dict payloads where keys only describe modality. +Schema objects may carry open-ended metadata for query-time hints such as +coordinate frame, units, rough shape summary, accepted file suffixes, schema +URI, model family, or source/transport details. Metadata should help humans and +adapter selection code, but compatibility should still be based on the declared +event capabilities, semantic model fields, payload representation hints, and +lifecycle labels. + For simple CLI text-to-video or image-to-video runs, `UserInputSchema` can be -trivial or omitted because there may be no live controls. `ModelInputSchema` is +trivial or omitted because there may be no live controls. `InferenceInputSchema` is more important because each supported model still needs to declare the model-facing values it expects. @@ -410,19 +453,24 @@ unless the checkpoint already matches a supported generic adapter. ## Input Mapping Input mapping is required whenever `UserInputs` need to become per-step -`ModelInputs`. In the T1 envelope this boundary is represented by a separate +`InferenceInput`. In the T1 envelope this boundary is represented by a separate `InputMapping` protocol. A model adapter may provide the default mapper because it knows how its supported user controls affect model-facing inputs. Applications, benchmarks, replay tools, or hosted runtimes may replace that mapper when they need a different wire surface or aggregation policy. +The selected mapping may be a single mapper or a composed set of mappers, so one +run can combine separate prompt, first-frame, and live-control mappings instead +of routing everything through one object. + There are two separate moments to keep clear: -- before runtime initialization, FlashDreams should select the mapping and check - obvious compatibility between the app event source and the model; +- before runtime initialization, FlashDreams should select the mapping or mapper + set and check obvious compatibility between the app event source and the + model; - during the standard loop, the runtime or runner queues and timestamps user events, then uses the selected mapping to build initial or per-step - `ModelInputs` from the relevant event window, often after the session reports + `InferenceInput` from the relevant event window, often after the session reports what it needs next. This keeps the Reactor-style contract intact: the model-side integration can @@ -621,14 +669,16 @@ registry, standard loop, concrete output modes, or model migrations: `InferenceSession`. - Step data carriers are named `StepRequest` and `StepResult`; a session returns `None` from `next_step_request()` when the rollout is complete. -- User-facing inputs use `UserInputs`; model-facing inputs use `ModelInputs`. +- Raw inputs use `UserInputs`, canonicalized inputs use `CanonicalInputs`, and + model-facing inputs use `InferenceInput`. Both remain lightweight payload envelopes with shallow read-only mappings. -- `UserInputSchema` and `ModelInputSchema` stay intentionally small: they +- `UserInputSchema`, `CanonicalInputSchema`, and `InferenceInputSchema` stay + intentionally small: they declare supported event types and required named fields for early validation, not a full type system. - Input mapping is represented by a separate `InputMapping` protocol. Model adapters may provide a default mapping; runtimes and applications may override - it while preserving the `UserInputs` to `ModelInputs` boundary. Simple + it while preserving the `CanonicalInputs` to `InferenceInput` boundary. Simple fixed-input runs can use `IdentityInputMapping`. - Output handling is represented by `OutputTarget`; `NullOutputTarget` is the initial headless implementation. @@ -660,7 +710,8 @@ Proceed with the proposed split: - `InferenceConfig` for model/runtime execution; - `UserInputs` for app-facing controls and replay traces; -- `ModelInputs` for model-facing initial and per-step inputs; +- `CanonicalInputs` for device-independent application-facing inputs; +- `InferenceInput` for model-facing global and per-step conditioning; - input mapping for model/application-specific conversion; - runtime/session boundaries for lifecycle and stepping; - output targets for display, streaming, files, and benchmarks; diff --git a/docs/inference_runtime_inputs_implementation.md b/docs/inference_runtime_inputs_implementation.md new file mode 100644 index 000000000..8d485768a --- /dev/null +++ b/docs/inference_runtime_inputs_implementation.md @@ -0,0 +1,287 @@ + + +# Inference Runtime Inputs Implementation Notes + +This note documents the input layers of the experimental runtime API: what +exists, how the pieces fit together, what the compatibility query answers, and +what is intentionally still outside this layer. + +Implementation lives in `flashdreams.runtime`: + +- `flashdreams/flashdreams/runtime/inputs.py` — the input types and schemas +- `flashdreams/flashdreams/runtime/canonical.py` — raw device to canonical + modality conversion +- `flashdreams/flashdreams/runtime/mapping.py` — canonical to encoded mapping + and compatibility +- `flashdreams/tests/test_runtime_canonical.py` +- `flashdreams/tests/test_runtime_input_mapping.py` +- `flashdreams/tests/test_inference_runtime_api.py` — the T1 envelope tests, + including a reference loop that exercises all three layers + +The supported-model input inventory that informed this work is in +`docs/inference_runtime_supported_inputs_inventory.md`. + +## The Three Layers + +```text +UserInputs ──InputCanonicalizer──▶ CanonicalInputs ──InputMapping──▶ InferenceInput + raw canonicalized encoded +(device events) (device-independent) (what the session gets) +``` + +| Layer | Type | Owner | Example | +| --- | --- | --- | --- | +| raw | `UserInputs` / `UserInputEvent` | transport, replay loader, benchmark driver | `key_down {"key": "w"}`, wheel axis reading | +| canonicalized | `CanonicalInputs` | device converters registered on `InputCanonicalizer` | `driver_command {throttle, brake, steer, ...}` | +| encoded | `InferenceInput` | the selected `InputMapping` | whatever the model's session consumes | + +Applications and mappings consume `CanonicalInputs`. They never read raw device +events: `InputMapping.map_step_inputs` takes `canonical_inputs`, not +`user_inputs`, so this is enforced by the signature rather than by convention. +Adding a keyboard, gamepad, or wheel is an `InputCanonicalizer.register` call +that touches no application, mapping, or model code. + +This path covers **live user control only**. Global conditioning is +application-owned data and reaches `InferenceInput` directly, without passing +through canonicalization or a device converter. An application that wants a +trigger key to swap the prompt reads that as ordinary canonical control input +and updates its own global conditioning in response. + +## Conditioning Slots + +Both the canonical and encoded layers split into two slots, and the split means +the same thing at each: + +- **global conditioning** — conditions the whole rollout: prompt, conditioning + frame, scene. Normally supplied at session start. +- **per-step conditioning** — needed to generate the next chunk or frame: + steering, HD map frames, camera trajectory. + +`InputPhase` is `Literal["global", "step"]`. The axis names *which slot*, not +*when the value may arrive* — see the next section. + +## Global Conditioning Updates Are Not Resets + +A non-empty global slot on a mid-rollout `InferenceInput` is an **update +request**. The session should apply it when the model supports doing so. +Resetting rollout state is a separate, explicit `InferenceSession.reset()` call. +The motivating case is changing prompt and conditioning frame mid-run to change +the weather in an Omnidreams rollout. + +```python +from flashdreams.runtime import InferenceInput + +steady_state = InferenceInput(step={"steering": 0.25}) +assert not steady_state.requests_global_update + +changed_weather = steady_state.with_global_update({"prompt": "heavy rain"}) +assert changed_weather.requests_global_update +``` + +Because `with_step()` carries the global slot through unchanged, use +`without_global_update()` for the steady-state case; otherwise every step looks +like an update request. + +Whether a value can actually be swapped mid-rollout is declared per field: + +```python +from flashdreams.runtime import SESSION_START_ONLY, InferenceInputSchema, InputField + +schema = InferenceInputSchema( + global_fields=( + InputField(name="prompt", update_policy="step_boundary"), + InputField(name="scene_id", update_policy=SESSION_START_ONLY), + ) +) +schema.unsupported_global_updates( + InferenceInput(global_conditioning={"prompt": "heavy rain", "scene_id": "town_02"}) +) +# ("scene_id",) +``` + +`SESSION_START_ONLY` is the one reserved `update_policy` token. Everything else +in that vocabulary, and all of `lifecycle`, is open and adapter-owned; this layer +only carries it as queryable metadata. + +Steady-state steps must leave the global slot empty; otherwise every step reads +as an update request. Converters emit every window, because live control is +level-triggered: a key held across a step emits no events but still means full +throttle. + +## Raw Inputs + +`UserInputEvent` carries `timestamp_s`, `event_type`, `payload`, `source`, and +`source_event_id`. `UserInputs` holds an ordered batch plus a `snapshot` and +`metadata`, and slices to a half-open `TimeWindow`: + +```python +from flashdreams.runtime import TimeWindow, UserInputEvent, UserInputs + +inputs = UserInputs( + events=( + UserInputEvent(timestamp_s=0.0, event_type="prompt_set", + payload={"prompt": "drive forward"}), + UserInputEvent(timestamp_s=0.5, event_type="key_down", payload={"key": "w"}), + ) +) +step_window = inputs.window(TimeWindow(start_s=0.0, end_s=1.0)) +``` + +`UserInputSchema` describes what a transport, replay trace, or benchmark driver +can provide. `event_types` declares only that an event type exists; +`UserInputCapability` additionally pins the payload fields it carries, so a +converter can require `key_down` events that actually have a `key`. A bare +`event_types` entry still satisfies any consumer needing no specific payload +fields, so schemas written before capabilities existed keep working. + +## Canonical Modalities + +A `CanonicalModality` is a device-independent input: a name and the payload +fields it guarantees. Converters implement `DeviceConverter`, declaring +what raw capabilities they consume and which modality they produce. + +```python +from flashdreams.runtime import ( + DRIVER_COMMAND, InputCanonicalizer, KeyboardToDriverCommand, TimeWindow, +) + +canonicalizer = InputCanonicalizer([KeyboardToDriverCommand()]) +canonicalizer.register(WheelToDriverCommand()) # a wheel is one call + +canonical = canonicalizer.canonicalize( + user_inputs, window=TimeWindow(start_s=0.0, end_s=1.0), source_schema=browser +) +canonical.values["driver_command"]["throttle"] +``` + +`DRIVER_COMMAND` is the one shipped modality. `KeyboardToDriverCommand` reuses +`KeyboardState`/`normalize_key` from `flashdreams.serving.realtime.input` and +mirrors the semantics the Omnidreams interactive-drive keyboard backend already +has. Its key bindings are data (`DEFAULT_DRIVING_BINDINGS`), and the set of +tracked keys is derived from them, so a rebound layout cannot leave an action +unreachable. + +`ScriptedModality` is the mock/replay converter. It consumes no raw +capabilities, so a benchmark or test can author a scenario at the canonical +level without knowing any device vocabulary: + +```python +canonicalizer = InputCanonicalizer([ + ScriptedModality(modality=DRIVER_COMMAND, timeline=[(0.0, full_throttle)]), +]) +canonicalizer.canonicalize( + UserInputs(), window=step_window, source_schema=UserInputSchema() +) +``` + +Application code is identical between a real run and a scripted one. + +Converters are stateful, so feed windows in session order and call +`InputCanonicalizer.reset()` at a rollout boundary. Replaying the same window +sequence reproduces the same `CanonicalInputs`. + +When several devices produce the same modality, the highest-priority one that +returned a value wins; `CanonicalInputs.metadata["canonical_sources"]` records +which device supplied each. Every feedable converter still sees each window, so +a preempted device's state stays current and unplugging the higher-priority +device does not resume from stale state. + +## Mapping And Compatibility + +`InputMapping` is the canonical-to-encoded boundary. `InputMappingSchema` is its +declarative surface: `consumes` names canonical modalities; `produces_global` +and `produces_step` name the `InferenceInput` fields it can build. + +`InputMapping.validate()` raises, which fails a run late and cannot say *which* +optional model input a source would enable or *which* missing modality makes a +required one unreachable. `check_mapping_compatibility` answers those before +expensive runtime initialization: + +```python +from flashdreams.runtime import check_mapping_set_compatibility + +compatibility = check_mapping_set_compatibility( + canonical_schema=canonicalizer.canonical_schema(browser), + inference_input_schema=adapter.inference_input_schema, + mapping_schemas=(prompt_mapping, frame_mapping, steering_mapping), +) +if not compatibility.can_drive: + compatibility.raise_if_incompatible() +``` + +`MappingCompatibility` reports `missing_modalities`, +`missing_required_model_fields`, `satisfied_required_model_fields`, +`available_optional_model_fields`, and `unavailable_mapping_schemas`. + +Compatibility is evaluated per mapping rather than over a flattened bag, so each +mapping keeps its own consumes/produces link. A mapping the source cannot feed +is dropped and reported, costing only the inputs it produced. So a dropped +mapping that fed only optional fields degrades the run instead of vetoing it, +and those fields are correctly absent from `available_optional_model_fields`; a +dropped mapping that was the only producer of a required field still blocks. + +Because a mapping consumes modalities rather than raw events, one mapping +written against `driver_command` works for a keyboard, a wheel, or any device +registered later, with no change to the mapping or the model schema. + +`undeclared_inference_inputs()` reports payload keys a mapping produced but did +not declare, which keeps hand-written schemas honest as the code drifts. + +## What This Does Not Validate + +The schemas intentionally avoid becoming a rich type system. These remain the +responsibility of the model adapter, runtime, session, or mapping: + +- tensor shape and dtype, image decode details; +- camera coordinate systems, pose and timestamp units; +- prompt-embedding swap mechanics; +- whether a model can actually apply a declared update policy at runtime; +- deep validation of scene, HD map, or actor-state data. + +The layer answers "can this source plausibly drive this model through this +mapping?" It does not replace model-owned validation. + +## Open Questions + +Tracked against the runtime API discussion, not yet settled: + +- **Alternative valid input combinations.** `InferenceInputSchema` has one flat + required set, so "accepts `{prompt}` OR `{prompt, conditioning_frame}`" cannot + be expressed. `MappingCompatibility.missing_required_model_fields` assumes a + single required set too. +- **`step()` returning a future**, for models with a dependency on their own + output. `InferenceSession.step()` is currently synchronous. +- **`Input System` ownership.** The diagrams show it pulling events, so the + Application owns an input system. `InputCanonicalizer` is currently a pure + function over a supplied window and owns no source. Whether it needs to grow + one depends on the loop-ownership decision. Mock input and key binding are + handled (`ScriptedModality`, `DEFAULT_DRIVING_BINDINGS`). + +## Owned Elsewhere + +Named here only so the boundary is explicit; these are not gaps in the input +layer: + +- **`FrameStream`**, which the architecture diagrams place between + `InferenceSession` and `Output Target`. The code writes `StepResult` straight + to `OutputTarget.write()`. Output shape is T5. +- **Declared output modalities**, so an output target or quality-eval can state + what it requires and be matched the way inputs now are. T5/T8. +- **`Application`**, the class that has-a input system, input map, global + conditioning, session, and output target. T4. +- **Loop ownership** — whether the application or the runtime/session drives the + main event loop, and whether inputs are queued and batched. + +## Validation + +```bash +.venv/bin/pytest flashdreams/tests/test_runtime_canonical.py \ + flashdreams/tests/test_runtime_input_mapping.py \ + flashdreams/tests/test_inference_runtime_api.py -q +.venv/bin/ty check flashdreams/flashdreams/runtime +``` + +At the time of writing these pass: 87 tests, and `ty` is clean. diff --git a/docs/inference_runtime_supported_inputs_inventory.md b/docs/inference_runtime_supported_inputs_inventory.md new file mode 100644 index 000000000..ebe9d853a --- /dev/null +++ b/docs/inference_runtime_supported_inputs_inventory.md @@ -0,0 +1,321 @@ + + +# Supported Model Input Inventory + +This note inventories the inputs used by the currently supported FlashDreams +runners and interactive runtimes, plus the SANA-WM input surface on `main`, then +records the T2/T3 API implications. It is intentionally about input contracts, +not tensor shape validation or model quality. + +## Inventory + +WAN 2.1 T2V, Self-Forcing WAN 2.1 T2V, Causal-Forcing T2V, +FastVideo Causal WAN 2.2 T2V, and Cosmos Predict2 T2V: + +- Source/app inputs: prompt text or prompt text file, pixel height/width, and + fps or block count depending on runner. +- Model-facing initial inputs: prompt text plus latent/output height and width + derived from run config. +- Model-facing step/update inputs: no live controls; AR loop steps with fixed + session state. + +WAN 2.1 I2V, Causal-Forcing I2V, and Cosmos Predict2 I2V: + +- Source/app inputs: prompt text or prompt file, first-frame image path or URL, + and pixel height/width. +- Model-facing initial inputs: prompt text and decoded first-frame tensor. +- Model-facing step/update inputs: no live controls. + +FlashVSR: + +- Source/app inputs: input video path or URL, chunk size, crop region, sparse + ratio, and optional output FPS. +- Model-facing initial inputs: no explicit prompt at runner time; the prompt + tensor is configured in the pipeline. Input video dimensions affect + per-video runtime/pipeline setup. +- Model-facing step/update inputs: video chunks passed to + `pipeline.generate(input=clip)`. + +LingBot CLI: + +- Source/app inputs: prompt or prompt path, first-frame image path, pose path, + intrinsics path, total blocks, dimensions, and fps. +- Model-facing initial inputs: prompt text and first-frame tensor. +- Model-facing step/update inputs: `CamCtrlInput` with intrinsics, camera poses, + and world scale. + +LingBot WebRTC: + +- Source/app inputs: session prompt, uploaded/remote/default first-frame image, + keyboard events, reset requests, text-event catalog, and trigger events. +- Model-facing initial inputs: prompt text, first-frame tensor, base text + embeddings, precomputed text-event embeddings, base intrinsics, and world + scale. +- Model-facing step/update inputs: keyboard event windows become pose segments + and camera trajectories. Text-event triggers can replace rollout text + embeddings when the model supports it. + +HY-WorldPlay WAN I2V: + +- Source/app inputs: prompt or prompt path, first-frame image path or example + image, pose string or pose JSON, memory-selection settings, dimensions, fps, + and seed. +- Model-facing initial inputs: prompt text and first-frame tensor for cache + initialization. +- Model-facing step/update inputs: pose data is bound for the rollout as action + labels, view matrices, intrinsics, and memory-selection state before AR steps. + +Omnidreams CLI: + +- Source/app inputs: shared prompt or per-camera prompts, HDMap video paths, + first-frame image/video paths, camera names, example-data UUID, and optional + embedding save/load paths. +- Model-facing initial inputs: prompt list, first-frame tensor, view names; or + precomputed text/image/negative-text embeddings. +- Model-facing step/update inputs: HDMap video chunks passed per AR step. + +Omnidreams WebRTC: + +- Source/app inputs: scene directory or scene UUID, scene variant, camera name, + prompt/first-frame assets resolved from the scene, keyboard events, reset + requests, and optional postprocess preset. +- Model-facing initial inputs: scene data, renderer, first-frame tensor, prompt, + camera calibration/extrinsics, initial ego pose, and initial timestamp. +- Model-facing step/update inputs: keyboard event windows become ego poses, + camera poses per view, and frame timestamps. The wrapper renders HDMap + conditioning internally for each step. + +Omnidreams interactive drive: + +- Source/app inputs: scene bundle, keyboard events or wheel/controller samples, + view-mode/reset/scene-exit controls, and vehicle/chunk config. +- Model-facing initial inputs: scene bundle, selected camera, prompt, initial + RGB frame, initial rig pose, and initial timestamp. +- Model-facing step/update inputs: `DriverCommand` samples become trajectory + chunks, rendered frames, and world-model conditioning. + +Template recipe: + +- Source/app inputs: synthetic runner config: batch size, height, width, context + tokens, AR steps, and seed. +- Model-facing initial inputs: synthetic transformer context, optional negative + context, height, and width. +- Model-facing step/update inputs: optional synthetic control tensor. + +WAN 2.2 TI2V pipeline config: + +- Source/app inputs: downstream runners use this rather than a standalone runner + in this tree. +- Model-facing initial inputs: prompt text and first-frame image for TI2V-style + cache initialization. +- Model-facing step/update inputs: downstream runners decide controls; + HY-WorldPlay currently binds action/camera state around it. + +SANA-WM bidirectional and streaming on `main`: + +- Source/app inputs: first-frame image path, prompt or prompt path, optional + negative prompt, camera trajectory path or action DSL, optional intrinsics + path or derived intrinsics, frame count, fps, Stage-1 sampling knobs, seed, + precision/refiner options, and streaming chunk/block settings. +- Model-facing initial inputs: decoder context such as prompt, fps, + `save_stage1`, refiner seed, sink size, and streaming refiner window/block + parameters. +- Model-facing step/update inputs: bidirectional passes one + `SanaWMI2VConditioningRequest` into the single generation step. Streaming + passes one `SanaWMStreamingI2VConditioningRequest` repeatedly; the + conditioning encoder caches rollout-wide prompt, first-frame, camera, latent + shape, and chunk-boundary state, then slices per AR chunk. +- Model-facing semantic fields include prompt, negative prompt, first frame, + camera-to-world trajectory, intrinsics vec4 sequence, frame count, fps, + sampling parameters, seed, and streaming chunking parameters. + +## API Implications + +The inventory changes the T2/T3 shape in four concrete ways. + +First, a selected mapping is often a composition. A LingBot-like run needs prompt +mapping, first-frame mapping, and keyboard-to-camera mapping. Omnidreams may add +scene selection, camera selection, and HDMap mapping. The implementation should +support checking a set of mapping schemas as one compatibility surface, while +still allowing a single mapping object when that is simpler. + +Second, `InferenceInputSchema` needs a lightweight lifecycle tag in addition to the +`initial` versus `step` phase. The phase answers when the value is needed at the +standard-loop level. The lifecycle tag distinguishes where the model adapter +uses it, such as: + +- `runtime_config`: values that affect setup before model/runtime construction, + such as FlashVSR input-video dimensions; +- `cache_init`: values passed when initializing or resetting a rollout cache, + such as prompts, first frames, view names, and precomputed embeddings; +- `rollout_binding`: values bound after cache initialization but before AR + steps, such as HY-WorldPlay action labels, camera tensors, and memory state; +- `step_input`: values consumed for one generated chunk, such as HDMap frames, + camera trajectories, driver commands, video chunks, and timestamps; +- `session_update`: values that can update an active session when supported, + such as LingBot text-event embedding swaps. + +The lifecycle tag is metadata, not a new deep type system. If both a model field +and mapping output specify lifecycle, compatibility should require them to agree. +If either side omits it, matching stays permissive for simple schemas. + +Third, `semantic_type` should be treated as a representation hint rather than a +universal semantic type. For example, `prompt` may arrive as inline text or a +path but become prompt text or text embeddings; the global conditioning frame +may arrive as a path, URL, bytes, or decoded tensor; camera motion may arrive as keys, pose JSON, +Numpy arrays, or integrated tensors. The semantic input name is still the main +contract. + +Fourth, schema objects need open-ended metadata for future adapters. This lets a +SANA-WM-like adapter advertise that `camera_trajectory_c2w` uses an +`[F,4,4]` OpenCV camera-to-world sequence, or lets another model advertise a +schema URI, units, coordinate frame, accepted file suffixes, cardinality hints, +or update notes. Metadata should remain query information and should not become +the compatibility type system. + +Fifth, `UserInputSchema` describes raw source capabilities, `CanonicalModality` +describes what an application consumes, and mapping schemas describe derived +model-facing semantics. A browser may provide `key_down`, `key_up`, +`prompt_set`, and `initial_frame_set` events. Those become canonical modalities +such as `driver_command` or `conditioning_prompt`; whether they can then drive +`steering`, `camera_trajectory`, or text embedding updates depends on the +selected mapping and model schema. + +## Implemented T2/T3 Shape + +The implementation that came out of this inventory is: + +1. Keep `UserInputEvent` and `UserInputs` as the raw event API, sliced by a + half-open `TimeWindow`. Static startup values remain timestamp-zero events. +2. Keep `UserInputSchema` lightweight and source-facing. `event_types` declares + that an event type exists; `UserInputCapability` additionally pins the + payload fields it carries. +3. Add a canonical layer between raw and encoded. `CanonicalModality` names a + device-independent input and its conditioning phase; `InputCanonicalizer` + registers per-device converters and produces `CanonicalInputs`. Applications + and mappings consume canonical inputs and never read raw device events. +4. Split `InferenceInput` (formerly `ModelInputs`) into `global_conditioning` + and `step`. A non-empty global slot mid-rollout is an update request, not a + reset; `InputField.update_policy` declares whether the model can apply it. +5. Extend `InputField` with `update_policy`, `lifecycle`, and `metadata` so + models can distinguish runtime config, cache initialization, rollout binding, + per-step inputs, and supported active-session updates. +6. Keep `InputMappingSchema` as the canonical-to-encoded boundary, with + mapping-set compatibility helpers for composed mappings. +7. Keep input names, semantic types, lifecycle labels, and metadata open-ended. + Adding a new model should usually mean adding adapter-owned schema + declarations and mappings, not changing the core input dataclasses. +8. Leave deep validation to model adapters, sessions, and mappings. The schema + layer catches obvious source/mapping/model mismatches before expensive + runtime initialization; it does not validate every tensor and coordinate + convention. + +See `docs/inference_runtime_inputs_implementation.md` for the resulting API. + +## Extensibility Contract + +The inventory above is not a vocabulary freeze. The core API does not contain a +closed enum of allowed input names. New adapters can introduce semantic field +names that match the model boundary they own. + +Use these conventions when adding future model schemas: + +- Prefer semantic names over modality names, such as `camera_trajectory_c2w` + instead of `array`, or `hdmap_frames` instead of `image`. +- Use `semantic_type` for a coarse representation hint, such as `path`, + `decoded_tensor`, `c2w_sequence`, `intrinsics_vec4_sequence`, or `embedding`. +- Use `lifecycle` to say where the adapter consumes the value, such as + `runtime_config`, `cache_init`, `rollout_binding`, `step_input`, or + `session_update`. +- Use `update_policy` to say when a value may change. `SESSION_START_ONLY` is + the one reserved token, meaning the value cannot be swapped mid-rollout. +- Use `metadata` for query hints: units, coordinate frame, shape summary, + accepted suffixes, schema URI, model family, value ranges, or cardinality. +- Keep deep validation in the adapter/mapping. The lightweight schemas answer + whether the selected source and mapping can plausibly drive the model before + expensive initialization. + +## Representative Schema Sketches + +These are not migration work for T4+, but they show that the current primitives +can describe the supported input surfaces. All use +`flashdreams.runtime.InferenceInputSchema` and `InputField`. + +```python +lingbot_model = InferenceInputSchema( + description="lingbot-world", + global_fields=( + InputField(name="prompt", lifecycle="cache_init"), + InputField(name="global_conditioning_frame", lifecycle="cache_init"), + ), + step_fields=( + InputField(name="camera_trajectory", lifecycle="step_input"), + InputField( + name="text_embeddings", + required=False, + update_policy="step_boundary", + lifecycle="session_update", + ), + ), +) +``` + +```python +omnidreams_model = InferenceInputSchema( + description="omnidreams", + global_fields=( + InputField(name="prompts", lifecycle="cache_init"), + InputField(name="global_conditioning_frames", lifecycle="cache_init"), + InputField(name="view_names", lifecycle="cache_init"), + InputField(name="text_embeddings", required=False, lifecycle="cache_init"), + InputField(name="image_embeddings", required=False, lifecycle="cache_init"), + ), + step_fields=(InputField(name="hdmap_frames", lifecycle="step_input"),), +) +``` + +```python +hy_worldplay_model = InferenceInputSchema( + description="hy-worldplay", + global_fields=( + InputField(name="prompt", lifecycle="cache_init"), + InputField(name="global_conditioning_frame", lifecycle="cache_init"), + InputField(name="action_labels", lifecycle="rollout_binding"), + InputField(name="camera_viewmats", lifecycle="rollout_binding"), + InputField(name="camera_intrinsics", lifecycle="rollout_binding"), + InputField(name="memory_config", lifecycle="rollout_binding"), + ), +) +``` + +```python +sana_wm_model = InferenceInputSchema( + description="sana-wm", + global_fields=( + InputField(name="prompt", lifecycle="cache_init"), + InputField(name="negative_prompt", required=False, lifecycle="cache_init"), + InputField(name="global_conditioning_frame", lifecycle="cache_init"), + InputField( + name="camera_trajectory_c2w", + semantic_type="c2w_sequence", + lifecycle="rollout_binding", + metadata={"shape": "[F,4,4]", "coordinates": "opencv_c2w"}, + ), + InputField( + name="camera_intrinsics_vec4", + required=False, + semantic_type="intrinsics_vec4_sequence", + lifecycle="rollout_binding", + metadata={"shape": "[F,4]"}, + ), + ), +) +``` + +SANA-WM's `stage1_sampling` and `streaming_chunking` are deliberately absent +above. They describe how to run the model rather than what conditions it, so +they belong in `InferenceConfig`, not in an input schema. Flagged here because +the runner currently threads them alongside the conditioning inputs. diff --git a/flashdreams/flashdreams/runtime/__init__.py b/flashdreams/flashdreams/runtime/__init__.py index 03e6202b0..ab303c745 100644 --- a/flashdreams/flashdreams/runtime/__init__.py +++ b/flashdreams/flashdreams/runtime/__init__.py @@ -7,22 +7,49 @@ intentionally additive while integrations migrate onto it. """ +from flashdreams.runtime.canonical import ( + DEFAULT_DRIVING_BINDINGS, + DRIVER_COMMAND, + DeviceConverter, + DeviceConverterSchema, + InputCanonicalizer, + KeyboardToDriverCommand, + ScriptedModality, +) from flashdreams.runtime.config import ExecutionBackend, InferenceConfig, Precision from flashdreams.runtime.inputs import ( + INPUT_PHASES, + SESSION_START_ONLY, + CanonicalInputs, + CanonicalInputSchema, + CanonicalModality, + InferenceInput, + InferenceInputSchema, InputField, - ModelInputs, - ModelInputSchema, + InputPhase, TimeWindow, + UserInputCapability, UserInputEvent, UserInputs, UserInputSchema, + validate_phase, ) from flashdreams.runtime.interfaces import ( InferenceRuntime, InferenceSession, ModelAdapter, ) -from flashdreams.runtime.mapping import IdentityInputMapping, InputMapping +from flashdreams.runtime.mapping import ( + DeclaresMappingSchema, + IdentityInputMapping, + InputMapping, + InputMappingSchema, + MappingCompatibility, + check_mapping_compatibility, + check_mapping_set_compatibility, + combine_mapping_schemas, + undeclared_inference_inputs, +) from flashdreams.runtime.metrics import ( InMemoryMetricsRecorder, MetricsRecorder, @@ -33,28 +60,50 @@ from flashdreams.runtime.types import StepRequest, StepResult __all__ = [ + "CanonicalInputs", + "CanonicalInputSchema", + "CanonicalModality", + "check_mapping_compatibility", + "check_mapping_set_compatibility", + "combine_mapping_schemas", + "DeclaresMappingSchema", + "DEFAULT_DRIVING_BINDINGS", + "DeviceConverter", + "DeviceConverterSchema", + "DRIVER_COMMAND", "ExecutionBackend", "IdentityInputMapping", "InferenceConfig", + "InferenceInput", + "InferenceInputSchema", "InferenceRuntime", "InferenceSession", "InMemoryMetricsRecorder", + "INPUT_PHASES", + "InputCanonicalizer", "InputField", "InputMapping", + "InputMappingSchema", + "InputPhase", + "KeyboardToDriverCommand", + "MappingCompatibility", "MetricsRecorder", "ModelAdapter", - "ModelInputs", - "ModelInputSchema", "NullMetricsRecorder", "NullOutputTarget", "OutputArtifact", "OutputTarget", "Precision", "RuntimeMetricSample", + "ScriptedModality", + "SESSION_START_ONLY", "StepRequest", "StepResult", "TimeWindow", + "undeclared_inference_inputs", + "UserInputCapability", "UserInputEvent", "UserInputs", "UserInputSchema", + "validate_phase", ] diff --git a/flashdreams/flashdreams/runtime/canonical.py b/flashdreams/flashdreams/runtime/canonical.py new file mode 100644 index 000000000..55f333ce7 --- /dev/null +++ b/flashdreams/flashdreams/runtime/canonical.py @@ -0,0 +1,387 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Raw device input to canonical modality conversion. + +This is the ``raw input -> canonicalized input`` leg. Applications consume +:class:`~flashdreams.runtime.inputs.CanonicalInputs`; they never read raw device +events. Adding a keyboard, gamepad, or force-feedback wheel is therefore a +:meth:`InputCanonicalizer.register` call that touches no application, mapping, +or model code. + +Converters are stateful, because HID input is edge-triggered while per-step +conditioning is level-triggered: a key held across a step emits no events yet +still means full throttle. Feed windows in session order and call +:meth:`InputCanonicalizer.reset` at a rollout boundary; replaying the same +window sequence then reproduces the same canonical inputs. + +This layer covers live user control only. Global conditioning such as a prompt +or conditioning frame is application-owned and reaches ``InferenceInput`` +directly, without passing through canonicalization. +""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping, Sequence +from dataclasses import dataclass, field +from types import MappingProxyType +from typing import Any, Protocol, runtime_checkable + +from flashdreams.runtime._utils import freeze_mapping +from flashdreams.runtime.inputs import ( + CanonicalInputs, + CanonicalInputSchema, + CanonicalModality, + TimeWindow, + UserInputCapability, + UserInputs, + UserInputSchema, +) +from flashdreams.serving.realtime.input import KeyboardState, normalize_key + +DriverBindings = Mapping[str, frozenset[str]] + +DEFAULT_DRIVING_BINDINGS: DriverBindings = MappingProxyType( + { + "throttle": frozenset({"w", "up"}), + "brake": frozenset({"s", "down"}), + "steer_left": frozenset({"a", "left"}), + "steer_right": frozenset({"d", "right"}), + "stop": frozenset({"space"}), + "reverse": frozenset(), + } +) +"""Default key bindings for :class:`KeyboardToDriverCommand`. + +Bindings are data so a layout can be rebound without editing the converter, and +so the set of tracked keys is derived from them rather than declared twice. +""" + +_DRIVER_ACTIONS = frozenset(DEFAULT_DRIVING_BINDINGS) + + +@dataclass(frozen=True, kw_only=True, slots=True) +class DeviceConverterSchema: + """Metadata for one device-to-canonical-modality converter.""" + + name: str + produces: CanonicalModality + consumes: tuple[UserInputCapability, ...] = () + device_kind: str | None = None + priority: int = 0 + metadata: Mapping[str, Any] = field( + default_factory=dict, + compare=False, + hash=False, + ) + + def __post_init__(self) -> None: + if not self.name.strip(): + raise ValueError("DeviceConverterSchema.name must be non-empty.") + if not isinstance(self.produces, CanonicalModality): + raise TypeError("produces must be a CanonicalModality object.") + object.__setattr__(self, "metadata", freeze_mapping(self.metadata)) + + +@runtime_checkable +class DeviceConverter(Protocol): + """Contract for turning one device's raw events into a canonical modality.""" + + @property + def schema(self) -> DeviceConverterSchema: + """Return converter metadata used for source selection.""" + ... + + def reset(self) -> None: + """Drop accumulated device state at a session or rollout boundary.""" + ... + + def convert( + self, + user_inputs: UserInputs, + window: TimeWindow, + ) -> Mapping[str, Any] | None: + """Return the modality value for ``window``, or ``None`` if inactive. + + ``user_inputs`` is already filtered to ``window``. Returning ``None`` + lets a present-but-idle device yield to a lower-priority one. + """ + ... + + +DRIVER_COMMAND = CanonicalModality( + name="driver_command", + payload_fields=frozenset({"throttle", "brake", "steer", "stop", "reverse"}), + description=( + "Normalized driving intent. throttle/brake are in [0, 1], steer is in " + "[-1, 1] with positive meaning left." + ), +) + + +class KeyboardToDriverCommand: + """Convert keyboard edges into :data:`DRIVER_COMMAND` level state. + + Mirrors the mapping the Omnidreams interactive-drive keyboard backend + already uses, so a keyboard reaches a model through the shared layer with + the same semantics it has today. + """ + + def __init__( + self, + *, + name: str = "keyboard-to-driver-command", + bindings: DriverBindings = DEFAULT_DRIVING_BINDINGS, + priority: int = 0, + ) -> None: + unknown = sorted(set(bindings) - _DRIVER_ACTIONS) + if unknown: + raise ValueError( + f"Unknown driver actions in bindings: {unknown}. " + f"Supported actions: {sorted(_DRIVER_ACTIONS)}." + ) + self._bindings = { + action: frozenset(normalize_key(key) for key in bindings.get(action, ())) + for action in _DRIVER_ACTIONS + } + # Tracked keys are derived, so they cannot drift from the bindings and + # silently make an action unreachable. + self._supported_keys = frozenset( + key for keys in self._bindings.values() for key in keys + ) + self._state = KeyboardState(supported_keys=self._supported_keys) + self._schema = DeviceConverterSchema( + name=name, + produces=DRIVER_COMMAND, + device_kind="keyboard", + priority=priority, + consumes=( + UserInputCapability( + event_type="key_down", + payload_fields=frozenset({"key"}), + ), + UserInputCapability( + event_type="key_up", + payload_fields=frozenset({"key"}), + ), + ), + ) + + @property + def schema(self) -> DeviceConverterSchema: + return self._schema + + def reset(self) -> None: + self._state = KeyboardState(supported_keys=self._supported_keys) + + def convert( + self, + user_inputs: UserInputs, + window: TimeWindow, + ) -> Mapping[str, Any] | None: + del window + for event in user_inputs.events: + if event.event_type not in {"key_down", "key_up"}: + continue + key = event.payload.get("key") + if not isinstance(key, str): + continue + self._state.apply_event( + event="keydown" if event.event_type == "key_down" else "keyup", + key=key, + ) + + pressed = {normalize_key(key) for key in self._state.snapshot()} + + def held(action: str) -> bool: + return bool(self._bindings[action] & pressed) + + steer = 0.0 + if held("steer_left"): + steer += 1.0 + if held("steer_right"): + steer -= 1.0 + return DRIVER_COMMAND.value( + { + "throttle": 1.0 if held("throttle") else 0.0, + "brake": 1.0 if held("brake") else 0.0, + "steer": steer, + "stop": held("stop"), + "reverse": held("reverse"), + } + ) + + +class ScriptedModality: + """Emit pre-authored canonical values, for benchmarks, replay, and tests. + + Mocking input should not require knowing the raw device vocabulary. This + converter consumes no raw capabilities, so it is feedable by any source + -- including an empty :class:`UserInputSchema` -- and application code is + identical between a real run and a scripted one. + + ``timeline`` is ``(start_s, value)`` pairs. Values are level-triggered and + held until the next entry begins, matching how live converters behave. An + entry applies to a window once it has begun by the window's end, and + ``None`` is returned for windows before the first entry. + """ + + def __init__( + self, + *, + modality: CanonicalModality, + timeline: Sequence[tuple[float, Mapping[str, Any]]], + name: str | None = None, + device_kind: str | None = "scripted", + priority: int = 0, + ) -> None: + entries = tuple(sorted(timeline, key=lambda entry: entry[0])) + for start_s, value in entries: + if start_s < 0: + raise ValueError("timeline start_s must be >= 0.") + modality.value(value) + self._entries = tuple( + (start_s, modality.value(value)) for start_s, value in entries + ) + self._modality = modality + self._schema = DeviceConverterSchema( + name=name or f"scripted-{modality.name}", + produces=modality, + device_kind=device_kind, + priority=priority, + ) + + @property + def schema(self) -> DeviceConverterSchema: + return self._schema + + def reset(self) -> None: + # The timeline is a pure function of the window, so replay is + # deterministic without any state to clear. + return None + + def convert( + self, + user_inputs: UserInputs, + window: TimeWindow, + ) -> Mapping[str, Any] | None: + del user_inputs + current: Mapping[str, Any] | None = None + for start_s, value in self._entries: + if start_s < window.end_s: + current = value + else: + break + return current + + +class InputCanonicalizer: + """Registry of device converters plus the raw-to-canonical rewrite. + + Registration is the whole extension point: a new device is a converter + registered against an existing modality, and a new modality is a converter + registered with a new :class:`CanonicalModality`. + """ + + def __init__(self, converters: Iterable[DeviceConverter] = ()) -> None: + self._converters: list[DeviceConverter] = [] + for converter in converters: + self.register(converter) + + def register(self, converter: DeviceConverter) -> None: + """Register one device converter.""" + if not isinstance(converter, DeviceConverter): + raise TypeError("converter must implement the DeviceConverter protocol.") + name = converter.schema.name + if any(existing.schema.name == name for existing in self._converters): + raise ValueError( + f"A device converter named {name!r} is already registered." + ) + self._converters.append(converter) + + @property + def converters(self) -> tuple[DeviceConverter, ...]: + """Return every registered converter.""" + return tuple(self._converters) + + def reset(self) -> None: + """Reset every registered converter's device state.""" + for converter in self._converters: + converter.reset() + + def converters_for( + self, + source_schema: UserInputSchema, + ) -> tuple[DeviceConverter, ...]: + """Return converters this source can feed, highest priority first.""" + feedable = [ + converter + for converter in self._converters + if all( + source_schema.supports(capability) + for capability in converter.schema.consumes + ) + ] + # Sort is stable, so equal-priority converters keep registration order. + return tuple(sorted(feedable, key=lambda each: -each.schema.priority)) + + def unavailable_converters( + self, + source_schema: UserInputSchema, + ) -> tuple[DeviceConverter, ...]: + """Return converters this source cannot feed, for diagnostics.""" + feedable = {id(converter) for converter in self.converters_for(source_schema)} + return tuple( + converter for converter in self._converters if id(converter) not in feedable + ) + + def canonical_schema( + self, + source_schema: UserInputSchema, + ) -> CanonicalInputSchema: + """Return the canonical modalities this raw source can supply. + + This is the boundary an application declares against. A mapping that + consumes ``driver_command`` then matches a keyboard source, a wheel + source, or any device registered later. + """ + modalities: list[CanonicalModality] = [] + for converter in self.converters_for(source_schema): + modality = converter.schema.produces + if modality not in modalities: + modalities.append(modality) + return CanonicalInputSchema( + modalities=tuple(modalities), + description=source_schema.description, + ) + + def canonicalize( + self, + user_inputs: UserInputs, + *, + window: TimeWindow, + source_schema: UserInputSchema, + ) -> CanonicalInputs: + """Convert one raw window into canonical inputs. + + Every feedable converter sees the window so its device state stays + current even while another device has precedence; that way unplugging + the higher-priority device does not resume from stale state. Among + converters producing the same modality, the highest-priority one that + returned a value wins. + """ + windowed = user_inputs.window(window) + values: dict[str, Any] = {} + sources: dict[str, str] = {} + for converter in self.converters_for(source_schema): + value = converter.convert(windowed, window) + modality = converter.schema.produces + if value is not None and modality.name not in values: + values[modality.name] = value + if converter.schema.device_kind is not None: + sources[modality.name] = converter.schema.device_kind + + metadata: dict[str, Any] = {} + if sources: + metadata["canonical_sources"] = freeze_mapping(sources) + return CanonicalInputs(values=values, metadata=metadata) diff --git a/flashdreams/flashdreams/runtime/inputs.py b/flashdreams/flashdreams/runtime/inputs.py index e14b35722..f0be31bea 100644 --- a/flashdreams/flashdreams/runtime/inputs.py +++ b/flashdreams/flashdreams/runtime/inputs.py @@ -8,10 +8,29 @@ import math from collections.abc import Iterable, Mapping from dataclasses import dataclass, field -from typing import Any +from typing import Any, Literal, cast from flashdreams.runtime._utils import freeze_mapping +InputPhase = Literal["global", "step"] + +INPUT_PHASES: tuple[InputPhase, ...] = ("global", "step") + +SESSION_START_ONLY = "session_start" +"""``InputField.update_policy`` value meaning "supply at session start only". + +``update_policy`` is otherwise an open, adapter-owned vocabulary. This is the +one reserved token, because the runtime needs to distinguish a conditioning +value that can be swapped mid-rollout from one that cannot. +""" + + +def validate_phase(value: str) -> InputPhase: + """Return ``value`` as a validated :data:`InputPhase`.""" + if value not in INPUT_PHASES: + raise ValueError(f"phase must be 'global' or 'step', got {value!r}.") + return cast(InputPhase, value) + @dataclass(frozen=True, kw_only=True, slots=True) class TimeWindow: @@ -35,16 +54,70 @@ def contains(self, timestamp_s: float) -> bool: @dataclass(frozen=True, kw_only=True, slots=True) class InputField: - """Lightweight schema field for user snapshots or model inputs.""" + """Lightweight schema field for user snapshots or model inputs. + + ``update_policy`` and ``lifecycle`` are plain query metadata. They let a + model advertise facts such as "prompt updates land at step boundaries" or + "this value is consumed at cache init" without making this layer + responsible for implementing or deeply validating that behavior. + """ name: str required: bool = True semantic_type: str | None = None + update_policy: str | None = None + lifecycle: str | None = None + metadata: Mapping[str, Any] = field( + default_factory=dict, + compare=False, + hash=False, + ) description: str = "" def __post_init__(self) -> None: if not self.name.strip(): raise ValueError("InputField.name must be non-empty.") + object.__setattr__(self, "metadata", freeze_mapping(self.metadata)) + + +@dataclass(frozen=True, kw_only=True, slots=True) +class UserInputCapability: + """One user event a source or mapping can provide, at payload granularity. + + ``UserInputSchema.event_types`` declares only that an event type exists. A + capability additionally pins the payload fields carried by that event, so a + mapping can state that it needs ``key_down`` events that actually carry a + ``key``. + """ + + event_type: str + semantic_type: str | None = None + payload_fields: frozenset[str] = field(default_factory=frozenset) + metadata: Mapping[str, Any] = field( + default_factory=dict, + compare=False, + hash=False, + ) + description: str = "" + + def __post_init__(self) -> None: + if not self.event_type.strip(): + raise ValueError("UserInputCapability.event_type must be non-empty.") + for payload_field in self.payload_fields: + if not payload_field.strip(): + raise ValueError("payload field names must be non-empty.") + object.__setattr__(self, "metadata", freeze_mapping(self.metadata)) + + def is_satisfied_by(self, provider: "UserInputCapability") -> bool: + """Return whether ``provider`` can satisfy this consumed capability.""" + if self.event_type != provider.event_type: + return False + semantic_ok = ( + self.semantic_type is None + or provider.semantic_type is None + or self.semantic_type == provider.semantic_type + ) + return semantic_ok and self.payload_fields.issubset(provider.payload_fields) @dataclass(frozen=True, kw_only=True, slots=True) @@ -53,6 +126,7 @@ class UserInputSchema: event_types: frozenset[str] = field(default_factory=frozenset) snapshot_fields: tuple[InputField, ...] = () + capabilities: tuple[UserInputCapability, ...] = () description: str = "" def supports_event_types(self, event_types: Iterable[str]) -> bool: @@ -60,7 +134,63 @@ def supports_event_types(self, event_types: Iterable[str]) -> bool: requested = frozenset(event_types) if not requested: return True - return requested.issubset(self.event_types) + return requested.issubset(self.declared_event_types()) + + def declared_event_types(self) -> frozenset[str]: + """Return event types from ``event_types`` and from ``capabilities``.""" + return self.event_types | frozenset( + capability.event_type for capability in self.capabilities + ) + + def declared_capabilities(self) -> tuple[UserInputCapability, ...]: + """Return capabilities, widened with bare ``event_types`` entries. + + A plain ``event_types`` entry carries no payload promise, so it is + modeled as a capability with no payload fields. Coarse schemas written + before capabilities existed therefore still satisfy any consumer that + does not require specific payload fields. + """ + declared = list(self.capabilities) + covered = {capability.event_type for capability in declared} + declared.extend( + UserInputCapability(event_type=event_type) + for event_type in sorted(self.event_types - covered) + ) + return tuple(declared) + + def supports(self, capability: UserInputCapability) -> bool: + """Return whether this source can satisfy ``capability``.""" + return any( + capability.is_satisfied_by(provider) + for provider in self.declared_capabilities() + ) + + def validate_event(self, event: "UserInputEvent") -> None: + """Validate one event against the event types this source declares.""" + matching = [ + capability + for capability in self.declared_capabilities() + if capability.event_type == event.event_type + ] + if not matching: + raise ValueError( + f"User input source does not provide event type {event.event_type!r}." + ) + payload_keys = set(event.payload) + if not any( + capability.payload_fields.issubset(payload_keys) for capability in matching + ): + expected = sorted( + { + payload_field + for capability in matching + for payload_field in capability.payload_fields + } + ) + raise ValueError( + f"Event {event.event_type!r} payload is missing required " + f"fields: {expected}." + ) def missing_snapshot(self, inputs: "UserInputs") -> tuple[str, ...]: """Return required snapshot fields absent from ``inputs``.""" @@ -74,10 +204,10 @@ def require_snapshot(self, inputs: "UserInputs") -> None: @dataclass(frozen=True, kw_only=True, slots=True) -class ModelInputSchema: +class InferenceInputSchema: """Minimal metadata for model-facing initial and per-step inputs.""" - initial_fields: tuple[InputField, ...] = () + global_fields: tuple[InputField, ...] = () """Model inputs required before starting the initial generation/session.""" step_fields: tuple[InputField, ...] = () @@ -85,21 +215,81 @@ class ModelInputSchema: description: str = "" - def missing_initial(self, inputs: "ModelInputs") -> tuple[str, ...]: + def fields_for(self, phase: InputPhase) -> tuple[InputField, ...]: + """Return every declared field for ``phase``.""" + return ( + self.global_fields + if validate_phase(phase) == "global" + else self.step_fields + ) + + def required_fields( + self, + phase: InputPhase | None = None, + ) -> tuple[tuple[InputPhase, InputField], ...]: + """Return required fields as ``(phase, field)``, optionally filtered.""" + return self._select(phase, required=True) + + def optional_fields( + self, + phase: InputPhase | None = None, + ) -> tuple[tuple[InputPhase, InputField], ...]: + """Return optional fields as ``(phase, field)``, optionally filtered.""" + return self._select(phase, required=False) + + def field_for(self, *, name: str, phase: InputPhase) -> InputField | None: + """Return one declared field, if present.""" + for input_field in self.fields_for(phase): + if input_field.name == name: + return input_field + return None + + def _select( + self, + phase: InputPhase | None, + *, + required: bool, + ) -> tuple[tuple[InputPhase, InputField], ...]: + phases = INPUT_PHASES if phase is None else (validate_phase(phase),) + return tuple( + (each_phase, input_field) + for each_phase in phases + for input_field in self.fields_for(each_phase) + if input_field.required is required + ) + + def unsupported_global_updates(self, inputs: "InferenceInput") -> tuple[str, ...]: + """Return requested conditioning updates this model cannot apply. + + A field whose ``update_policy`` is :data:`SESSION_START_ONLY` can be + supplied when the session starts but not changed mid-rollout. Any other + policy, including ``None``, is treated as permissive here; the adapter + still owns whether the swap actually succeeds. + """ + return tuple( + name + for name in inputs.global_conditioning + if (declared := self.field_for(name=name, phase="global")) is not None + and declared.update_policy == SESSION_START_ONLY + ) + + def missing_global(self, inputs: "InferenceInput") -> tuple[str, ...]: """Return required initial fields absent from ``inputs``.""" - return _missing_required(self.initial_fields, inputs.initial) + return _missing_required(self.global_fields, inputs.global_conditioning) - def missing_step(self, inputs: "ModelInputs") -> tuple[str, ...]: + def missing_step(self, inputs: "InferenceInput") -> tuple[str, ...]: """Return required per-step fields absent from ``inputs``.""" return _missing_required(self.step_fields, inputs.step) - def require_initial(self, inputs: "ModelInputs") -> None: + def require_global(self, inputs: "InferenceInput") -> None: """Raise if required initial fields are absent.""" - missing = self.missing_initial(inputs) + missing = self.missing_global(inputs) if missing: - raise ValueError(f"Missing required initial model input(s): {missing}") + raise ValueError( + f"Missing required global conditioning input(s): {missing}" + ) - def require_step(self, inputs: "ModelInputs") -> None: + def require_step(self, inputs: "InferenceInput") -> None: """Raise if required per-step fields are absent.""" missing = self.missing_step(inputs) if missing: @@ -171,23 +361,154 @@ def window(self, time_window: TimeWindow) -> "UserInputs": @dataclass(frozen=True, kw_only=True, slots=True) -class ModelInputs: - """Model-facing payloads split by initial and per-step use.""" +class CanonicalModality: + """A device-independent user input an application consumes. + + This is the middle layer of ``raw input -> canonicalized input -> encoded + inference input``. Applications and benchmarks declare and consume + modalities; they never read raw device events, so adding a new device is a + converter registration rather than an application change. + + Modalities describe live user control only. Global conditioning such as a + prompt or conditioning frame is application-owned and reaches + :class:`InferenceInput` directly, without passing through this layer. + """ + + name: str + payload_fields: frozenset[str] = field(default_factory=frozenset) + metadata: Mapping[str, Any] = field( + default_factory=dict, + compare=False, + hash=False, + ) + description: str = "" + + def __post_init__(self) -> None: + if not self.name.strip(): + raise ValueError("CanonicalModality.name must be non-empty.") + for payload_field in self.payload_fields: + if not payload_field.strip(): + raise ValueError("payload field names must be non-empty.") + object.__setattr__(self, "metadata", freeze_mapping(self.metadata)) + + def is_satisfied_by(self, provider: "CanonicalModality") -> bool: + """Return whether ``provider`` can satisfy this consumed modality.""" + return self.name == provider.name and self.payload_fields.issubset( + provider.payload_fields + ) + + def value(self, payload: Mapping[str, Any]) -> Mapping[str, Any]: + """Return ``payload`` frozen, checking it covers this modality.""" + missing = sorted(self.payload_fields - set(payload)) + if missing: + raise ValueError( + f"Canonical modality {self.name!r} requires payload fields " + f"{missing}, which the converter did not produce." + ) + return freeze_mapping(payload) + + +@dataclass(frozen=True, kw_only=True, slots=True) +class CanonicalInputSchema: + """Canonical modalities an application can be fed by a given source.""" + + modalities: tuple[CanonicalModality, ...] = () + description: str = "" + + def supports(self, modality: CanonicalModality) -> bool: + """Return whether this source can supply ``modality``.""" + return any(modality.is_satisfied_by(provided) for provided in self.modalities) + + +@dataclass(frozen=True, kw_only=True, slots=True) +class CanonicalInputs: + """Canonicalized user input for one step, keyed by modality name. + + Values are level-triggered and normally present every step: a key held down + emits no events but still means full throttle. Global conditioning does not + appear here; it is application-owned and reaches :class:`InferenceInput` + directly. + """ __hash__ = None - initial: Mapping[str, Any] = field(default_factory=dict) + values: Mapping[str, Any] = field(default_factory=dict) + metadata: Mapping[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + object.__setattr__(self, "values", freeze_mapping(self.values)) + object.__setattr__(self, "metadata", freeze_mapping(self.metadata)) + + +@dataclass(frozen=True, kw_only=True, slots=True) +class InferenceInput: + """Encoded inputs for one :class:`InferenceSession` call. + + Two conditioning slots: + + - ``global_conditioning``: values that condition the whole rollout, such as + the conditioning frame or prompt. Normally supplied when the session + starts. + - ``step``: values needed to generate the next chunk or frame. + + A non-empty ``global_conditioning`` on a mid-rollout input is an *update + request*, not a reset. The session should apply it when the model supports + that; resetting rollout state is a separate, explicit + :meth:`InferenceSession.reset` call. Whether a given value can be updated + mid-rollout is declared per field by ``InputField.update_policy``; see + :meth:`InferenceInputSchema.unsupported_global_updates`. + """ + + __hash__ = None + + global_conditioning: Mapping[str, Any] = field(default_factory=dict) step: Mapping[str, Any] = field(default_factory=dict) metadata: Mapping[str, Any] = field(default_factory=dict) def __post_init__(self) -> None: - object.__setattr__(self, "initial", freeze_mapping(self.initial)) + object.__setattr__( + self, "global_conditioning", freeze_mapping(self.global_conditioning) + ) object.__setattr__(self, "step", freeze_mapping(self.step)) object.__setattr__(self, "metadata", freeze_mapping(self.metadata)) - def with_step(self, step: Mapping[str, Any]) -> "ModelInputs": - """Return a copy with replaced per-step payload.""" - return ModelInputs(initial=self.initial, step=step, metadata=self.metadata) + @property + def requests_global_update(self) -> bool: + """Return whether this input asks the session to update conditioning.""" + return bool(self.global_conditioning) + + def with_step(self, step: Mapping[str, Any]) -> "InferenceInput": + """Return a copy with replaced per-step payload. + + The global slot is carried through unchanged, so a mid-rollout input + built this way keeps whatever update request it already had. Use + :meth:`without_global_update` for the common steady-state case. + """ + return InferenceInput( + global_conditioning=self.global_conditioning, + step=step, + metadata=self.metadata, + ) + + def with_global_update( + self, global_conditioning: Mapping[str, Any] + ) -> "InferenceInput": + """Return a copy requesting a mid-rollout conditioning update.""" + return InferenceInput( + global_conditioning=global_conditioning, + step=self.step, + metadata=self.metadata, + ) + + def without_global_update(self) -> "InferenceInput": + """Return a copy that requests no conditioning update.""" + return InferenceInput(step=self.step, metadata=self.metadata) + + def for_phase(self, phase: InputPhase) -> Mapping[str, Any]: + """Return the payload mapping for ``phase``.""" + return ( + self.global_conditioning if validate_phase(phase) == "global" else self.step + ) def _missing_required( diff --git a/flashdreams/flashdreams/runtime/interfaces.py b/flashdreams/flashdreams/runtime/interfaces.py index 9b6a064fd..852a77f1c 100644 --- a/flashdreams/flashdreams/runtime/interfaces.py +++ b/flashdreams/flashdreams/runtime/interfaces.py @@ -9,9 +9,9 @@ from flashdreams.runtime.config import InferenceConfig from flashdreams.runtime.inputs import ( - ModelInputs, - ModelInputSchema, - UserInputSchema, + CanonicalInputSchema, + InferenceInput, + InferenceInputSchema, ) from flashdreams.runtime.mapping import InputMapping from flashdreams.runtime.types import StepRequest, StepResult @@ -25,11 +25,11 @@ def next_step_request(self) -> StepRequest | None: """Describe the next step's inputs, or return ``None`` when complete.""" ... - def step(self, inputs: ModelInputs) -> StepResult: + def step(self, inputs: InferenceInput) -> StepResult: """Run one sequential inference step.""" ... - def reset(self, inputs: ModelInputs | None = None) -> None: + def reset(self, inputs: InferenceInput | None = None) -> None: """Reset this session's rollout state when the backend supports it.""" ... @@ -42,8 +42,8 @@ def close(self) -> None: class InferenceRuntime(Protocol): """Heavyweight reusable runtime created from :class:`InferenceConfig`.""" - def start_session(self, inputs: ModelInputs) -> InferenceSession: - """Create an isolated session from initial model inputs.""" + def start_session(self, inputs: InferenceInput) -> InferenceSession: + """Create an isolated session from global conditioning inputs.""" ... def close(self) -> None: @@ -56,10 +56,10 @@ def close(self) -> None: class ModelAdapter(Protocol): """Model-specific boundary that declares defaults and creates runtimes. - Adapters declare model-facing input requirements, optional user-input - capabilities, and an optional default mapping between the two. Runtime, - application, or benchmark code may override that mapping while preserving the - same ``UserInputs`` to ``ModelInputs`` boundary. + Adapters declare model-facing input requirements, the canonical modalities + their default mapping consumes, and an optional default mapping between the + two. Runtime, application, or benchmark code may override that mapping while + preserving the same ``CanonicalInputs`` to ``InferenceInput`` boundary. """ @property @@ -68,17 +68,17 @@ def model_id(self) -> str: ... @property - def model_input_schema(self) -> ModelInputSchema: + def inference_input_schema(self) -> InferenceInputSchema: """Model-facing initial and per-step input requirements.""" ... @property - def user_input_schema(self) -> UserInputSchema | None: - """User inputs supported by the adapter's default mapping, if any.""" + def canonical_input_schema(self) -> CanonicalInputSchema | None: + """Canonical modalities the adapter's default mapping consumes.""" ... def default_input_mapping(self) -> InputMapping | None: - """Return the model-provided default user-to-model mapping, if any.""" + """Return the model-provided default canonical-to-model mapping.""" ... def validate_config(self, config: InferenceConfig) -> None: diff --git a/flashdreams/flashdreams/runtime/mapping.py b/flashdreams/flashdreams/runtime/mapping.py index 756351081..94f481406 100644 --- a/flashdreams/flashdreams/runtime/mapping.py +++ b/flashdreams/flashdreams/runtime/mapping.py @@ -1,17 +1,24 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Input mapping boundary from user input windows to model inputs.""" +"""Input mapping boundary from canonical inputs to encoded inference inputs.""" from __future__ import annotations -from typing import Protocol, runtime_checkable +from collections.abc import Mapping, Sequence +from dataclasses import dataclass, field, replace +from typing import Any, Protocol, runtime_checkable +from flashdreams.runtime._utils import freeze_mapping from flashdreams.runtime.inputs import ( - ModelInputs, - ModelInputSchema, - UserInputs, - UserInputSchema, + INPUT_PHASES, + CanonicalInputs, + CanonicalInputSchema, + CanonicalModality, + InferenceInput, + InferenceInputSchema, + InputField, + InputPhase, ) from flashdreams.runtime.types import StepRequest @@ -28,28 +35,28 @@ class InputMapping(Protocol): def validate( self, *, - user_schema: UserInputSchema | None = None, - model_schema: ModelInputSchema | None = None, + canonical_schema: CanonicalInputSchema | None = None, + inference_input_schema: InferenceInputSchema | None = None, ) -> None: """Fail early for obvious app, event-source, and model mismatches.""" ... - def map_initial_inputs( + def map_global_inputs( self, *, - user_inputs: UserInputs, - model_inputs: ModelInputs, - ) -> ModelInputs: - """Build initial model inputs before a session starts.""" + canonical_inputs: CanonicalInputs, + inference_input: InferenceInput, + ) -> InferenceInput: + """Build global conditioning inputs before a session starts.""" ... def map_step_inputs( self, *, - user_inputs: UserInputs, - model_inputs: ModelInputs, + canonical_inputs: CanonicalInputs, + inference_input: InferenceInput, request: StepRequest, - ) -> ModelInputs: + ) -> InferenceInput: """Build model inputs for one session step from the current input window.""" ... @@ -60,26 +67,315 @@ class IdentityInputMapping: def validate( self, *, - user_schema: UserInputSchema | None = None, - model_schema: ModelInputSchema | None = None, + canonical_schema: CanonicalInputSchema | None = None, + inference_input_schema: InferenceInputSchema | None = None, ) -> None: - del user_schema, model_schema + del canonical_schema, inference_input_schema - def map_initial_inputs( + def map_global_inputs( self, *, - user_inputs: UserInputs, - model_inputs: ModelInputs, - ) -> ModelInputs: - del user_inputs - return model_inputs + canonical_inputs: CanonicalInputs, + inference_input: InferenceInput, + ) -> InferenceInput: + del canonical_inputs + return inference_input def map_step_inputs( self, *, - user_inputs: UserInputs, - model_inputs: ModelInputs, + canonical_inputs: CanonicalInputs, + inference_input: InferenceInput, request: StepRequest, - ) -> ModelInputs: - del user_inputs, request - return model_inputs + ) -> InferenceInput: + del canonical_inputs, request + return inference_input + + +@dataclass(frozen=True, kw_only=True, slots=True) +class InputMappingSchema: + """Declarative compatibility surface for one mapping. + + ``InputMapping.validate`` fails a run late and opaquely: it raises, but it + cannot answer which optional model inputs a source would enable, or which + missing user capability is responsible for an unreachable model input. This + schema makes those questions answerable before runtime initialization. + """ + + name: str = "input-mapping" + consumes: tuple[CanonicalModality, ...] = () + produces_global: tuple[InputField, ...] = () + produces_step: tuple[InputField, ...] = () + metadata: Mapping[str, Any] = field( + default_factory=dict, + compare=False, + hash=False, + ) + + def __post_init__(self) -> None: + if not self.name.strip(): + raise ValueError("InputMappingSchema.name must be non-empty.") + object.__setattr__(self, "metadata", freeze_mapping(self.metadata)) + + def produces_for(self, phase: InputPhase) -> tuple[InputField, ...]: + """Return the fields this mapping produces for ``phase``.""" + return self.produces_global if phase == "global" else self.produces_step + + def can_produce(self, phase: InputPhase, required: InputField) -> bool: + """Return whether this mapping can produce ``required`` in ``phase``.""" + return any( + _field_matches(produced, required) for produced in self.produces_for(phase) + ) + + +def _field_matches(produced: InputField, required: InputField) -> bool: + if produced.name != required.name: + return False + semantic_ok = ( + produced.semantic_type is None + or required.semantic_type is None + or produced.semantic_type == required.semantic_type + ) + lifecycle_ok = ( + produced.lifecycle is None + or required.lifecycle is None + or produced.lifecycle == required.lifecycle + ) + return semantic_ok and lifecycle_ok + + +@dataclass(frozen=True, kw_only=True, slots=True) +class MappingCompatibility: + """Compatibility report for one source, model schema, and mapping set. + + Mappings whose consumed capabilities the source cannot provide are reported + in ``unavailable_mapping_schemas`` and excluded from the satisfied/available + reports, so those lists only name model inputs that can really be produced. + """ + + __hash__ = None + + canonical_schema: CanonicalInputSchema + inference_input_schema: InferenceInputSchema + mapping_schema: InputMappingSchema + missing_modalities: tuple[CanonicalModality, ...] = () + missing_required_model_fields: tuple[tuple[InputPhase, InputField], ...] = () + satisfied_required_model_fields: tuple[tuple[InputPhase, InputField], ...] = () + available_optional_model_fields: tuple[tuple[InputPhase, InputField], ...] = () + unavailable_mapping_schemas: tuple[InputMappingSchema, ...] = () + + @property + def can_drive(self) -> bool: + """Return whether this source can drive this model through the mapping. + + A mapping the source cannot feed does not block the run unless it was + the only way to produce a required model input. + """ + return not (self.missing_required_model_fields or self.missing_modalities) + + @property + def unavailable_mapping_names(self) -> tuple[str, ...]: + """Return names of mappings dropped because the source cannot feed them.""" + return tuple(schema.name for schema in self.unavailable_mapping_schemas) + + def raise_if_incompatible(self) -> None: + """Raise a compact error when this mapping cannot drive the model.""" + if self.can_drive: + return + problems: list[str] = [] + if self.missing_modalities: + missing = ", ".join(modality.name for modality in self.missing_modalities) + problems.append(f"missing canonical modalities: {missing}") + if self.missing_required_model_fields: + missing = ", ".join( + f"{phase}:{input_field.name}" + for phase, input_field in self.missing_required_model_fields + ) + problems.append(f"missing required model inputs: {missing}") + if self.unavailable_mapping_schemas: + problems.append( + "unavailable mappings: " + ", ".join(self.unavailable_mapping_names) + ) + raise ValueError( + f"Input mapping {self.mapping_schema.name!r} cannot drive this model " + f"from the selected source: " + "; ".join(problems) + ) + + +def _source_can_feed( + canonical_schema: CanonicalInputSchema, + mapping_schema: InputMappingSchema, +) -> bool: + return all( + canonical_schema.supports(modality) for modality in mapping_schema.consumes + ) + + +def combine_mapping_schemas( + mapping_schemas: Sequence[InputMappingSchema], + *, + name: str = "input-mapping-set", +) -> InputMappingSchema: + """Combine independently declared mappings into one compatibility surface. + + Duplicates are collapsed. Because ``metadata`` is excluded from equality, + the metadata of collapsed duplicates is merged rather than dropped, with the + first declaration winning on conflicting keys. + """ + consumes: list[CanonicalModality] = [] + produces: dict[InputPhase, list[InputField]] = {"global": [], "step": []} + + def _merge(target: list[Any], value: Any) -> None: + for index, existing in enumerate(target): + if existing == value: + if value.metadata: + target[index] = replace( + existing, + metadata={**dict(value.metadata), **dict(existing.metadata)}, + ) + return + target.append(value) + + for mapping_schema in mapping_schemas: + if not isinstance(mapping_schema, InputMappingSchema): + raise TypeError("mapping_schemas must contain InputMappingSchema objects.") + for modality in mapping_schema.consumes: + _merge(consumes, modality) + for phase in INPUT_PHASES: + for input_field in mapping_schema.produces_for(phase): + _merge(produces[phase], input_field) + + return InputMappingSchema( + name=name, + consumes=tuple(consumes), + produces_global=tuple(produces["global"]), + produces_step=tuple(produces["step"]), + ) + + +def _build_compatibility( + *, + canonical_schema: CanonicalInputSchema, + inference_input_schema: InferenceInputSchema, + mapping_schemas: Sequence[InputMappingSchema], + reported_schema: InputMappingSchema, +) -> MappingCompatibility: + feedable: list[InputMappingSchema] = [] + unavailable: list[InputMappingSchema] = [] + for mapping_schema in mapping_schemas: + if _source_can_feed(canonical_schema, mapping_schema): + feedable.append(mapping_schema) + else: + unavailable.append(mapping_schema) + + usable = combine_mapping_schemas(feedable, name=reported_schema.name) + required = inference_input_schema.required_fields() + missing_required = tuple( + (phase, input_field) + for phase, input_field in required + if not usable.can_produce(phase, input_field) + ) + satisfied_required = tuple( + (phase, input_field) + for phase, input_field in required + if usable.can_produce(phase, input_field) + ) + available_optional = tuple( + (phase, input_field) + for phase, input_field in inference_input_schema.optional_fields() + if usable.can_produce(phase, input_field) + ) + + # Only capabilities that block a required model input make the mapping + # unusable. A dropped mapping that fed nothing but optional fields degrades + # the run instead of vetoing it. + missing_modalities: list[CanonicalModality] = [] + for mapping_schema in unavailable: + if not any( + mapping_schema.can_produce(phase, input_field) + for phase, input_field in missing_required + ): + continue + for modality in mapping_schema.consumes: + if canonical_schema.supports(modality) or modality in missing_modalities: + continue + missing_modalities.append(modality) + + return MappingCompatibility( + canonical_schema=canonical_schema, + inference_input_schema=inference_input_schema, + mapping_schema=reported_schema, + missing_modalities=tuple(missing_modalities), + missing_required_model_fields=missing_required, + satisfied_required_model_fields=satisfied_required, + available_optional_model_fields=available_optional, + unavailable_mapping_schemas=tuple(unavailable), + ) + + +def check_mapping_compatibility( + *, + canonical_schema: CanonicalInputSchema, + inference_input_schema: InferenceInputSchema, + mapping_schema: InputMappingSchema, +) -> MappingCompatibility: + """Check whether a user-input source can drive a model through a mapping.""" + if not isinstance(mapping_schema, InputMappingSchema): + raise TypeError("mapping_schema must be an InputMappingSchema object.") + return _build_compatibility( + canonical_schema=canonical_schema, + inference_input_schema=inference_input_schema, + mapping_schemas=(mapping_schema,), + reported_schema=mapping_schema, + ) + + +def check_mapping_set_compatibility( + *, + canonical_schema: CanonicalInputSchema, + inference_input_schema: InferenceInputSchema, + mapping_schemas: Sequence[InputMappingSchema], + name: str = "input-mapping-set", +) -> MappingCompatibility: + """Check compatibility for a composed set of mappings. + + Each mapping keeps its own consumes/produces link, so a mapping the source + cannot feed only costs the model inputs that mapping produced. + """ + mapping_schemas = tuple(mapping_schemas) + return _build_compatibility( + canonical_schema=canonical_schema, + inference_input_schema=inference_input_schema, + mapping_schemas=mapping_schemas, + reported_schema=combine_mapping_schemas(mapping_schemas, name=name), + ) + + +def undeclared_inference_inputs( + inputs: InferenceInput, + mapping_schema: InputMappingSchema, +) -> tuple[tuple[InputPhase, str], ...]: + """Return payload keys a mapping produced but did not declare. + + Mapping schemas are hand-written, so they drift from what + ``map_global_inputs``/``map_step_inputs`` actually return. Mapping tests + can use this to keep the declared compatibility surface honest. + """ + return tuple( + (phase, key) + for phase in INPUT_PHASES + for key in inputs.for_phase(phase) + if not any( + declared.name == key for declared in mapping_schema.produces_for(phase) + ) + ) + + +@runtime_checkable +class DeclaresMappingSchema(Protocol): + """Optional refinement of :class:`InputMapping` that declares its surface.""" + + @property + def mapping_schema(self) -> InputMappingSchema: + """Return the declarative compatibility surface for this mapping.""" + ... diff --git a/flashdreams/flashdreams/runtime/types.py b/flashdreams/flashdreams/runtime/types.py index 52bf82166..467753026 100644 --- a/flashdreams/flashdreams/runtime/types.py +++ b/flashdreams/flashdreams/runtime/types.py @@ -10,7 +10,7 @@ from typing import Any from flashdreams.runtime._utils import freeze_mapping -from flashdreams.runtime.inputs import ModelInputSchema, TimeWindow +from flashdreams.runtime.inputs import InferenceInputSchema, TimeWindow @dataclass(frozen=True, kw_only=True, slots=True) @@ -24,7 +24,7 @@ class StepRequest: __hash__ = None step_index: int - model_input_schema: ModelInputSchema | None = None + inference_input_schema: InferenceInputSchema | None = None user_input_window: TimeWindow | None = None metadata: Mapping[str, Any] = field(default_factory=dict) diff --git a/flashdreams/tests/test_inference_runtime_api.py b/flashdreams/tests/test_inference_runtime_api.py index 1474383a0..edfafa634 100644 --- a/flashdreams/tests/test_inference_runtime_api.py +++ b/flashdreams/tests/test_inference_runtime_api.py @@ -9,17 +9,20 @@ import pytest from flashdreams.runtime import ( + CanonicalInputs, + CanonicalInputSchema, IdentityInputMapping, InferenceConfig, + InferenceInput, + InferenceInputSchema, InferenceRuntime, InferenceSession, InMemoryMetricsRecorder, + InputCanonicalizer, InputField, InputMapping, MetricsRecorder, ModelAdapter, - ModelInputs, - ModelInputSchema, NullOutputTarget, OutputArtifact, OutputTarget, @@ -27,6 +30,7 @@ StepRequest, StepResult, TimeWindow, + UserInputCapability, UserInputEvent, UserInputs, UserInputSchema, @@ -35,6 +39,18 @@ pytestmark = pytest.mark.ci_cpu +_SESSION_HORIZON_S = 3600.0 + +_KEYBOARD_SOURCE = UserInputSchema( + capabilities=( + UserInputCapability( + event_type="keyboard.keydown", payload_fields=frozenset({"key"}) + ), + ) +) +_KEYBOARD_CANONICALIZER = InputCanonicalizer() + + def test_inference_config_keeps_runtime_settings_separate() -> None: denied_app_fields = {"prompt", "output_dir", "browser_settings"} config = InferenceConfig( @@ -90,17 +106,19 @@ def test_runtime_metric_sample_rejects_bool_values() -> None: RuntimeMetricSample(name="sample", value=True) -def test_model_input_schema_validates_initial_and_step_payloads() -> None: - schema = ModelInputSchema( - initial_fields=( +def test_inference_input_schema_validates_initial_and_step_payloads() -> None: + schema = InferenceInputSchema( + global_fields=( InputField(name="prompt"), - InputField(name="first_frame"), + InputField(name="global_conditioning_frame"), ), step_fields=(InputField(name="camera_poses"),), ) - inputs = ModelInputs(initial={"prompt": "drive", "first_frame": object()}) + inputs = InferenceInput( + global_conditioning={"prompt": "drive", "global_conditioning_frame": object()} + ) - schema.require_initial(inputs) + schema.require_global(inputs) assert schema.missing_step(inputs) == ("camera_poses",) with pytest.raises(ValueError, match="camera_poses"): @@ -164,25 +182,27 @@ def test_user_input_schema_validates_required_snapshot_fields() -> None: schema.require_snapshot(UserInputs()) -def test_identity_input_mapping_leaves_model_inputs_unchanged() -> None: +def test_identity_input_mapping_leaves_inference_input_unchanged() -> None: mapping = IdentityInputMapping() - model_inputs = ModelInputs(initial={"prompt": "fixed"}, step={"hdmap": object()}) + inference_input = InferenceInput( + global_conditioning={"prompt": "fixed"}, step={"hdmap": object()} + ) request = StepRequest(step_index=0) assert ( - mapping.map_initial_inputs( - user_inputs=UserInputs(), - model_inputs=model_inputs, + mapping.map_global_inputs( + canonical_inputs=CanonicalInputs(), + inference_input=inference_input, ) - is model_inputs + is inference_input ) assert ( mapping.map_step_inputs( - user_inputs=UserInputs(), - model_inputs=model_inputs, + canonical_inputs=CanonicalInputs(), + inference_input=inference_input, request=request, ) - is model_inputs + is inference_input ) @@ -258,7 +278,7 @@ def test_runtime_api_components_compose_for_sequential_session() -> None: ), ) ) - model_inputs = ModelInputs(initial={"prompt": "drive forward"}) + inference_input = InferenceInput(global_conditioning={"prompt": "drive forward"}) output = NullOutputTarget(store_results=True) metrics = InMemoryMetricsRecorder() @@ -269,8 +289,10 @@ def test_runtime_api_components_compose_for_sequential_session() -> None: adapter=adapter, config=config, mapping=mapping, + canonicalizer=_KEYBOARD_CANONICALIZER, + source_schema=_KEYBOARD_SOURCE, user_inputs=user_inputs, - model_inputs=model_inputs, + inference_input=inference_input, output=output, metrics=metrics, ) @@ -291,8 +313,10 @@ def test_reference_loop_validates_mapping_before_runtime_creation() -> None: adapter=adapter, config=InferenceConfig(model_id="fake-model"), mapping=mapping, + canonicalizer=_KEYBOARD_CANONICALIZER, + source_schema=_KEYBOARD_SOURCE, user_inputs=UserInputs(), - model_inputs=ModelInputs(initial={"prompt": "drive forward"}), + inference_input=InferenceInput(global_conditioning={"prompt": "drive forward"}), output=NullOutputTarget(), metrics=InMemoryMetricsRecorder(), ) @@ -311,8 +335,12 @@ def test_reference_loop_closes_runtime_when_session_start_fails() -> None: adapter=adapter, config=InferenceConfig(model_id="fake-model"), mapping=IdentityInputMapping(), + canonicalizer=_KEYBOARD_CANONICALIZER, + source_schema=_KEYBOARD_SOURCE, user_inputs=UserInputs(), - model_inputs=ModelInputs(initial={"prompt": "drive forward"}), + inference_input=InferenceInput( + global_conditioning={"prompt": "drive forward"} + ), output=output, metrics=metrics, ) @@ -328,18 +356,24 @@ def _drive_two_step_session( adapter: ModelAdapter, config: InferenceConfig, mapping: InputMapping, + canonicalizer: InputCanonicalizer, + source_schema: UserInputSchema, user_inputs: UserInputs, - model_inputs: ModelInputs, + inference_input: InferenceInput, output: OutputTarget, metrics: MetricsRecorder, ) -> None: mapping.validate( - user_schema=adapter.user_input_schema, - model_schema=adapter.model_input_schema, + canonical_schema=adapter.canonical_input_schema, + inference_input_schema=adapter.inference_input_schema, ) - initial_inputs = mapping.map_initial_inputs( - user_inputs=user_inputs, - model_inputs=model_inputs, + initial_inputs = mapping.map_global_inputs( + canonical_inputs=canonicalizer.canonicalize( + user_inputs, + window=TimeWindow(start_s=0.0, end_s=_SESSION_HORIZON_S), + source_schema=source_schema, + ), + inference_input=inference_input, ) runtime = adapter.create_runtime(config) session: InferenceSession | None = None @@ -350,13 +384,16 @@ def _drive_two_step_session( output_opened = True while (request := session.next_step_request()) is not None: step_inputs = mapping.map_step_inputs( - user_inputs=( - user_inputs.window(request.user_input_window) - if request.user_input_window is not None - else user_inputs + canonical_inputs=canonicalizer.canonicalize( + user_inputs, + window=request.user_input_window + or TimeWindow(start_s=0.0, end_s=_SESSION_HORIZON_S), + source_schema=source_schema, ), - model_inputs=ModelInputs( - initial=initial_inputs.initial, + # The global slot stays empty in steady state. A mapping that + # sees ``canonical_inputs.has_global_change`` fills it via + # ``with_global_update`` to request a mid-rollout swap. + inference_input=InferenceInput( step={"chunk_index": request.step_index}, ), request=request, @@ -379,11 +416,11 @@ def _drive_two_step_session( class _FakeAdapter: model_id = "fake-model" - model_input_schema = ModelInputSchema( - initial_fields=(InputField(name="prompt"),), + inference_input_schema = InferenceInputSchema( + global_fields=(InputField(name="prompt"),), step_fields=(InputField(name="chunk_index"),), ) - user_input_schema = UserInputSchema(event_types=frozenset({"keyboard.keydown"})) + canonical_input_schema = CanonicalInputSchema() def default_input_mapping(self) -> InputMapping: return IdentityInputMapping() @@ -394,31 +431,31 @@ def validate_config(self, config: InferenceConfig) -> None: def create_runtime(self, config: InferenceConfig) -> InferenceRuntime: self.validate_config(config) - return _FakeRuntime(model_input_schema=self.model_input_schema) + return _FakeRuntime(inference_input_schema=self.inference_input_schema) class _FakeRuntime: - def __init__(self, *, model_input_schema: ModelInputSchema) -> None: - self._model_input_schema = model_input_schema + def __init__(self, *, inference_input_schema: InferenceInputSchema) -> None: + self._inference_input_schema = inference_input_schema self.closed = False - def start_session(self, inputs: ModelInputs) -> InferenceSession: - self._model_input_schema.require_initial(inputs) - return _FakeSession(model_input_schema=self._model_input_schema) + def start_session(self, inputs: InferenceInput) -> InferenceSession: + self._inference_input_schema.require_global(inputs) + return _FakeSession(inference_input_schema=self._inference_input_schema) def close(self) -> None: self.closed = True class _FailingRuntime(_FakeRuntime): - def start_session(self, inputs: ModelInputs) -> InferenceSession: + def start_session(self, inputs: InferenceInput) -> InferenceSession: del inputs raise RuntimeError("start failed") class _FakeSession: - def __init__(self, *, model_input_schema: ModelInputSchema) -> None: - self._model_input_schema = model_input_schema + def __init__(self, *, inference_input_schema: InferenceInputSchema) -> None: + self._inference_input_schema = inference_input_schema self.step_index = 0 self.closed = False @@ -427,15 +464,15 @@ def next_step_request(self) -> StepRequest | None: return None return StepRequest( step_index=self.step_index, - model_input_schema=self._model_input_schema, + inference_input_schema=self._inference_input_schema, user_input_window=TimeWindow( start_s=0.5 * self.step_index, end_s=0.5 * (self.step_index + 1), ), ) - def step(self, inputs: ModelInputs) -> StepResult: - self._model_input_schema.require_step(inputs) + def step(self, inputs: InferenceInput) -> StepResult: + self._inference_input_schema.require_step(inputs) result = StepResult( step_index=self.step_index, output=f"chunk-{self.step_index}", @@ -449,7 +486,7 @@ def step(self, inputs: ModelInputs) -> StepResult: self.step_index += 1 return result - def reset(self, inputs: ModelInputs | None = None) -> None: + def reset(self, inputs: InferenceInput | None = None) -> None: del inputs self.step_index = 0 @@ -464,14 +501,19 @@ def __init__(self) -> None: def validate( self, *, - user_schema: UserInputSchema | None = None, - model_schema: ModelInputSchema | None = None, + canonical_schema: CanonicalInputSchema | None = None, + inference_input_schema: InferenceInputSchema | None = None, ) -> None: - super().validate(user_schema=user_schema, model_schema=model_schema) + super().validate( + canonical_schema=canonical_schema, + inference_input_schema=inference_input_schema, + ) self.validated = True class _OrderCheckingAdapter(_FakeAdapter): + canonical_input_schema = CanonicalInputSchema() + def __init__(self, *, mapping: _OrderCheckingMapping) -> None: self._mapping = mapping self.created_runtime_after_validate = False @@ -479,14 +521,18 @@ def __init__(self, *, mapping: _OrderCheckingMapping) -> None: def create_runtime(self, config: InferenceConfig) -> InferenceRuntime: self.validate_config(config) self.created_runtime_after_validate = self._mapping.validated - return _FakeRuntime(model_input_schema=self.model_input_schema) + return _FakeRuntime(inference_input_schema=self.inference_input_schema) class _FailingStartAdapter(_FakeAdapter): + canonical_input_schema = CanonicalInputSchema() + def __init__(self) -> None: self.runtime: _FailingRuntime | None = None def create_runtime(self, config: InferenceConfig) -> InferenceRuntime: self.validate_config(config) - self.runtime = _FailingRuntime(model_input_schema=self.model_input_schema) + self.runtime = _FailingRuntime( + inference_input_schema=self.inference_input_schema + ) return self.runtime diff --git a/flashdreams/tests/test_runtime_canonical.py b/flashdreams/tests/test_runtime_canonical.py new file mode 100644 index 000000000..1ad48d39e --- /dev/null +++ b/flashdreams/tests/test_runtime_canonical.py @@ -0,0 +1,590 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for the raw-input to canonical-modality layer. + +These cover the middle leg of ``raw input -> canonicalized input -> encoded +inference input``: applications consume canonical modalities, never raw device +events, so adding a device is a registration rather than an application change. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +import pytest + +from flashdreams.runtime import ( + DRIVER_COMMAND, + CanonicalInputs, + CanonicalModality, + DeviceConverterSchema, + InferenceInput, + InferenceInputSchema, + InputCanonicalizer, + InputField, + InputMappingSchema, + KeyboardToDriverCommand, + ScriptedModality, + TimeWindow, + UserInputCapability, + UserInputEvent, + UserInputs, + UserInputSchema, + check_mapping_compatibility, +) + +pytestmark = pytest.mark.ci_cpu + +KEYBOARD_SOURCE = UserInputSchema( + capabilities=( + UserInputCapability(event_type="key_down", payload_fields=frozenset({"key"})), + UserInputCapability(event_type="key_up", payload_fields=frozenset({"key"})), + ) +) +WHEEL_SOURCE = UserInputSchema( + capabilities=( + UserInputCapability( + event_type="wheel_axis", payload_fields=frozenset({"axis", "value"}) + ), + ) +) +PROMPT_SOURCE = UserInputSchema( + capabilities=( + UserInputCapability( + event_type="prompt_set", payload_fields=frozenset({"prompt"}) + ), + ) +) + +# Written once against the canonical modality. It names no key and no axis. +STEERING_MAPPING = InputMappingSchema( + name="driver-command-to-steering", + consumes=(DRIVER_COMMAND,), + produces_step=(InputField(name="steering"),), +) +STEERING_MODEL = InferenceInputSchema(step_fields=(InputField(name="steering"),)) + +WINDOW = TimeWindow(start_s=0.0, end_s=1.0) +NEXT_WINDOW = TimeWindow(start_s=1.0, end_s=2.0) + + +class WheelToDriverCommand: + """Minimal wheel converter standing in for a real evdev profile.""" + + def __init__(self, *, priority: int = 10) -> None: + self._steer = 0.0 + self._seen = False + self._schema = DeviceConverterSchema( + name="wheel-to-driver-command", + produces=DRIVER_COMMAND, + device_kind="wheel", + priority=priority, + consumes=( + UserInputCapability( + event_type="wheel_axis", + payload_fields=frozenset({"axis", "value"}), + ), + ), + ) + + @property + def schema(self) -> DeviceConverterSchema: + return self._schema + + def reset(self) -> None: + self._steer = 0.0 + self._seen = False + + def convert( + self, user_inputs: UserInputs, window: TimeWindow + ) -> Mapping[str, Any] | None: + del window + for event in user_inputs.events: + if event.event_type == "wheel_axis" and event.payload["axis"] == "steer": + self._seen = True + self._steer = float(event.payload["value"]) + if not self._seen: + return None + return DRIVER_COMMAND.value( + { + "throttle": 0.0, + "brake": 0.0, + "steer": self._steer, + "stop": False, + "reverse": False, + } + ) + + +def _key(event_type: str, key: str, timestamp_s: float) -> UserInputEvent: + return UserInputEvent( + timestamp_s=timestamp_s, event_type=event_type, payload={"key": key} + ) + + +def _command(canonical: CanonicalInputs) -> Mapping[str, Any]: + assert DRIVER_COMMAND.name in canonical.values + return canonical.values[DRIVER_COMMAND.name] + + +# --- per-step conditioning ---------------------------------------------- + + +def test_keyboard_edges_become_canonical_driver_command() -> None: + canonicalizer = InputCanonicalizer([KeyboardToDriverCommand()]) + inputs = UserInputs(events=(_key("key_down", "w", 0.1),)) + + canonical = canonicalizer.canonicalize( + inputs, window=WINDOW, source_schema=KEYBOARD_SOURCE + ) + + assert _command(canonical)["throttle"] == 1.0 + assert _command(canonical)["steer"] == 0.0 + assert canonical.metadata["canonical_sources"]["driver_command"] == "keyboard" + + +def test_key_aliases_are_normalized() -> None: + canonicalizer = InputCanonicalizer([KeyboardToDriverCommand()]) + inputs = UserInputs(events=(_key("key_down", "ArrowLeft", 0.1),)) + + canonical = canonicalizer.canonicalize( + inputs, window=WINDOW, source_schema=KEYBOARD_SOURCE + ) + + assert _command(canonical)["steer"] == 1.0 + + +def test_held_key_still_emits_in_a_window_with_no_events() -> None: + """Edge-triggered HID must become level-triggered per-step conditioning.""" + canonicalizer = InputCanonicalizer([KeyboardToDriverCommand()]) + inputs = UserInputs(events=(_key("key_down", "w", 0.1),)) + canonicalizer.canonicalize(inputs, window=WINDOW, source_schema=KEYBOARD_SOURCE) + + quiet = canonicalizer.canonicalize( + inputs, window=NEXT_WINDOW, source_schema=KEYBOARD_SOURCE + ) + + assert _command(quiet)["throttle"] == 1.0 + + +def test_key_release_returns_to_neutral() -> None: + canonicalizer = InputCanonicalizer([KeyboardToDriverCommand()]) + inputs = UserInputs(events=(_key("key_down", "a", 0.1), _key("key_up", "a", 1.5))) + canonicalizer.canonicalize(inputs, window=WINDOW, source_schema=KEYBOARD_SOURCE) + + released = canonicalizer.canonicalize( + inputs, window=NEXT_WINDOW, source_schema=KEYBOARD_SOURCE + ) + + assert _command(released)["steer"] == 0.0 + + +def test_reset_drops_device_state() -> None: + canonicalizer = InputCanonicalizer([KeyboardToDriverCommand()]) + inputs = UserInputs(events=(_key("key_down", "w", 0.1),)) + canonicalizer.canonicalize(inputs, window=WINDOW, source_schema=KEYBOARD_SOURCE) + + canonicalizer.reset() + after = canonicalizer.canonicalize( + UserInputs(), window=NEXT_WINDOW, source_schema=KEYBOARD_SOURCE + ) + + assert _command(after)["throttle"] == 0.0 + + +# --- boundary: global conditioning is not canonicalized ----------------- + + +def test_canonical_inputs_carry_live_control_only() -> None: + """Global conditioning is application-owned and bypasses this layer.""" + canonicalizer = InputCanonicalizer([KeyboardToDriverCommand()]) + inputs = UserInputs( + events=( + _key("key_down", "w", 0.1), + UserInputEvent( + timestamp_s=0.2, event_type="prompt_set", payload={"prompt": "rain"} + ), + ) + ) + + canonical = canonicalizer.canonicalize( + inputs, window=WINDOW, source_schema=KEYBOARD_SOURCE + ) + + assert set(canonical.values) == {"driver_command"} + + +def test_application_supplies_global_conditioning_directly() -> None: + """A prompt swap reaches the session without touching canonicalization.""" + update = InferenceInput(step={"steering": 0.0}).with_global_update( + {"prompt": "heavy rain"} + ) + + assert update.requests_global_update + assert update.global_conditioning["prompt"] == "heavy rain" + + +# --- device independence ------------------------------------------------ + + +def test_mapping_written_against_a_modality_accepts_a_keyboard() -> None: + canonicalizer = InputCanonicalizer([KeyboardToDriverCommand()]) + + compatibility = check_mapping_compatibility( + canonical_schema=canonicalizer.canonical_schema(KEYBOARD_SOURCE), + inference_input_schema=STEERING_MODEL, + mapping_schema=STEERING_MAPPING, + ) + + assert compatibility.can_drive + + +def test_adding_a_device_needs_no_application_or_model_change() -> None: + """A wheel is one register() call; mapping and model schemas are untouched.""" + canonicalizer = InputCanonicalizer([KeyboardToDriverCommand()]) + canonicalizer.register(WheelToDriverCommand()) + + compatibility = check_mapping_compatibility( + canonical_schema=canonicalizer.canonical_schema(WHEEL_SOURCE), + inference_input_schema=STEERING_MODEL, + mapping_schema=STEERING_MAPPING, + ) + assert compatibility.can_drive + + canonical = canonicalizer.canonicalize( + UserInputs( + events=( + UserInputEvent( + timestamp_s=0.5, + event_type="wheel_axis", + payload={"axis": "steer", "value": -0.4}, + ), + ) + ), + window=WINDOW, + source_schema=WHEEL_SOURCE, + ) + assert _command(canonical)["steer"] == pytest.approx(-0.4) + + +def test_source_with_no_feedable_converter_supplies_no_modalities() -> None: + canonicalizer = InputCanonicalizer([WheelToDriverCommand()]) + + schema = canonicalizer.canonical_schema(KEYBOARD_SOURCE) + + assert schema.modalities == () + assert not schema.supports(DRIVER_COMMAND) + assert canonicalizer.unavailable_converters(KEYBOARD_SOURCE) + + +def test_highest_priority_device_wins_when_both_are_present() -> None: + canonicalizer = InputCanonicalizer( + [KeyboardToDriverCommand(), WheelToDriverCommand()] + ) + both = UserInputSchema( + capabilities=KEYBOARD_SOURCE.capabilities + WHEEL_SOURCE.capabilities + ) + + canonical = canonicalizer.canonicalize( + UserInputs( + events=( + _key("key_down", "a", 0.2), + UserInputEvent( + timestamp_s=0.5, + event_type="wheel_axis", + payload={"axis": "steer", "value": -0.4}, + ), + ) + ), + window=WINDOW, + source_schema=both, + ) + + assert canonical.metadata["canonical_sources"]["driver_command"] == "wheel" + assert _command(canonical)["steer"] == pytest.approx(-0.4) + + +def test_preempted_device_keeps_its_state_current() -> None: + """Keyboard state must not be stale when the wheel disappears.""" + canonicalizer = InputCanonicalizer( + [KeyboardToDriverCommand(), WheelToDriverCommand()] + ) + both = UserInputSchema( + capabilities=KEYBOARD_SOURCE.capabilities + WHEEL_SOURCE.capabilities + ) + inputs = UserInputs( + events=( + _key("key_down", "w", 0.2), + UserInputEvent( + timestamp_s=0.5, + event_type="wheel_axis", + payload={"axis": "steer", "value": -0.4}, + ), + ) + ) + preempted = canonicalizer.canonicalize(inputs, window=WINDOW, source_schema=both) + assert preempted.metadata["canonical_sources"]["driver_command"] == "wheel" + + keyboard_only = canonicalizer.canonicalize( + inputs, window=NEXT_WINDOW, source_schema=KEYBOARD_SOURCE + ) + + assert keyboard_only.metadata["canonical_sources"]["driver_command"] == "keyboard" + assert _command(keyboard_only)["throttle"] == 1.0 + + +# --- registry ----------------------------------------------------------- + + +def test_duplicate_converter_names_are_rejected() -> None: + canonicalizer = InputCanonicalizer([KeyboardToDriverCommand()]) + + with pytest.raises(ValueError, match="already registered"): + canonicalizer.register(KeyboardToDriverCommand()) + + +def test_converter_must_fill_the_declared_modality_payload() -> None: + modality = CanonicalModality( + name="steering_wheel", payload_fields=frozenset({"steer", "throttle"}) + ) + + with pytest.raises(ValueError, match="requires payload fields"): + modality.value({"steer": 0.0}) + + +def test_new_modality_is_a_registration_not_a_core_change() -> None: + pedals = CanonicalModality( + name="pedal_state", payload_fields=frozenset({"throttle"}) + ) + + class PedalsConverter: + schema = DeviceConverterSchema( + name="pedals", + produces=pedals, + device_kind="pedals", + consumes=( + UserInputCapability( + event_type="pedal_axis", + payload_fields=frozenset({"value"}), + ), + ), + ) + + def reset(self) -> None: + return None + + def convert( + self, user_inputs: UserInputs, window: TimeWindow + ) -> Mapping[str, Any] | None: + del window + if not user_inputs.events: + return None + return pedals.value( + {"throttle": float(user_inputs.events[-1].payload["value"])} + ) + + source = UserInputSchema( + capabilities=( + UserInputCapability( + event_type="pedal_axis", payload_fields=frozenset({"value"}) + ), + ) + ) + canonicalizer = InputCanonicalizer([PedalsConverter()]) + + assert canonicalizer.canonical_schema(source).modalities == (pedals,) + canonical = canonicalizer.canonicalize( + UserInputs( + events=( + UserInputEvent( + timestamp_s=0.5, event_type="pedal_axis", payload={"value": 0.75} + ), + ) + ), + window=WINDOW, + source_schema=source, + ) + assert canonical.values["pedal_state"]["throttle"] == pytest.approx(0.75) + + +def test_replaying_the_same_windows_reproduces_the_same_canonical_inputs() -> None: + inputs = UserInputs(events=(_key("key_down", "w", 0.1), _key("key_down", "a", 1.2))) + + def run() -> list[dict[str, Any]]: + canonicalizer = InputCanonicalizer([KeyboardToDriverCommand()]) + return [ + dict( + _command( + canonicalizer.canonicalize( + inputs, window=window, source_schema=KEYBOARD_SOURCE + ) + ) + ) + for window in (WINDOW, NEXT_WINDOW) + ] + + assert run() == run() + + +# --- key bindings ------------------------------------------------------- + + +def test_bindings_are_data_and_can_be_rebound() -> None: + """A layout change must not require editing the converter.""" + azerty = InputCanonicalizer( + [ + KeyboardToDriverCommand( + bindings={ + "throttle": frozenset({"z"}), + "brake": frozenset({"s"}), + "steer_left": frozenset({"q"}), + "steer_right": frozenset({"d"}), + "stop": frozenset({"space"}), + } + ) + ] + ) + + canonical = azerty.canonicalize( + UserInputs(events=(_key("key_down", "z", 0.1),)), + window=WINDOW, + source_schema=KEYBOARD_SOURCE, + ) + + assert _command(canonical)["throttle"] == 1.0 + + +def test_tracked_keys_are_derived_so_an_action_cannot_go_unreachable() -> None: + """Declaring bindings and tracked keys separately used to disagree.""" + converter = KeyboardToDriverCommand( + bindings={"stop": frozenset({"escape"}), "throttle": frozenset({"w"})} + ) + canonicalizer = InputCanonicalizer([converter]) + + canonical = canonicalizer.canonicalize( + UserInputs(events=(_key("key_down", "escape", 0.1),)), + window=WINDOW, + source_schema=KEYBOARD_SOURCE, + ) + + assert _command(canonical)["stop"] is True + + +def test_unknown_driver_action_is_rejected() -> None: + with pytest.raises(ValueError, match="Unknown driver actions"): + KeyboardToDriverCommand(bindings={"turbo": frozenset({"t"})}) + + +def test_reverse_is_bindable() -> None: + canonicalizer = InputCanonicalizer( + [KeyboardToDriverCommand(bindings={"reverse": frozenset({"r"})})] + ) + + canonical = canonicalizer.canonicalize( + UserInputs(events=(_key("key_down", "r", 0.1),)), + window=WINDOW, + source_schema=KEYBOARD_SOURCE, + ) + + assert _command(canonical)["reverse"] is True + + +# --- scripted / mock input ---------------------------------------------- + + +def _scripted() -> InputCanonicalizer: + return InputCanonicalizer( + [ + ScriptedModality( + modality=DRIVER_COMMAND, + timeline=[ + ( + 0.0, + { + "throttle": 1.0, + "brake": 0.0, + "steer": 0.0, + "stop": False, + "reverse": False, + }, + ), + ( + 2.0, + { + "throttle": 0.0, + "brake": 0.0, + "steer": 1.0, + "stop": False, + "reverse": False, + }, + ), + ], + ) + ] + ) + + +def test_mock_input_needs_no_raw_events_or_source_schema() -> None: + """Authoring a benchmark scenario must not require raw device vocabulary.""" + canonical = _scripted().canonicalize( + UserInputs(), window=WINDOW, source_schema=UserInputSchema() + ) + + assert _command(canonical)["throttle"] == 1.0 + + +def test_scripted_values_hold_until_the_next_entry() -> None: + canonicalizer = _scripted() + windows = [TimeWindow(start_s=t, end_s=t + 1.0) for t in (0.0, 1.0, 2.0)] + + steer = [ + _command( + canonicalizer.canonicalize( + UserInputs(), window=w, source_schema=UserInputSchema() + ) + )["steer"] + for w in windows + ] + + assert steer == [0.0, 0.0, 1.0] + + +def test_scripted_converter_is_silent_before_its_first_entry() -> None: + canonicalizer = InputCanonicalizer( + [ + ScriptedModality( + modality=CanonicalModality(name="late", payload_fields=frozenset()), + timeline=[(5.0, {})], + ) + ] + ) + + canonical = canonicalizer.canonicalize( + UserInputs(), window=WINDOW, source_schema=UserInputSchema() + ) + + assert canonical.values == {} + + +def test_scripted_timeline_is_validated_against_the_modality() -> None: + with pytest.raises(ValueError, match="requires payload fields"): + ScriptedModality(modality=DRIVER_COMMAND, timeline=[(0.0, {"throttle": 1.0})]) + + +def test_scripted_replay_is_deterministic() -> None: + def run() -> list[float]: + canonicalizer = _scripted() + return [ + _command( + canonicalizer.canonicalize( + UserInputs(), + window=TimeWindow(start_s=t, end_s=t + 1.0), + source_schema=UserInputSchema(), + ) + )["steer"] + for t in (0.0, 1.0, 2.0) + ] + + assert run() == run() diff --git a/flashdreams/tests/test_runtime_input_mapping.py b/flashdreams/tests/test_runtime_input_mapping.py new file mode 100644 index 000000000..00cd9758f --- /dev/null +++ b/flashdreams/tests/test_runtime_input_mapping.py @@ -0,0 +1,573 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for declarative input-mapping compatibility in the runtime API. + +These cover the T2/T3 contract: sources declare what user events they can +provide at payload granularity, models declare required and optional +initial/per-step inputs, and a mapping declares what it consumes and produces so +compatibility can be answered before expensive runtime initialization. +""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from flashdreams.runtime import ( + DRIVER_COMMAND, + SESSION_START_ONLY, + CanonicalInputs, + CanonicalInputSchema, + CanonicalModality, + IdentityInputMapping, + InferenceInput, + InferenceInputSchema, + InputField, + InputMappingSchema, + StepRequest, + TimeWindow, + UserInputCapability, + UserInputEvent, + UserInputs, + UserInputSchema, + check_mapping_compatibility, + check_mapping_set_compatibility, + combine_mapping_schemas, + undeclared_inference_inputs, +) + +pytestmark = pytest.mark.ci_cpu + +KEY_DOWN = UserInputCapability(event_type="key_down", payload_fields=frozenset({"key"})) +KEY_UP = UserInputCapability(event_type="key_up", payload_fields=frozenset({"key"})) +PROMPT_SET = UserInputCapability( + event_type="prompt_set", + semantic_type="text", + payload_fields=frozenset({"prompt"}), +) +FRAME_SET = UserInputCapability( + event_type="initial_frame_set", payload_fields=frozenset({"image"}) +) + +BROWSER_SOURCE = UserInputSchema( + capabilities=(KEY_DOWN, KEY_UP, PROMPT_SET, FRAME_SET), + description="browser webrtc client", +) + +CAMERA_LOOK = CanonicalModality( + name="camera_look", payload_fields=frozenset({"yaw", "pitch"}) +) + +CANONICAL_ALL = CanonicalInputSchema(modalities=(DRIVER_COMMAND, CAMERA_LOOK)) + +# Global conditioning is application-owned and does not come from a canonical +# modality, so this mapping consumes nothing and only declares what it produces. +PROMPT_MAPPING = InputMappingSchema( + name="prompt", + produces_global=(InputField(name="prompt", semantic_type="text"),), +) +FRAME_MAPPING = InputMappingSchema( + name="conditioning-frame", + produces_global=(InputField(name="global_conditioning_frame", required=False),), +) +STEERING_MAPPING = InputMappingSchema( + name="driver-command-to-steering", + consumes=(DRIVER_COMMAND,), + produces_step=(InputField(name="steering"),), +) +LOOK_MAPPING = InputMappingSchema( + name="camera-look", + consumes=(CAMERA_LOOK,), + produces_step=(InputField(name="camera_delta", required=False),), +) + +DRIVING_MODEL = InferenceInputSchema( + global_fields=( + InputField(name="prompt", semantic_type="text", lifecycle="cache_init"), + ), + step_fields=( + InputField(name="steering", lifecycle="step_input"), + InputField(name="camera_delta", required=False, lifecycle="step_input"), + ), +) + + +# --- user input events and windowing ------------------------------------ + + +def test_startup_values_are_represented_as_events() -> None: + inputs = UserInputs( + events=( + UserInputEvent( + timestamp_s=0.0, event_type="prompt_set", payload={"prompt": "drive"} + ), + UserInputEvent( + timestamp_s=0.5, event_type="key_down", payload={"key": "w"} + ), + ) + ) + + assert inputs.events[0].event_type == "prompt_set" + assert inputs.events[0].payload["prompt"] == "drive" + + +def test_windowing_is_half_open_and_deterministic() -> None: + inputs = UserInputs( + events=tuple( + UserInputEvent(timestamp_s=t, event_type="key_down", payload={"key": "w"}) + for t in (0.0, 0.5, 1.0, 1.5) + ) + ) + + windowed = inputs.window(TimeWindow(start_s=0.5, end_s=1.5)) + + assert [event.timestamp_s for event in windowed.events] == [0.5, 1.0] + + +def test_out_of_order_events_are_rejected() -> None: + with pytest.raises(ValueError, match="non-decreasing"): + UserInputs( + events=( + UserInputEvent(timestamp_s=1.0, event_type="key_down"), + UserInputEvent(timestamp_s=0.5, event_type="key_up"), + ) + ) + + +# --- user input schemas ------------------------------------------------- + + +def test_source_declares_capabilities_at_payload_granularity() -> None: + assert BROWSER_SOURCE.supports(KEY_DOWN) + assert not BROWSER_SOURCE.supports( + UserInputCapability( + event_type="key_down", payload_fields=frozenset({"key", "modifiers"}) + ) + ) + + +def test_bare_event_types_still_satisfy_payload_free_consumers() -> None: + """Coarse pre-capability schemas keep working against the finer query.""" + coarse = UserInputSchema(event_types=frozenset({"reset"})) + + assert coarse.supports(UserInputCapability(event_type="reset")) + assert not coarse.supports( + UserInputCapability(event_type="reset", payload_fields=frozenset({"reason"})) + ) + assert coarse.supports_event_types({"reset"}) + + +def test_capabilities_widen_declared_event_types() -> None: + assert "key_down" in BROWSER_SOURCE.declared_event_types() + assert BROWSER_SOURCE.supports_event_types({"key_down", "prompt_set"}) + + +def test_semantic_type_mismatch_blocks_capability_match() -> None: + source = UserInputSchema( + capabilities=( + UserInputCapability(event_type="prompt_set", semantic_type="embedding"), + ) + ) + + assert not source.supports( + UserInputCapability(event_type="prompt_set", semantic_type="text") + ) + + +def test_event_validation_reports_missing_payload_fields() -> None: + event = UserInputEvent(timestamp_s=0.0, event_type="key_down", payload={}) + + with pytest.raises(ValueError, match="missing required"): + BROWSER_SOURCE.validate_event(event) + + +def test_event_validation_rejects_undeclared_event_type() -> None: + event = UserInputEvent(timestamp_s=0.0, event_type="wheel_axis") + + with pytest.raises(ValueError, match="does not provide event type"): + BROWSER_SOURCE.validate_event(event) + + +# --- model input schemas ------------------------------------------------ + + +def test_model_declares_required_and_optional_fields_per_phase() -> None: + required = DRIVING_MODEL.required_fields() + optional = DRIVING_MODEL.optional_fields() + + assert {(phase, f.name) for phase, f in required} == { + ("global", "prompt"), + ("step", "steering"), + } + assert {(phase, f.name) for phase, f in optional} == {("step", "camera_delta")} + + +def test_required_fields_can_be_filtered_by_phase() -> None: + step_only = DRIVING_MODEL.required_fields("step") + + assert [f.name for _, f in step_only] == ["steering"] + + +def test_field_lookup_is_phase_scoped() -> None: + assert DRIVING_MODEL.field_for(name="prompt", phase="global") is not None + assert DRIVING_MODEL.field_for(name="prompt", phase="step") is None + + +def test_invalid_phase_is_rejected() -> None: + bad_phase: Any = "final" + + with pytest.raises(ValueError, match="phase must be"): + DRIVING_MODEL.fields_for(bad_phase) + + +def test_inference_input_expose_payload_per_phase() -> None: + inputs = InferenceInput( + global_conditioning={"prompt": "drive"}, step={"steering": 0.25} + ) + + assert inputs.for_phase("global")["prompt"] == "drive" + assert inputs.for_phase("step")["steering"] == 0.25 + + +def test_lifecycle_and_update_policy_are_queryable_metadata() -> None: + field = InputField( + name="prompt", + update_policy="step_boundary", + lifecycle="cache_init", + metadata={"coordinates": "opencv_c2w"}, + ) + + assert field.update_policy == "step_boundary" + assert field.lifecycle == "cache_init" + assert field.metadata["coordinates"] == "opencv_c2w" + + +def test_metadata_is_excluded_from_field_equality() -> None: + plain = InputField(name="prompt") + annotated = InputField(name="prompt", metadata={"note": "hint"}) + + assert plain == annotated + + +# --- mapping compatibility ---------------------------------------------- + + +def test_compatible_source_model_and_mapping_can_drive() -> None: + compatibility = check_mapping_set_compatibility( + canonical_schema=CANONICAL_ALL, + inference_input_schema=DRIVING_MODEL, + mapping_schemas=(PROMPT_MAPPING, STEERING_MAPPING, LOOK_MAPPING), + ) + + assert compatibility.can_drive + assert {(p, f.name) for p, f in compatibility.satisfied_required_model_fields} == { + ("global", "prompt"), + ("step", "steering"), + } + assert {(p, f.name) for p, f in compatibility.available_optional_model_fields} == { + ("step", "camera_delta") + } + + +def test_missing_required_model_field_blocks_the_run() -> None: + compatibility = check_mapping_set_compatibility( + canonical_schema=CANONICAL_ALL, + inference_input_schema=DRIVING_MODEL, + mapping_schemas=(PROMPT_MAPPING,), + ) + + assert not compatibility.can_drive + assert [f.name for _, f in compatibility.missing_required_model_fields] == [ + "steering" + ] + + +def test_missing_source_capability_is_reported_when_it_blocks() -> None: + no_wheel = CanonicalInputSchema(modalities=(CAMERA_LOOK,)) + + compatibility = check_mapping_set_compatibility( + canonical_schema=no_wheel, + inference_input_schema=DRIVING_MODEL, + mapping_schemas=(PROMPT_MAPPING, STEERING_MAPPING), + ) + + assert not compatibility.can_drive + assert compatibility.unavailable_mapping_names == ("driver-command-to-steering",) + assert {m.name for m in compatibility.missing_modalities} == {"driver_command"} + + +def test_unfeedable_optional_mapping_degrades_instead_of_vetoing() -> None: + """Losing a mapping that fed only optional fields must not block the run.""" + no_look = CanonicalInputSchema(modalities=(DRIVER_COMMAND,)) + + compatibility = check_mapping_set_compatibility( + canonical_schema=no_look, + inference_input_schema=DRIVING_MODEL, + mapping_schemas=(PROMPT_MAPPING, STEERING_MAPPING, LOOK_MAPPING), + ) + + assert compatibility.can_drive + assert compatibility.unavailable_mapping_names == ("camera-look",) + # The dropped mapping's field must not be advertised as available. + assert compatibility.available_optional_model_fields == () + + +def test_optional_field_needs_mapping_support_to_be_available() -> None: + compatibility = check_mapping_set_compatibility( + canonical_schema=CANONICAL_ALL, + inference_input_schema=DRIVING_MODEL, + mapping_schemas=(PROMPT_MAPPING, STEERING_MAPPING), + ) + + assert compatibility.can_drive + assert compatibility.available_optional_model_fields == () + + +def test_lifecycle_disagreement_blocks_a_field_match() -> None: + model = InferenceInputSchema( + global_fields=(InputField(name="prompt", lifecycle="rollout_binding"),) + ) + mapping = InputMappingSchema( + name="prompt", + produces_global=(InputField(name="prompt", lifecycle="cache_init"),), + ) + + compatibility = check_mapping_compatibility( + canonical_schema=CANONICAL_ALL, + inference_input_schema=model, + mapping_schema=mapping, + ) + + assert not compatibility.can_drive + + +def test_unspecified_lifecycle_stays_permissive() -> None: + model = InferenceInputSchema(global_fields=(InputField(name="prompt"),)) + + compatibility = check_mapping_compatibility( + canonical_schema=CANONICAL_ALL, + inference_input_schema=model, + mapping_schema=PROMPT_MAPPING, + ) + + assert compatibility.can_drive + + +def test_raise_if_incompatible_names_both_failure_kinds() -> None: + compatibility = check_mapping_set_compatibility( + canonical_schema=CanonicalInputSchema(modalities=(CAMERA_LOOK,)), + inference_input_schema=DRIVING_MODEL, + mapping_schemas=(PROMPT_MAPPING, STEERING_MAPPING), + ) + + with pytest.raises(ValueError) as excinfo: + compatibility.raise_if_incompatible() + + message = str(excinfo.value) + assert "missing canonical modalities" in message + assert "missing required model inputs" in message + + +def test_raise_if_incompatible_is_a_no_op_when_compatible() -> None: + compatibility = check_mapping_set_compatibility( + canonical_schema=CANONICAL_ALL, + inference_input_schema=DRIVING_MODEL, + mapping_schemas=(PROMPT_MAPPING, FRAME_MAPPING, STEERING_MAPPING), + ) + + compatibility.raise_if_incompatible() + + +def test_check_mapping_compatibility_rejects_a_non_schema() -> None: + not_a_schema: Any = object() + + with pytest.raises(TypeError, match="InputMappingSchema"): + check_mapping_compatibility( + canonical_schema=CANONICAL_ALL, + inference_input_schema=DRIVING_MODEL, + mapping_schema=not_a_schema, + ) + + +# --- mapping schema composition ----------------------------------------- + + +def test_combining_mappings_unions_their_surfaces() -> None: + combined = combine_mapping_schemas((PROMPT_MAPPING, STEERING_MAPPING)) + + assert {m.name for m in combined.consumes} == {"driver_command"} + assert [f.name for f in combined.produces_global] == ["prompt"] + assert [f.name for f in combined.produces_step] == ["steering"] + + +def test_duplicate_declarations_collapse_and_merge_metadata() -> None: + first = InputMappingSchema( + name="a", + produces_global=(InputField(name="prompt", metadata={"source": "a"}),), + ) + second = InputMappingSchema( + name="b", + produces_global=( + InputField(name="prompt", metadata={"source": "b", "extra": "kept"}), + ), + ) + + combined = combine_mapping_schemas((first, second)) + + assert len(combined.produces_global) == 1 + metadata = combined.produces_global[0].metadata + assert metadata["source"] == "a" + assert metadata["extra"] == "kept" + + +def test_combine_rejects_non_schema_entries() -> None: + not_a_schema: Any = object() + + with pytest.raises(TypeError, match="InputMappingSchema"): + combine_mapping_schemas((PROMPT_MAPPING, not_a_schema)) + + +# --- declaration drift -------------------------------------------------- + + +def test_undeclared_inference_input_catches_schema_drift() -> None: + produced = InferenceInput( + global_conditioning={"prompt": "drive"}, step={"steering": 0.0} + ) + + undeclared = undeclared_inference_inputs(produced, PROMPT_MAPPING) + + assert undeclared == (("step", "steering"),) + + +def test_declared_outputs_report_no_drift() -> None: + combined = combine_mapping_schemas((PROMPT_MAPPING, STEERING_MAPPING)) + produced = InferenceInput( + global_conditioning={"prompt": "drive"}, step={"steering": 0.0} + ) + + assert undeclared_inference_inputs(produced, combined) == () + + +# --- interoperability with the T1 envelope ------------------------------ + + +def test_identity_mapping_needs_no_declared_surface() -> None: + """Fixed-input runs stay possible without any schema declaration.""" + mapping = IdentityInputMapping() + fixed = InferenceInput( + global_conditioning={"prompt": "fixed"}, step={"steering": 0.0} + ) + + mapped = mapping.map_step_inputs( + canonical_inputs=CanonicalInputs(), + inference_input=fixed, + request=StepRequest(step_index=0), + ) + + assert mapped.step["steering"] == 0.0 + + +def test_empty_mapping_set_cannot_satisfy_a_required_field() -> None: + compatibility = check_mapping_set_compatibility( + canonical_schema=CANONICAL_ALL, + inference_input_schema=DRIVING_MODEL, + mapping_schemas=(), + ) + + assert not compatibility.can_drive + assert len(compatibility.missing_required_model_fields) == 2 + + +def test_model_with_no_requirements_is_always_drivable() -> None: + compatibility = check_mapping_set_compatibility( + canonical_schema=CanonicalInputSchema(), + inference_input_schema=InferenceInputSchema(), + mapping_schemas=(), + ) + + assert compatibility.can_drive + + +# --- global conditioning updates vs reset ------------------------------- + + +def test_empty_global_slot_requests_no_update() -> None: + steady_state = InferenceInput(step={"steering": 0.25}) + + assert not steady_state.requests_global_update + + +def test_non_empty_global_slot_mid_rollout_is_an_update_request() -> None: + """Changing weather mid-run updates conditioning; it is not a reset.""" + updated = InferenceInput(step={"steering": 0.0}).with_global_update( + {"prompt": "heavy rain"} + ) + + assert updated.requests_global_update + assert updated.global_conditioning["prompt"] == "heavy rain" + assert updated.step["steering"] == 0.0 + + +def test_with_step_carries_the_global_slot_through() -> None: + started = InferenceInput(global_conditioning={"prompt": "drive"}) + + stepped = started.with_step({"steering": 0.5}) + + assert stepped.global_conditioning["prompt"] == "drive" + + +def test_without_global_update_clears_the_request() -> None: + started = InferenceInput( + global_conditioning={"prompt": "drive"}, step={"steering": 0.5} + ) + + steady_state = started.without_global_update() + + assert not steady_state.requests_global_update + assert steady_state.step["steering"] == 0.5 + + +def test_model_can_declare_conditioning_it_cannot_swap_mid_rollout() -> None: + schema = InferenceInputSchema( + global_fields=( + InputField(name="prompt", update_policy="step_boundary"), + InputField(name="scene_id", update_policy=SESSION_START_ONLY), + ) + ) + update = InferenceInput( + global_conditioning={"prompt": "heavy rain", "scene_id": "town_02"} + ) + + assert schema.unsupported_global_updates(update) == ("scene_id",) + + +def test_permissive_when_no_update_policy_is_declared() -> None: + schema = InferenceInputSchema(global_fields=(InputField(name="prompt"),)) + update = InferenceInput(global_conditioning={"prompt": "heavy rain"}) + + assert schema.unsupported_global_updates(update) == () + + +def test_undeclared_global_values_are_left_to_the_adapter() -> None: + schema = InferenceInputSchema(global_fields=(InputField(name="prompt"),)) + update = InferenceInput(global_conditioning={"mystery": 1}) + + assert schema.unsupported_global_updates(update) == () + + +def test_steady_state_steps_do_not_request_a_global_update() -> None: + """Carrying session-start conditioning forward would look like an update.""" + started = InferenceInput(global_conditioning={"prompt": "drive"}) + + steady_state = InferenceInput(step={"chunk_index": 1}) + + assert started.requests_global_update + assert not steady_state.requests_global_update + assert ( + not started.with_step({"chunk_index": 1}) + .without_global_update() + .requests_global_update + ) From 4083c344a05f8dd07c5b1382c60bca2e11759d3e Mon Sep 17 00:00:00 2001 From: Fangjun Zhou Date: Wed, 5 Aug 2026 15:35:57 -0700 Subject: [PATCH 10/30] WIP InferenceSession implementation --- docs/inference_runtime_api_design.md | 2 +- ...inference_runtime_inputs_implementation.md | 61 ++---- ...ence_runtime_supported_inputs_inventory.md | 10 +- flashdreams/flashdreams/runtime/__init__.py | 10 +- .../flashdreams/runtime/inference_session.py | 194 +++++++++++------ flashdreams/flashdreams/runtime/inputs.py | 115 ++-------- flashdreams/flashdreams/runtime/interfaces.py | 12 +- flashdreams/flashdreams/runtime/mapping.py | 24 ++- flashdreams/flashdreams/runtime/output.py | 8 +- flashdreams/flashdreams/runtime/types.py | 7 +- .../tests/test_inference_runtime_api.py | 81 +++++-- flashdreams/tests/test_inference_session.py | 198 ++++++++++++++++++ flashdreams/tests/test_runtime_canonical.py | 13 +- .../tests/test_runtime_input_mapping.py | 137 ++---------- 14 files changed, 493 insertions(+), 379 deletions(-) create mode 100644 flashdreams/tests/test_inference_session.py diff --git a/docs/inference_runtime_api_design.md b/docs/inference_runtime_api_design.md index f70fbd890..a753742d5 100644 --- a/docs/inference_runtime_api_design.md +++ b/docs/inference_runtime_api_design.md @@ -667,7 +667,7 @@ registry, standard loop, concrete output modes, or model migrations: - The model-specific integration boundary is named `ModelAdapter`. - Heavyweight lifecycle is split into `InferenceRuntime` and `InferenceSession`. -- Step data carriers are named `StepRequest` and `StepResult`; a session returns +- Step data carriers are named `StepRequest` and `InferenceOutput`; a session returns `None` from `next_step_request()` when the rollout is complete. - Raw inputs use `UserInputs`, canonicalized inputs use `CanonicalInputs`, and model-facing inputs use `InferenceInput`. diff --git a/docs/inference_runtime_inputs_implementation.md b/docs/inference_runtime_inputs_implementation.md index f7db05644..9ad8a4a3c 100644 --- a/docs/inference_runtime_inputs_implementation.md +++ b/docs/inference_runtime_inputs_implementation.md @@ -14,7 +14,7 @@ Implementation lives in `flashdreams.runtime`: - `flashdreams/flashdreams/runtime/inputs.py` — user/canonical input types and schemas - `flashdreams/flashdreams/runtime/inference_session.py` — model-ready - `InferenceInput` and session lifecycle + `InferenceInput`, `InferenceInputSchema`, and session lifecycle - `flashdreams/flashdreams/runtime/canonical.py` — raw device to canonical modality conversion - `flashdreams/flashdreams/runtime/mapping.py` — canonical to encoded mapping @@ -63,56 +63,41 @@ the same thing at each: - **per-step conditioning** — needed to generate the next chunk or frame: steering, HD map frames, camera trajectory. -`InputPhase` is `Literal["global", "step"]`. The axis names *which slot*, not -*when the value may arrive* — see the next section. - -## Global Conditioning Updates Are Not Resets - -A non-empty global slot on a mid-rollout `InferenceInput` is an **update -request**. The session should apply it when the model supports doing so. -Resetting rollout state is a separate, explicit `InferenceSession.reset()` call. -The motivating case is changing prompt and conditioning frame mid-run to change -the weather in an Omnidreams rollout. +``InferenceInput`` exposes exactly those two mappings: ```python from flashdreams.runtime import InferenceInput -steady_state = InferenceInput(step={"steering": 0.25}) -assert not steady_state.requests_global_update - -changed_weather = steady_state.with_global_update({"prompt": "heavy rain"}) -assert changed_weather.requests_global_update +inputs = InferenceInput( + global_conditioning={"prompt": "drive"}, + per_step_conditioning={"steering": 0.25}, +) ``` -Because `with_step()` carries the global slot through unchanged, use -`without_global_update()` for the steady-state case; otherwise every step looks -like an update request. +``InputPhase`` remains ``Literal["global", "step"]`` for mapping schemas. The +inference envelope uses the more explicit ``global_conditioning`` and +``per_step_conditioning`` names directly. -Whether a value can actually be swapped mid-rollout is declared per field: +## Payload Validation + +``InferenceInputSchema`` declares ``global_fields`` and +``per_step_fields``. Its two checks validate the corresponding payload and +raise ``ValueError`` when a required field is absent: ```python -from flashdreams.runtime import SESSION_START_ONLY, InferenceInputSchema, InputField +from flashdreams.runtime import InferenceInputSchema, InputField schema = InferenceInputSchema( - global_fields=( - InputField(name="prompt", update_policy="step_boundary"), - InputField(name="scene_id", update_policy=SESSION_START_ONLY), - ) + global_fields=(InputField(name="prompt"),), + per_step_fields=(InputField(name="steering"),), ) -schema.unsupported_global_updates( - InferenceInput(global_conditioning={"prompt": "heavy rain", "scene_id": "town_02"}) -) -# ("scene_id",) +schema.check_global_payload(inputs) +schema.check_per_step_payload(inputs) ``` -`SESSION_START_ONLY` is the one reserved `update_policy` token. Everything else -in that vocabulary, and all of `lifecycle`, is open and adapter-owned; this layer -only carries it as queryable metadata. - -Steady-state steps must leave the global slot empty; otherwise every step reads -as an update request. Converters emit every window, because live control is -level-triggered: a key held across a step emits no events but still means full -throttle. +Optional fields do not block either check. ``update_policy``, ``lifecycle``, and +other ``InputField`` metadata remain adapter-owned query hints; deep payload +validation stays in the adapter or mapping. ## Raw Inputs @@ -269,7 +254,7 @@ Named here only so the boundary is explicit; these are not gaps in the input layer: - **`FrameStream`**, which the architecture diagrams place between - `InferenceSession` and `Output Target`. The code writes `StepResult` straight + `InferenceSession` and `Output Target`. The code writes `InferenceOutput` straight to `OutputTarget.write()`. Output shape is T5. - **Declared output modalities**, so an output target or quality-eval can state what it requires and be matched the way inputs now are. T5/T8. diff --git a/docs/inference_runtime_supported_inputs_inventory.md b/docs/inference_runtime_supported_inputs_inventory.md index ebe9d853a..9b87b3820 100644 --- a/docs/inference_runtime_supported_inputs_inventory.md +++ b/docs/inference_runtime_supported_inputs_inventory.md @@ -198,7 +198,7 @@ The implementation that came out of this inventory is: registers per-device converters and produces `CanonicalInputs`. Applications and mappings consume canonical inputs and never read raw device events. 4. Split `InferenceInput` (formerly `ModelInputs`) into `global_conditioning` - and `step`. A non-empty global slot mid-rollout is an update request, not a + and `per_step_conditioning`. A non-empty global slot mid-rollout is an update request, not a reset; `InputField.update_policy` declares whether the model can apply it. 5. Extend `InputField` with `update_policy`, `lifecycle`, and `metadata` so models can distinguish runtime config, cache initialization, rollout binding, @@ -246,12 +246,11 @@ can describe the supported input surfaces. All use ```python lingbot_model = InferenceInputSchema( - description="lingbot-world", global_fields=( InputField(name="prompt", lifecycle="cache_init"), InputField(name="global_conditioning_frame", lifecycle="cache_init"), ), - step_fields=( + per_step_fields=( InputField(name="camera_trajectory", lifecycle="step_input"), InputField( name="text_embeddings", @@ -265,7 +264,6 @@ lingbot_model = InferenceInputSchema( ```python omnidreams_model = InferenceInputSchema( - description="omnidreams", global_fields=( InputField(name="prompts", lifecycle="cache_init"), InputField(name="global_conditioning_frames", lifecycle="cache_init"), @@ -273,13 +271,12 @@ omnidreams_model = InferenceInputSchema( InputField(name="text_embeddings", required=False, lifecycle="cache_init"), InputField(name="image_embeddings", required=False, lifecycle="cache_init"), ), - step_fields=(InputField(name="hdmap_frames", lifecycle="step_input"),), + per_step_fields=(InputField(name="hdmap_frames", lifecycle="step_input"),), ) ``` ```python hy_worldplay_model = InferenceInputSchema( - description="hy-worldplay", global_fields=( InputField(name="prompt", lifecycle="cache_init"), InputField(name="global_conditioning_frame", lifecycle="cache_init"), @@ -293,7 +290,6 @@ hy_worldplay_model = InferenceInputSchema( ```python sana_wm_model = InferenceInputSchema( - description="sana-wm", global_fields=( InputField(name="prompt", lifecycle="cache_init"), InputField(name="negative_prompt", required=False, lifecycle="cache_init"), diff --git a/flashdreams/flashdreams/runtime/__init__.py b/flashdreams/flashdreams/runtime/__init__.py index 9ce9d4ee2..427e5d144 100644 --- a/flashdreams/flashdreams/runtime/__init__.py +++ b/flashdreams/flashdreams/runtime/__init__.py @@ -17,14 +17,18 @@ ScriptedModality, ) from flashdreams.runtime.config import ExecutionBackend, InferenceConfig, Precision -from flashdreams.runtime.inference_session import InferenceInput +from flashdreams.runtime.inference_session import ( + InferenceInput, + InferenceInputSchema, + InferenceOutput, + InferenceSessionConfig, +) from flashdreams.runtime.inputs import ( INPUT_PHASES, SESSION_START_ONLY, CanonicalInputs, CanonicalInputSchema, CanonicalModality, - InferenceInputSchema, InputField, InputPhase, TimeWindow, @@ -76,8 +80,10 @@ "InferenceConfig", "InferenceInput", "InferenceInputSchema", + "InferenceOutput", "InferenceRuntime", "InferenceSession", + "InferenceSessionConfig", "InMemoryMetricsRecorder", "INPUT_PHASES", "InputCanonicalizer", diff --git a/flashdreams/flashdreams/runtime/inference_session.py b/flashdreams/flashdreams/runtime/inference_session.py index 6ce8b48d0..50a07acc2 100644 --- a/flashdreams/flashdreams/runtime/inference_session.py +++ b/flashdreams/flashdreams/runtime/inference_session.py @@ -13,123 +13,189 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Inference session lifecycle and model-input envelope.""" +"""Inference session lifecycle, model-input envelope, and schema.""" +from abc import ABC, abstractmethod from collections.abc import Mapping from dataclasses import dataclass, field -from typing import Any, TypedDict - -from typing_extensions import Unpack +from typing import Any from flashdreams.infra.pipeline import ( StreamInferencePipeline, + StreamInferencePipelineCache, StreamInferencePipelineConfig, ) from flashdreams.runtime._utils import freeze_mapping -from flashdreams.runtime.inputs import InputPhase, validate_phase +from flashdreams.runtime.inputs import InputField, TimeWindow, check_payload +from flashdreams.runtime.types import StepRequest @dataclass(frozen=True, kw_only=True, slots=True) class InferenceInput: - """Encoded inputs for one :class:`InferenceSession` call. - - Two conditioning slots: - - - ``global_conditioning``: values that condition the whole rollout, such as - the conditioning frame or prompt. Normally supplied when the session - starts. - - ``step``: values needed to generate the next chunk or frame. - - A non-empty ``global_conditioning`` on a mid-rollout input is an *update - request*, not a reset. The session should apply it when the model supports - that; resetting rollout state is a separate, explicit - :meth:`InferenceSession.reset` call. Whether a given value can be updated - mid-rollout is declared by ``InputField.update_policy``; see - ``InferenceInputSchema.unsupported_global_updates``. - """ + """Global and per-step conditioning for one inference call.""" __hash__ = None global_conditioning: Mapping[str, Any] = field(default_factory=dict) - step: Mapping[str, Any] = field(default_factory=dict) - metadata: Mapping[str, Any] = field(default_factory=dict) + per_step_conditioning: Mapping[str, Any] = field(default_factory=dict) def __post_init__(self) -> None: object.__setattr__( self, "global_conditioning", freeze_mapping(self.global_conditioning) ) - object.__setattr__(self, "step", freeze_mapping(self.step)) + object.__setattr__( + self, + "per_step_conditioning", + freeze_mapping(self.per_step_conditioning), + ) + + +@dataclass(frozen=True, kw_only=True, slots=True) +class InferenceOutput: + """Generated output and metadata for one inference step.""" + + __hash__ = None + + step_index: int + """Zero-based index of the completed inference step.""" + + output: Any = None + """Generated payload for the step.""" + + frame_count: int | None = None + """Number of generated frames when the output is frame-based.""" + + output_window: TimeWindow | None = None + """Session time window represented by the generated output.""" + + metadata: Mapping[str, Any] = field(default_factory=dict) + """Output metadata supplied by the session or model adapter.""" + + metrics: Mapping[str, float | int] = field(default_factory=dict) + """Per-step numeric measurements.""" + + def __post_init__(self) -> None: + if self.step_index < 0: + raise ValueError("InferenceOutput.step_index must be >= 0.") + if self.frame_count is not None and self.frame_count < 0: + raise ValueError("InferenceOutput.frame_count must be >= 0.") object.__setattr__(self, "metadata", freeze_mapping(self.metadata)) + object.__setattr__(self, "metrics", freeze_mapping(self.metrics)) - @property - def requests_global_update(self) -> bool: - """Return whether this input asks the session to update conditioning.""" - return bool(self.global_conditioning) - def with_step(self, step: Mapping[str, Any]) -> "InferenceInput": - """Return a copy with replaced per-step payload. +@dataclass(frozen=True, kw_only=True, slots=True) +class InferenceInputSchema: + """Required and optional fields in each inference input payload.""" - The global slot is carried through unchanged, so a mid-rollout input - built this way keeps whatever update request it already had. Use - :meth:`without_global_update` for the common steady-state case. - """ - return InferenceInput( - global_conditioning=self.global_conditioning, - step=step, - metadata=self.metadata, - ) + global_fields: tuple[InputField, ...] = () + """Model inputs required before starting the initial generation/session.""" - def with_global_update( - self, global_conditioning: Mapping[str, Any] - ) -> "InferenceInput": - """Return a copy requesting a mid-rollout conditioning update.""" - return InferenceInput( - global_conditioning=global_conditioning, - step=self.step, - metadata=self.metadata, - ) + per_step_fields: tuple[InputField, ...] = () + """Per-step model inputs required after the session starts.""" - def without_global_update(self) -> "InferenceInput": - """Return a copy that requests no conditioning update.""" - return InferenceInput(step=self.step, metadata=self.metadata) + def check_global_payload(self, inputs: InferenceInput) -> None: + """Check that required global fields are present in ``inputs``.""" + check_payload(self.global_fields, inputs.global_conditioning) - def for_phase(self, phase: InputPhase) -> Mapping[str, Any]: - """Return the payload mapping for ``phase``.""" - return ( - self.global_conditioning if validate_phase(phase) == "global" else self.step - ) + def check_per_step_payload(self, inputs: InferenceInput) -> None: + """Check that required per-step fields are present in ``inputs``.""" + check_payload(self.per_step_fields, inputs.per_step_conditioning) -class InferenceSessionConfig(TypedDict): +@dataclass(frozen=True, kw_only=True, slots=True) +class InferenceSessionConfig: """Configuration for constructing an inference session.""" + __hash__ = None + pipeline: StreamInferencePipelineConfig """Pipeline configuration to instantiate.""" -class InferenceSession: +class InferenceSession(ABC): """Stateful inference pipeline session.""" - def __init__(self, **kwargs: Unpack[InferenceSessionConfig]) -> None: + _pipeline_cache: StreamInferencePipelineCache[Any, Any, Any] | None + """Pipeline cache for the active rollout; ``None`` before its first step.""" + + _step_index: int + """Zero-based index assigned to the next generated output.""" + + def __init__(self, config: InferenceSessionConfig) -> None: """Initialize the inference pipeline. Args: - **kwargs: Session construction keyword arguments. + config: Session configuration. """ + self.config = config # Initialize the inference pipeline from the provided configuration. - self.pipeline: StreamInferencePipeline = kwargs["pipeline"].setup() + self.pipeline: StreamInferencePipeline = self.config.pipeline.setup() + self._pipeline_cache = None + self._step_index = 0 def __del__(self) -> None: """Release session resources.""" if hasattr(self, "pipeline"): del self.pipeline - def reset(self) -> None: - """Reset the inference session.""" + def next_step_request(self) -> StepRequest: + """Return input requirements for the next pipeline step.""" + return StepRequest(step_index=self._step_index) - def step(self, inference_input: InferenceInput) -> None: + @abstractmethod + def reset(self) -> None: + """Reset the pipeline and discard the active rollout state.""" + pipeline_reset = getattr(self.pipeline, "reset", None) + if callable(pipeline_reset): + pipeline_reset() + self._pipeline_cache = None + self._step_index = 0 + + @abstractmethod + def step(self, inference_input: InferenceInput) -> InferenceOutput: """Run one inference step. Args: inference_input: Model-ready inputs for the step. + + Returns: + Generated output for the step. + + Raises: + ValueError: Global conditioning is supplied after the rollout starts. """ + request = self.next_step_request() + input_schema = request.inference_input_schema + if self._pipeline_cache is None: + if input_schema is not None: + input_schema.check_global_payload(inference_input) + self._pipeline_cache = self.pipeline.initialize_cache( + **inference_input.global_conditioning + ) + elif inference_input.global_conditioning: + raise ValueError( + "InferenceInput.global_conditioning can only be supplied on the " + "first step after reset()." + ) + + if input_schema is not None: + input_schema.check_per_step_payload(inference_input) + pipeline_input = inference_input.per_step_conditioning or None + output = self.pipeline.generate( + autoregressive_index=request.step_index, + cache=self._pipeline_cache, + input=pipeline_input, + ) + self.pipeline.finalize( + autoregressive_index=request.step_index, + cache=self._pipeline_cache, + ) + inference_output = InferenceOutput( + step_index=request.step_index, + output=output, + output_window=request.user_input_window, + metadata=request.metadata, + metrics={}, # No metrics for now, inject to pipeline later. + ) + self._step_index = request.step_index + 1 + return inference_output diff --git a/flashdreams/flashdreams/runtime/inputs.py b/flashdreams/flashdreams/runtime/inputs.py index 66a4432c9..edd97bc45 100644 --- a/flashdreams/flashdreams/runtime/inputs.py +++ b/flashdreams/flashdreams/runtime/inputs.py @@ -1,20 +1,17 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""User, canonical, and model-input schemas for the experimental runtime API.""" +"""User and canonical input envelopes and schemas for the runtime API.""" from __future__ import annotations import math from collections.abc import Iterable, Mapping from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Any, Literal, cast +from typing import Any, Literal, cast from flashdreams.runtime._utils import freeze_mapping -if TYPE_CHECKING: - from flashdreams.runtime.inference_session import InferenceInput - InputPhase = Literal["global", "step"] INPUT_PHASES: tuple[InputPhase, ...] = ("global", "step") @@ -197,7 +194,11 @@ def validate_event(self, event: "UserInputEvent") -> None: def missing_snapshot(self, inputs: "UserInputs") -> tuple[str, ...]: """Return required snapshot fields absent from ``inputs``.""" - return _missing_required(self.snapshot_fields, inputs.snapshot) + return tuple( + input_field.name + for input_field in self.snapshot_fields + if input_field.required and input_field.name not in inputs.snapshot + ) def require_snapshot(self, inputs: "UserInputs") -> None: """Raise if required snapshot fields are absent.""" @@ -206,99 +207,6 @@ def require_snapshot(self, inputs: "UserInputs") -> None: raise ValueError(f"Missing required user snapshot field(s): {missing}") -@dataclass(frozen=True, kw_only=True, slots=True) -class InferenceInputSchema: - """Minimal metadata for model-facing initial and per-step inputs.""" - - global_fields: tuple[InputField, ...] = () - """Model inputs required before starting the initial generation/session.""" - - step_fields: tuple[InputField, ...] = () - """Per-step model inputs required after the session starts.""" - - description: str = "" - - def fields_for(self, phase: InputPhase) -> tuple[InputField, ...]: - """Return every declared field for ``phase``.""" - return ( - self.global_fields - if validate_phase(phase) == "global" - else self.step_fields - ) - - def required_fields( - self, - phase: InputPhase | None = None, - ) -> tuple[tuple[InputPhase, InputField], ...]: - """Return required fields as ``(phase, field)``, optionally filtered.""" - return self._select(phase, required=True) - - def optional_fields( - self, - phase: InputPhase | None = None, - ) -> tuple[tuple[InputPhase, InputField], ...]: - """Return optional fields as ``(phase, field)``, optionally filtered.""" - return self._select(phase, required=False) - - def field_for(self, *, name: str, phase: InputPhase) -> InputField | None: - """Return one declared field, if present.""" - for input_field in self.fields_for(phase): - if input_field.name == name: - return input_field - return None - - def _select( - self, - phase: InputPhase | None, - *, - required: bool, - ) -> tuple[tuple[InputPhase, InputField], ...]: - phases = INPUT_PHASES if phase is None else (validate_phase(phase),) - return tuple( - (each_phase, input_field) - for each_phase in phases - for input_field in self.fields_for(each_phase) - if input_field.required is required - ) - - def unsupported_global_updates(self, inputs: InferenceInput) -> tuple[str, ...]: - """Return requested conditioning updates this model cannot apply. - - A field whose ``update_policy`` is :data:`SESSION_START_ONLY` can be - supplied when the session starts but not changed mid-rollout. Any other - policy, including ``None``, is treated as permissive here; the adapter - still owns whether the swap actually succeeds. - """ - return tuple( - name - for name in inputs.global_conditioning - if (declared := self.field_for(name=name, phase="global")) is not None - and declared.update_policy == SESSION_START_ONLY - ) - - def missing_global(self, inputs: InferenceInput) -> tuple[str, ...]: - """Return required initial fields absent from ``inputs``.""" - return _missing_required(self.global_fields, inputs.global_conditioning) - - def missing_step(self, inputs: InferenceInput) -> tuple[str, ...]: - """Return required per-step fields absent from ``inputs``.""" - return _missing_required(self.step_fields, inputs.step) - - def require_global(self, inputs: InferenceInput) -> None: - """Raise if required initial fields are absent.""" - missing = self.missing_global(inputs) - if missing: - raise ValueError( - f"Missing required global conditioning input(s): {missing}" - ) - - def require_step(self, inputs: InferenceInput) -> None: - """Raise if required per-step fields are absent.""" - missing = self.missing_step(inputs) - if missing: - raise ValueError(f"Missing required step model input(s): {missing}") - - @dataclass(frozen=True, kw_only=True, slots=True) class UserInputEvent: """User-facing input event timestamped in seconds since session start. @@ -444,11 +352,12 @@ def __post_init__(self) -> None: object.__setattr__(self, "metadata", freeze_mapping(self.metadata)) -def _missing_required( - fields: tuple[InputField, ...], payload: Mapping[str, Any] -) -> tuple[str, ...]: - return tuple( +def check_payload(fields: tuple[InputField, ...], payload: Mapping[str, Any]) -> None: + """Raise if ``payload`` omits any required field.""" + missing = tuple( input_field.name for input_field in fields if input_field.required and input_field.name not in payload ) + if missing: + raise ValueError(f"Missing required input(s): {missing}") diff --git a/flashdreams/flashdreams/runtime/interfaces.py b/flashdreams/flashdreams/runtime/interfaces.py index 0ff276e46..65a14e9b9 100644 --- a/flashdreams/flashdreams/runtime/interfaces.py +++ b/flashdreams/flashdreams/runtime/interfaces.py @@ -8,10 +8,14 @@ from typing import Protocol, runtime_checkable from flashdreams.runtime.config import InferenceConfig -from flashdreams.runtime.inference_session import InferenceInput -from flashdreams.runtime.inputs import CanonicalInputSchema, InferenceInputSchema +from flashdreams.runtime.inference_session import ( + InferenceInput, + InferenceInputSchema, + InferenceOutput, +) +from flashdreams.runtime.inputs import CanonicalInputSchema from flashdreams.runtime.mapping import InputMapping -from flashdreams.runtime.types import StepRequest, StepResult +from flashdreams.runtime.types import StepRequest @runtime_checkable @@ -22,7 +26,7 @@ def next_step_request(self) -> StepRequest | None: """Describe the next step's inputs, or return ``None`` when complete.""" ... - def step(self, inputs: InferenceInput) -> StepResult: + def step(self, inputs: InferenceInput) -> InferenceOutput: """Run one sequential inference step.""" ... diff --git a/flashdreams/flashdreams/runtime/mapping.py b/flashdreams/flashdreams/runtime/mapping.py index 4527c3fdd..79b269043 100644 --- a/flashdreams/flashdreams/runtime/mapping.py +++ b/flashdreams/flashdreams/runtime/mapping.py @@ -10,13 +10,12 @@ from typing import Any, Protocol, runtime_checkable from flashdreams.runtime._utils import freeze_mapping -from flashdreams.runtime.inference_session import InferenceInput +from flashdreams.runtime.inference_session import InferenceInput, InferenceInputSchema from flashdreams.runtime.inputs import ( INPUT_PHASES, CanonicalInputs, CanonicalInputSchema, CanonicalModality, - InferenceInputSchema, InputField, InputPhase, ) @@ -269,7 +268,15 @@ def _build_compatibility( unavailable.append(mapping_schema) usable = combine_mapping_schemas(feedable, name=reported_schema.name) - required = inference_input_schema.required_fields() + declared_fields: tuple[tuple[InputPhase, InputField], ...] = tuple( + (phase, input_field) + for phase, fields in ( + ("global", inference_input_schema.global_fields), + ("step", inference_input_schema.per_step_fields), + ) + for input_field in fields + ) + required = tuple(declared for declared in declared_fields if declared[1].required) missing_required = tuple( (phase, input_field) for phase, input_field in required @@ -282,7 +289,8 @@ def _build_compatibility( ) available_optional = tuple( (phase, input_field) - for phase, input_field in inference_input_schema.optional_fields() + for phase, input_field in declared_fields + if not input_field.required if usable.can_produce(phase, input_field) ) @@ -361,10 +369,14 @@ def undeclared_inference_inputs( ``map_global_inputs``/``map_step_inputs`` actually return. Mapping tests can use this to keep the declared compatibility surface honest. """ + payloads: tuple[tuple[InputPhase, Mapping[str, Any]], ...] = ( + ("global", inputs.global_conditioning), + ("step", inputs.per_step_conditioning), + ) return tuple( (phase, key) - for phase in INPUT_PHASES - for key in inputs.for_phase(phase) + for phase, payload in payloads + for key in payload if not any( declared.name == key for declared in mapping_schema.produces_for(phase) ) diff --git a/flashdreams/flashdreams/runtime/output.py b/flashdreams/flashdreams/runtime/output.py index aac341ee1..675b04d60 100644 --- a/flashdreams/flashdreams/runtime/output.py +++ b/flashdreams/flashdreams/runtime/output.py @@ -10,7 +10,7 @@ from typing import Any, Protocol, runtime_checkable from flashdreams.runtime._utils import freeze_mapping -from flashdreams.runtime.types import StepResult +from flashdreams.runtime.inference_session import InferenceOutput @dataclass(frozen=True, kw_only=True, slots=True) @@ -39,7 +39,7 @@ def open(self) -> None: """Prepare the target for a new run.""" ... - def write(self, result: StepResult) -> None: + def write(self, result: InferenceOutput) -> None: """Consume one generated step result.""" ... @@ -54,7 +54,7 @@ class NullOutputTarget: store_results: bool = False output_count: int = field(default=0, init=False) - results: list[StepResult] = field(default_factory=list, init=False) + results: list[InferenceOutput] = field(default_factory=list, init=False) _opened: bool = field(default=False, init=False, repr=False) @property @@ -66,7 +66,7 @@ def open(self) -> None: self.output_count = 0 self.results.clear() - def write(self, result: StepResult) -> None: + def write(self, result: InferenceOutput) -> None: if not self._opened: raise RuntimeError("Cannot write to a closed output target.") self.output_count += 1 diff --git a/flashdreams/flashdreams/runtime/types.py b/flashdreams/flashdreams/runtime/types.py index 467753026..e430f4794 100644 --- a/flashdreams/flashdreams/runtime/types.py +++ b/flashdreams/flashdreams/runtime/types.py @@ -7,10 +7,13 @@ from collections.abc import Mapping from dataclasses import dataclass, field -from typing import Any +from typing import TYPE_CHECKING, Any from flashdreams.runtime._utils import freeze_mapping -from flashdreams.runtime.inputs import InferenceInputSchema, TimeWindow +from flashdreams.runtime.inputs import TimeWindow + +if TYPE_CHECKING: + from flashdreams.runtime.inference_session import InferenceInputSchema @dataclass(frozen=True, kw_only=True, slots=True) diff --git a/flashdreams/tests/test_inference_runtime_api.py b/flashdreams/tests/test_inference_runtime_api.py index edfafa634..f504cf854 100644 --- a/flashdreams/tests/test_inference_runtime_api.py +++ b/flashdreams/tests/test_inference_runtime_api.py @@ -15,6 +15,7 @@ InferenceConfig, InferenceInput, InferenceInputSchema, + InferenceOutput, InferenceRuntime, InferenceSession, InMemoryMetricsRecorder, @@ -35,6 +36,9 @@ UserInputs, UserInputSchema, ) +from flashdreams.runtime.inference_session import ( + InferenceSession as PipelineInferenceSession, +) pytestmark = pytest.mark.ci_cpu @@ -88,8 +92,8 @@ def test_inference_config_rejects_empty_model_id() -> None: ), (lambda: UserInputEvent(timestamp_s=0.0, event_type=" "), "event_type"), (lambda: StepRequest(step_index=-1), "step_index"), - (lambda: StepResult(step_index=-1), "step_index"), - (lambda: StepResult(step_index=0, frame_count=-1), "frame_count"), + (lambda: InferenceOutput(step_index=-1), "step_index"), + (lambda: InferenceOutput(step_index=0, frame_count=-1), "frame_count"), (lambda: RuntimeMetricSample(name=" ", value=1.0), "name"), (lambda: RuntimeMetricSample(name="sample", value=float("nan")), "finite"), (lambda: OutputArtifact(kind=" ", uri="artifact://demo"), "kind"), @@ -106,23 +110,55 @@ def test_runtime_metric_sample_rejects_bool_values() -> None: RuntimeMetricSample(name="sample", value=True) -def test_inference_input_schema_validates_initial_and_step_payloads() -> None: +def test_inference_input_schema_checks_both_payloads() -> None: schema = InferenceInputSchema( global_fields=( InputField(name="prompt"), InputField(name="global_conditioning_frame"), ), - step_fields=(InputField(name="camera_poses"),), + per_step_fields=(InputField(name="camera_poses"),), ) inputs = InferenceInput( - global_conditioning={"prompt": "drive", "global_conditioning_frame": object()} + global_conditioning={ + "prompt": "drive", + "global_conditioning_frame": object(), + }, + per_step_conditioning={"camera_poses": object()}, ) - schema.require_global(inputs) - assert schema.missing_step(inputs) == ("camera_poses",) + schema.check_global_payload(inputs) + schema.check_per_step_payload(inputs) + with pytest.raises(ValueError, match="prompt"): + schema.check_global_payload(InferenceInput()) with pytest.raises(ValueError, match="camera_poses"): - schema.require_step(inputs) + schema.check_per_step_payload(InferenceInput()) + + +def test_inference_input_and_schema_only_declare_two_fields() -> None: + assert tuple(InferenceInput.__dataclass_fields__) == ( + "global_conditioning", + "per_step_conditioning", + ) + assert tuple(InferenceInputSchema.__dataclass_fields__) == ( + "global_fields", + "per_step_fields", + ) + + +def test_inference_output_matches_step_result_fields() -> None: + assert tuple(field.name for field in fields(InferenceOutput)) == tuple( + field.name for field in fields(StepResult) + ) + + +def test_inference_session_uses_step_requests_instead_of_a_schema_property() -> None: + assert "inference_input_schema" not in PipelineInferenceSession.__dict__ + assert callable(PipelineInferenceSession.next_step_request) + + +def test_pipeline_inference_session_requires_reset_and_step_implementations() -> None: + assert PipelineInferenceSession.__abstractmethods__ == frozenset({"reset", "step"}) def test_user_inputs_filter_timestamped_event_windows() -> None: @@ -185,7 +221,8 @@ def test_user_input_schema_validates_required_snapshot_fields() -> None: def test_identity_input_mapping_leaves_inference_input_unchanged() -> None: mapping = IdentityInputMapping() inference_input = InferenceInput( - global_conditioning={"prompt": "fixed"}, step={"hdmap": object()} + global_conditioning={"prompt": "fixed"}, + per_step_conditioning={"hdmap": object()}, ) request = StepRequest(step_index=0) @@ -208,7 +245,7 @@ def test_identity_input_mapping_leaves_inference_input_unchanged() -> None: def test_null_output_target_counts_and_optionally_stores_results() -> None: target = NullOutputTarget(store_results=True) - result = StepResult(step_index=0, output=b"frame") + result = InferenceOutput(step_index=0, output=b"frame") assert target.closed with pytest.raises(RuntimeError, match="closed output target"): @@ -224,22 +261,22 @@ def test_null_output_target_counts_and_optionally_stores_results() -> None: assert target.output_count == 1 assert target.results == [result] with pytest.raises(RuntimeError, match="closed output target"): - target.write(StepResult(step_index=1)) + target.write(InferenceOutput(step_index=1)) def test_null_output_target_open_resets_per_run_state() -> None: target = NullOutputTarget(store_results=True) target.open() - target.write(StepResult(step_index=0, output=b"first")) + target.write(InferenceOutput(step_index=0, output=b"first")) target.close() target.open() assert target.output_count == 0 assert target.results == [] - target.write(StepResult(step_index=0, output=b"second")) + target.write(InferenceOutput(step_index=0, output=b"second")) assert target.output_count == 1 - assert target.results == [StepResult(step_index=0, output=b"second")] + assert target.results == [InferenceOutput(step_index=0, output=b"second")] def test_in_memory_metrics_recorder_uses_seconds_for_timing() -> None: @@ -390,11 +427,9 @@ def _drive_two_step_session( or TimeWindow(start_s=0.0, end_s=_SESSION_HORIZON_S), source_schema=source_schema, ), - # The global slot stays empty in steady state. A mapping that - # sees ``canonical_inputs.has_global_change`` fills it via - # ``with_global_update`` to request a mid-rollout swap. + # Per-step calls do not need to repeat global conditioning. inference_input=InferenceInput( - step={"chunk_index": request.step_index}, + per_step_conditioning={"chunk_index": request.step_index}, ), request=request, ) @@ -418,7 +453,7 @@ class _FakeAdapter: model_id = "fake-model" inference_input_schema = InferenceInputSchema( global_fields=(InputField(name="prompt"),), - step_fields=(InputField(name="chunk_index"),), + per_step_fields=(InputField(name="chunk_index"),), ) canonical_input_schema = CanonicalInputSchema() @@ -440,7 +475,7 @@ def __init__(self, *, inference_input_schema: InferenceInputSchema) -> None: self.closed = False def start_session(self, inputs: InferenceInput) -> InferenceSession: - self._inference_input_schema.require_global(inputs) + self._inference_input_schema.check_global_payload(inputs) return _FakeSession(inference_input_schema=self._inference_input_schema) def close(self) -> None: @@ -471,9 +506,9 @@ def next_step_request(self) -> StepRequest | None: ), ) - def step(self, inputs: InferenceInput) -> StepResult: - self._inference_input_schema.require_step(inputs) - result = StepResult( + def step(self, inputs: InferenceInput) -> InferenceOutput: + self._inference_input_schema.check_per_step_payload(inputs) + result = InferenceOutput( step_index=self.step_index, output=f"chunk-{self.step_index}", frame_count=3, diff --git a/flashdreams/tests/test_inference_session.py b/flashdreams/tests/test_inference_session.py new file mode 100644 index 000000000..261b53843 --- /dev/null +++ b/flashdreams/tests/test_inference_session.py @@ -0,0 +1,198 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for inference-session pipeline orchestration.""" + +from typing import Any, cast + +import pytest +import torch + +from flashdreams.infra.pipeline import ( + StreamInferencePipeline, + StreamInferencePipelineConfig, +) +from flashdreams.runtime.inference_session import ( + InferenceInput, + InferenceInputSchema, + InferenceOutput, + InferenceSession, + InferenceSessionConfig, +) +from flashdreams.runtime.inputs import TimeWindow +from flashdreams.runtime.types import StepRequest + +pytestmark = pytest.mark.ci_cpu + + +class FakeStreamInferencePipeline(StreamInferencePipeline[Any, Any, Any]): + """Record session orchestration without constructing model components.""" + + def __init__(self, output: Any) -> None: + torch.nn.Module.__init__(self) + self.output = output + self.cache = object() + self.reset_calls = 0 + self.initialize_cache_calls: list[dict[str, Any]] = [] + self.generate_calls: list[dict[str, Any]] = [] + self.finalize_calls: list[dict[str, Any]] = [] + + def reset(self) -> None: + self.reset_calls += 1 + + def initialize_cache(self, **global_conditioning: Any) -> object: + self.initialize_cache_calls.append(global_conditioning) + self.cache = object() + return self.cache + + def generate( + self, autoregressive_index: int, cache: object, input: Any = None + ) -> Any: + self.generate_calls.append( + { + "autoregressive_index": autoregressive_index, + "cache": cache, + "input": input, + } + ) + return self.output + + def finalize(self, autoregressive_index: int, cache: object) -> dict[str, float]: + self.finalize_calls.append( + {"autoregressive_index": autoregressive_index, "cache": cache} + ) + return {"total_ms": 4.0} + + +class _FakePipelineConfig: + def __init__(self, pipeline: FakeStreamInferencePipeline) -> None: + self.pipeline = pipeline + self.setup_calls = 0 + + def setup(self) -> FakeStreamInferencePipeline: + self.setup_calls += 1 + return self.pipeline + + +class _ConcreteInferenceSession(InferenceSession): + def next_step_request(self) -> StepRequest: + return StepRequest( + step_index=self._step_index, + inference_input_schema=InferenceInputSchema(), + user_input_window=TimeWindow( + start_s=float(self._step_index), + end_s=float(self._step_index + 1), + ), + metadata={"request": "fake"}, + ) + + def reset(self) -> None: + super().reset() + + def step(self, inference_input: InferenceInput) -> InferenceOutput: + return super().step(inference_input) + + +def _create_session( + pipeline: FakeStreamInferencePipeline, +) -> tuple[_ConcreteInferenceSession, _FakePipelineConfig]: + pipeline_config = _FakePipelineConfig(pipeline) + config = InferenceSessionConfig( + pipeline=cast(StreamInferencePipelineConfig, pipeline_config) + ) + return _ConcreteInferenceSession(config), pipeline_config + + +def test_constructor_initializes_the_configured_pipeline() -> None: + pipeline = FakeStreamInferencePipeline(output=object()) + + session, pipeline_config = _create_session(pipeline) + + assert session.pipeline is pipeline + assert pipeline_config.setup_calls == 1 + + +def test_reset_resets_the_pipeline_and_rollout_state() -> None: + pipeline = FakeStreamInferencePipeline(output=object()) + session, _ = _create_session(pipeline) + session.step( + InferenceInput( + global_conditioning={"prompt": "first"}, + per_step_conditioning={"control": 1}, + ) + ) + + session.reset() + result = session.step( + InferenceInput( + global_conditioning={"prompt": "second"}, + per_step_conditioning={"control": 2}, + ) + ) + + assert pipeline.reset_calls == 1 + assert pipeline.initialize_cache_calls == [ + {"prompt": "first"}, + {"prompt": "second"}, + ] + assert result.step_index == 0 + + +def test_step_converts_inference_input_and_wraps_pipeline_output() -> None: + generated = object() + pipeline = FakeStreamInferencePipeline(output=generated) + session, _ = _create_session(pipeline) + inference_input = InferenceInput( + global_conditioning={"prompt": "drive"}, + per_step_conditioning={"steering": 0.25}, + ) + + result = session.step(inference_input) + + assert pipeline.initialize_cache_calls == [{"prompt": "drive"}] + assert pipeline.generate_calls == [ + { + "autoregressive_index": 0, + "cache": pipeline.cache, + "input": {"steering": 0.25}, + } + ] + assert pipeline.finalize_calls == [ + {"autoregressive_index": 0, "cache": pipeline.cache} + ] + assert result == InferenceOutput( + step_index=0, + output=generated, + output_window=TimeWindow(start_s=0.0, end_s=1.0), + metadata={"request": "fake"}, + metrics={"total_ms": 4.0}, + ) + + +def test_step_reuses_the_pipeline_cache_and_advances_the_index() -> None: + pipeline = FakeStreamInferencePipeline(output=object()) + session, _ = _create_session(pipeline) + session.step(InferenceInput(global_conditioning={"prompt": "drive"})) + + result = session.step(InferenceInput(per_step_conditioning={"steering": -0.5})) + + assert pipeline.initialize_cache_calls == [{"prompt": "drive"}] + assert pipeline.generate_calls[-1] == { + "autoregressive_index": 1, + "cache": pipeline.cache, + "input": {"steering": -0.5}, + } + assert result.step_index == 1 + assert session.next_step_request().step_index == 2 diff --git a/flashdreams/tests/test_runtime_canonical.py b/flashdreams/tests/test_runtime_canonical.py index 1ad48d39e..6a6df6b80 100644 --- a/flashdreams/tests/test_runtime_canonical.py +++ b/flashdreams/tests/test_runtime_canonical.py @@ -64,7 +64,7 @@ consumes=(DRIVER_COMMAND,), produces_step=(InputField(name="steering"),), ) -STEERING_MODEL = InferenceInputSchema(step_fields=(InputField(name="steering"),)) +STEERING_MODEL = InferenceInputSchema(per_step_fields=(InputField(name="steering"),)) WINDOW = TimeWindow(start_s=0.0, end_s=1.0) NEXT_WINDOW = TimeWindow(start_s=1.0, end_s=2.0) @@ -217,13 +217,14 @@ def test_canonical_inputs_carry_live_control_only() -> None: def test_application_supplies_global_conditioning_directly() -> None: - """A prompt swap reaches the session without touching canonicalization.""" - update = InferenceInput(step={"steering": 0.0}).with_global_update( - {"prompt": "heavy rain"} + """Global conditioning reaches the session without canonicalization.""" + inputs = InferenceInput( + global_conditioning={"prompt": "heavy rain"}, + per_step_conditioning={"steering": 0.0}, ) - assert update.requests_global_update - assert update.global_conditioning["prompt"] == "heavy rain" + assert inputs.global_conditioning["prompt"] == "heavy rain" + assert inputs.per_step_conditioning["steering"] == 0.0 # --- device independence ------------------------------------------------ diff --git a/flashdreams/tests/test_runtime_input_mapping.py b/flashdreams/tests/test_runtime_input_mapping.py index 00cd9758f..3ba230f63 100644 --- a/flashdreams/tests/test_runtime_input_mapping.py +++ b/flashdreams/tests/test_runtime_input_mapping.py @@ -17,7 +17,6 @@ from flashdreams.runtime import ( DRIVER_COMMAND, - SESSION_START_ONLY, CanonicalInputs, CanonicalInputSchema, CanonicalModality, @@ -87,7 +86,7 @@ global_fields=( InputField(name="prompt", semantic_type="text", lifecycle="cache_init"), ), - step_fields=( + per_step_fields=( InputField(name="steering", lifecycle="step_input"), InputField(name="camera_delta", required=False, lifecycle="step_input"), ), @@ -193,42 +192,24 @@ def test_event_validation_rejects_undeclared_event_type() -> None: # --- model input schemas ------------------------------------------------ -def test_model_declares_required_and_optional_fields_per_phase() -> None: - required = DRIVING_MODEL.required_fields() - optional = DRIVING_MODEL.optional_fields() - - assert {(phase, f.name) for phase, f in required} == { - ("global", "prompt"), - ("step", "steering"), - } - assert {(phase, f.name) for phase, f in optional} == {("step", "camera_delta")} - - -def test_required_fields_can_be_filtered_by_phase() -> None: - step_only = DRIVING_MODEL.required_fields("step") - - assert [f.name for _, f in step_only] == ["steering"] - - -def test_field_lookup_is_phase_scoped() -> None: - assert DRIVING_MODEL.field_for(name="prompt", phase="global") is not None - assert DRIVING_MODEL.field_for(name="prompt", phase="step") is None - - -def test_invalid_phase_is_rejected() -> None: - bad_phase: Any = "final" - - with pytest.raises(ValueError, match="phase must be"): - DRIVING_MODEL.fields_for(bad_phase) +def test_model_declares_fields_for_each_payload() -> None: + assert [field.name for field in DRIVING_MODEL.global_fields] == ["prompt"] + assert [field.name for field in DRIVING_MODEL.per_step_fields] == [ + "steering", + "camera_delta", + ] + assert DRIVING_MODEL.per_step_fields[0].required + assert not DRIVING_MODEL.per_step_fields[1].required -def test_inference_input_expose_payload_per_phase() -> None: +def test_inference_input_exposes_both_conditioning_payloads() -> None: inputs = InferenceInput( - global_conditioning={"prompt": "drive"}, step={"steering": 0.25} + global_conditioning={"prompt": "drive"}, + per_step_conditioning={"steering": 0.25}, ) - assert inputs.for_phase("global")["prompt"] == "drive" - assert inputs.for_phase("step")["steering"] == 0.25 + assert inputs.global_conditioning["prompt"] == "drive" + assert inputs.per_step_conditioning["steering"] == 0.25 def test_lifecycle_and_update_policy_are_queryable_metadata() -> None: @@ -434,7 +415,7 @@ def test_combine_rejects_non_schema_entries() -> None: def test_undeclared_inference_input_catches_schema_drift() -> None: produced = InferenceInput( - global_conditioning={"prompt": "drive"}, step={"steering": 0.0} + global_conditioning={"prompt": "drive"}, per_step_conditioning={"steering": 0.0} ) undeclared = undeclared_inference_inputs(produced, PROMPT_MAPPING) @@ -445,7 +426,7 @@ def test_undeclared_inference_input_catches_schema_drift() -> None: def test_declared_outputs_report_no_drift() -> None: combined = combine_mapping_schemas((PROMPT_MAPPING, STEERING_MAPPING)) produced = InferenceInput( - global_conditioning={"prompt": "drive"}, step={"steering": 0.0} + global_conditioning={"prompt": "drive"}, per_step_conditioning={"steering": 0.0} ) assert undeclared_inference_inputs(produced, combined) == () @@ -458,7 +439,7 @@ def test_identity_mapping_needs_no_declared_surface() -> None: """Fixed-input runs stay possible without any schema declaration.""" mapping = IdentityInputMapping() fixed = InferenceInput( - global_conditioning={"prompt": "fixed"}, step={"steering": 0.0} + global_conditioning={"prompt": "fixed"}, per_step_conditioning={"steering": 0.0} ) mapped = mapping.map_step_inputs( @@ -467,7 +448,7 @@ def test_identity_mapping_needs_no_declared_surface() -> None: request=StepRequest(step_index=0), ) - assert mapped.step["steering"] == 0.0 + assert mapped.per_step_conditioning["steering"] == 0.0 def test_empty_mapping_set_cannot_satisfy_a_required_field() -> None: @@ -489,85 +470,3 @@ def test_model_with_no_requirements_is_always_drivable() -> None: ) assert compatibility.can_drive - - -# --- global conditioning updates vs reset ------------------------------- - - -def test_empty_global_slot_requests_no_update() -> None: - steady_state = InferenceInput(step={"steering": 0.25}) - - assert not steady_state.requests_global_update - - -def test_non_empty_global_slot_mid_rollout_is_an_update_request() -> None: - """Changing weather mid-run updates conditioning; it is not a reset.""" - updated = InferenceInput(step={"steering": 0.0}).with_global_update( - {"prompt": "heavy rain"} - ) - - assert updated.requests_global_update - assert updated.global_conditioning["prompt"] == "heavy rain" - assert updated.step["steering"] == 0.0 - - -def test_with_step_carries_the_global_slot_through() -> None: - started = InferenceInput(global_conditioning={"prompt": "drive"}) - - stepped = started.with_step({"steering": 0.5}) - - assert stepped.global_conditioning["prompt"] == "drive" - - -def test_without_global_update_clears_the_request() -> None: - started = InferenceInput( - global_conditioning={"prompt": "drive"}, step={"steering": 0.5} - ) - - steady_state = started.without_global_update() - - assert not steady_state.requests_global_update - assert steady_state.step["steering"] == 0.5 - - -def test_model_can_declare_conditioning_it_cannot_swap_mid_rollout() -> None: - schema = InferenceInputSchema( - global_fields=( - InputField(name="prompt", update_policy="step_boundary"), - InputField(name="scene_id", update_policy=SESSION_START_ONLY), - ) - ) - update = InferenceInput( - global_conditioning={"prompt": "heavy rain", "scene_id": "town_02"} - ) - - assert schema.unsupported_global_updates(update) == ("scene_id",) - - -def test_permissive_when_no_update_policy_is_declared() -> None: - schema = InferenceInputSchema(global_fields=(InputField(name="prompt"),)) - update = InferenceInput(global_conditioning={"prompt": "heavy rain"}) - - assert schema.unsupported_global_updates(update) == () - - -def test_undeclared_global_values_are_left_to_the_adapter() -> None: - schema = InferenceInputSchema(global_fields=(InputField(name="prompt"),)) - update = InferenceInput(global_conditioning={"mystery": 1}) - - assert schema.unsupported_global_updates(update) == () - - -def test_steady_state_steps_do_not_request_a_global_update() -> None: - """Carrying session-start conditioning forward would look like an update.""" - started = InferenceInput(global_conditioning={"prompt": "drive"}) - - steady_state = InferenceInput(step={"chunk_index": 1}) - - assert started.requests_global_update - assert not steady_state.requests_global_update - assert ( - not started.with_step({"chunk_index": 1}) - .without_global_update() - .requests_global_update - ) From de51140596dcaee5c131004fe8291fa5f7283711 Mon Sep 17 00:00:00 2001 From: Aidan Foster Date: Wed, 5 Aug 2026 18:35:46 -0700 Subject: [PATCH 11/30] Clarify global conditioning input semantics Signed-off-by: Aidan Foster --- docs/inference_runtime_api_design.md | 65 ++++--- ...inference_runtime_inputs_implementation.md | 97 +++++----- ...ence_runtime_supported_inputs_inventory.md | 177 +++++++++--------- flashdreams/flashdreams/runtime/__init__.py | 2 - flashdreams/flashdreams/runtime/inputs.py | 114 +++-------- flashdreams/flashdreams/runtime/interfaces.py | 4 +- flashdreams/flashdreams/runtime/mapping.py | 33 ++-- flashdreams/flashdreams/runtime/types.py | 9 +- .../tests/test_inference_runtime_api.py | 19 +- flashdreams/tests/test_runtime_canonical.py | 11 +- .../tests/test_runtime_input_mapping.py | 167 +++++------------ 11 files changed, 293 insertions(+), 405 deletions(-) diff --git a/docs/inference_runtime_api_design.md b/docs/inference_runtime_api_design.md index f70fbd890..b6314a205 100644 --- a/docs/inference_runtime_api_design.md +++ b/docs/inference_runtime_api_design.md @@ -119,7 +119,7 @@ InferenceRuntime | v InferenceSession - one rollout/stream: prompt/initial inputs, cache/state, current step, reset + one rollout/stream: global conditioning, cache/state, current step, reset keeps per-run state from leaking across prompts, clients, or benchmark repeats | v @@ -187,11 +187,11 @@ local model implementation, a Dynamo-like backend, or a hosted service. | --- | --- | --- | | Model/preset registry | Lists what can run: model/preset slugs, scenarios, capabilities, resource hints, and supported output modes. | Must remain cheap to query and must not load checkpoints. | | App / integration / benchmark / transport | Owns the user-facing mode: CLI, native integration, WebRTC, benchmark, hosted request, or replay. | Supplies run setup, user inputs, model inputs, and output target selection. | -| User input library | Normalizes live or replayed controls into FlashDreams-supported user input events/windows. | Shared primitives for keyboard, reset, prompt/image updates, traces, and future scalar controls. | -| Input mapping | Converts user/app inputs plus initial model inputs into the model-specific inputs needed by the session. | A model adapter may provide a default mapping; runtimes, applications, benchmarks, and replay tools may override it without changing the model step. | +| User input library | Normalizes live or replayed controls into FlashDreams-supported user input events/windows. | Shared primitives for keyboard, reset, prompt/image selection, traces, and future scalar controls. | +| Input mapping | Converts user/app inputs plus global conditioning into the model-specific inputs needed by the session. | A model adapter may provide a default mapping; runtimes, applications, benchmarks, and replay tools may override it without changing the model step. | | ModelRunner / standard loop | Orchestrates one run from setup through runtime initialization, stepping, output, metrics, and teardown. | Shared orchestration layer used by CLIs, benchmarks, MP4 runs, and simple realtime flows. | | InferenceRuntime | Owns heavyweight lifecycle: distributed init, model construction, checkpoint loading, compile/capture, warmup, hosted-service connection, and teardown. | Long-lived reusable runtime created from `InferenceConfig`; lets FlashDreams load/warm once and create sessions sequentially unless the backend supports concurrency. | -| InferenceSession | Owns one rollout or stream: initial inputs, cache state, current step, reset behavior, step requirements, and step execution. | Per-rollout interface consumed by the standard loop; keeps state isolated across prompts, browser clients, replay scenarios, or benchmark repeats. | +| InferenceSession | Owns one rollout or stream: global conditioning, cache state, current step, reset behavior, step requirements, and step execution. | Per-rollout interface consumed by the standard loop; keeps state isolated across prompts, browser clients, replay scenarios, or benchmark repeats. | | Model implementation / inference pipeline | Implements encode, model step, decode, cache updates, and model-specific optimizations. | FlashDreams wraps this boundary; it should not replace every model implementation. | | Output target | Consumes generated outputs and handles presentation or persistence. | Separate from model execution so the same session can feed WebRTC, MP4, benchmark, or headless output. | | Metrics, artifacts, and profiling | Records timings, memory, quality data, logs, reports, traces, and optional NVTX ranges. | Shared observation layer for local runs, benchmarks, CI smoke, and hosted runs. | @@ -298,8 +298,7 @@ uses: - keyboard keydown/keyup events; - reset requests; -- prompt update requests; -- image update requests; +- prompt or image selection/update events; - future scalar controls such as throttle, brake, steer, or camera axes once an integration needs them. @@ -335,11 +334,11 @@ Examples of global conditioning include prompt, negative prompt, conditioning frame, input video, scene id, HD map asset, camera calibration, initial camera pose, seed, or model-specific fields. -Global conditioning is normally supplied when a session starts, but a non-empty -global slot on a mid-rollout input is an update request rather than a reset; -resetting rollout state is a separate `InferenceSession.reset()` call. Whether a -given value can be swapped mid-rollout is declared per field by -`InputField.update_policy`. +Global conditioning establishes session-global model state when a session +starts or resets. During an active rollout, a non-empty global-conditioning +payload passed to `InferenceSession.step()` asks the session to update that +state when the model supports it. Reset remains a separate explicit session +method. Examples of per-step conditioning include frame timestamps, pose segments, camera trajectory chunks, rendered HD map frames, conditioning video windows, @@ -349,16 +348,17 @@ Inference input payloads should use semantic names, not only modality names. For example, a first frame and an HD map frame should be distinct inputs even if both are image-like values. -Model input metadata may also include a lightweight lifecycle label, such as -runtime config, cache initialization, rollout binding, per-step input, or -session update. This should remain query metadata, not model-specific tensor -validation. +Model input names, payload kinds, semantic-type hints, and schema metadata +should be open-ended. Supported integrations such as SANA-WM, LingBot, +Omnidreams, and future external adapters may need different semantic fields. +Adding a new model should usually mean adding adapter-owned schema declarations +and mappings, not changing a central FlashDreams enum. -Model input names, payload kinds, lifecycle labels, and schema metadata should -be open-ended. Supported integrations such as SANA-WM, LingBot, Omnidreams, and -future external adapters may need different semantic fields. Adding a new model -should usually mean adding adapter-owned schema declarations and mappings, not -changing a central FlashDreams enum. +Consumption cadence is a separate hint from input scope. A field may be +provided through global conditioning because it is session-global state, while +the adapter consumes or slices it during every step. That can be recorded as +`frequency_consumed` metadata without changing whether the field belongs in +`global_conditioning_fields` or `step_fields`. For interactive runs, most `InferenceInput` values will be global conditioning plus per-step inputs produced by input mapping. For MP4 generation and benchmarking, the API @@ -374,7 +374,7 @@ These schemas are not meant to be a rich type system or a replacement for model-specific validation. They should be just enough to answer: - what can this app, transport, trace, or benchmark source provide? -- what does this model require before startup and at each step? +- what does this model require before session start and at each step? - can this event source drive this model with the selected mapping? The purpose is to fail early before expensive model initialization, produce @@ -386,7 +386,7 @@ coordinate frame, units, rough shape summary, accepted file suffixes, schema URI, model family, or source/transport details. Metadata should help humans and adapter selection code, but compatibility should still be based on the declared event capabilities, semantic model fields, payload representation hints, and -lifecycle labels. +schema phases. Consumption-cadence hints are descriptive and adapter-owned. For simple CLI text-to-video or image-to-video runs, `UserInputSchema` can be trivial or omitted because there may be no live controls. `InferenceInputSchema` is @@ -478,6 +478,14 @@ declare user inputs, declare model inputs, and provide a default mapping, while the runtime owns transport, event validation, timestamping, input queue/window selection, output delivery, and optional overrides. +`StepRequest` and `StepResult` are per-step runtime messages, not declarative +schemas. `InferenceSession.next_step_request()` returns a `StepRequest` to say +which step is next, which user-input time window to map, and whether this step +has any narrower `InferenceInputSchema` than the session default. The runner or +application then builds an `InferenceInput` and calls `InferenceSession.step()`, +which returns a `StepResult` carrying the generated output, output timing, +metrics, and step metadata. + Examples: - T2V mapping validates a prompt and creates no per-step control inputs. @@ -505,7 +513,7 @@ A run should: profiling, and optional scenario setup. 3. Validate that the event source and mapping can drive the selected model. 4. Initialize the runtime. -5. Start a session from initial model inputs. +5. Start a session from global conditioning inputs. 6. For each step, ask the session what it needs, gather live or fixed inputs, build step model inputs, run the session step, route outputs, and record metrics. @@ -553,8 +561,9 @@ generation, benchmarks, regression testing, and autotune. Two replay levels should be supported: -- user-event replay: records timestamped key events, prompt updates, image - updates, reset events, and timing, then runs normal input mapping; +- user-event replay: records timestamped key events, prompt or image + selection/update events, reset events, and timing, then runs normal input + mapping; - model-input replay: records or defines already-mapped per-step model inputs for stricter model-level regression tests. @@ -667,8 +676,10 @@ registry, standard loop, concrete output modes, or model migrations: - The model-specific integration boundary is named `ModelAdapter`. - Heavyweight lifecycle is split into `InferenceRuntime` and `InferenceSession`. -- Step data carriers are named `StepRequest` and `StepResult`; a session returns - `None` from `next_step_request()` when the rollout is complete. +- Step data carriers are named `StepRequest` and `StepResult`. They are runtime + messages around one call to `InferenceSession.step()`, not schema + declarations; a session returns `None` from `next_step_request()` when the + rollout is complete. - Raw inputs use `UserInputs`, canonicalized inputs use `CanonicalInputs`, and model-facing inputs use `InferenceInput`. Both remain lightweight payload envelopes with shallow read-only mappings. diff --git a/docs/inference_runtime_inputs_implementation.md b/docs/inference_runtime_inputs_implementation.md index 8d485768a..763b9810c 100644 --- a/docs/inference_runtime_inputs_implementation.md +++ b/docs/inference_runtime_inputs_implementation.md @@ -46,70 +46,60 @@ that touches no application, mapping, or model code. This path covers **live user control only**. Global conditioning is application-owned data and reaches `InferenceInput` directly, without passing -through canonicalization or a device converter. An application that wants a -trigger key to swap the prompt reads that as ordinary canonical control input -and updates its own global conditioning in response. +through canonicalization or a device converter. Session start/reset establishes +that global conditioning. During an active rollout, a non-empty +`global_conditioning` payload passed to `step()` requests an update of the +session-global state when the model supports it. ## Conditioning Slots -Both the canonical and encoded layers split into two slots, and the split means -the same thing at each: +The encoded layer splits model-facing inputs into two slots: -- **global conditioning** — conditions the whole rollout: prompt, conditioning - frame, scene. Normally supplied at session start. +- **global conditioning** — session-global model state: prompt, conditioning + frame, scene. - **per-step conditioning** — needed to generate the next chunk or frame: steering, HD map frames, camera trajectory. -`InputPhase` is `Literal["global", "step"]`. The axis names *which slot*, not -*when the value may arrive* — see the next section. +`InputPhase` is `Literal["global_conditioning", "step"]`. The phase names the +`InferenceInput` slot the caller provides. -## Global Conditioning Updates Are Not Resets +`InputField.frequency_consumed` is independent query metadata. It says how the +adapter consumes a field internally, such as `once` or `per_step`; it does not +decide whether the caller provides the field through `global_conditioning` or +`step`. -A non-empty global slot on a mid-rollout `InferenceInput` is an **update -request**. The session should apply it when the model supports doing so. -Resetting rollout state is a separate, explicit `InferenceSession.reset()` call. -The motivating case is changing prompt and conditioning frame mid-run to change -the weather in an Omnidreams rollout. +## Global Conditioning Is Session-Global State -```python -from flashdreams.runtime import InferenceInput - -steady_state = InferenceInput(step={"steering": 0.25}) -assert not steady_state.requests_global_update - -changed_weather = steady_state.with_global_update({"prompt": "heavy rain"}) -assert changed_weather.requests_global_update -``` - -Because `with_step()` carries the global slot through unchanged, use -`without_global_update()` for the steady-state case; otherwise every step looks -like an update request. - -Whether a value can actually be swapped mid-rollout is declared per field: +`InferenceInput.global_conditioning` carries session-scoped inputs. A runtime +passes those values to `InferenceRuntime.start_session()` or to +`InferenceSession.reset()` when the backend supports resetting a rollout. +During an active rollout, passing a non-empty `global_conditioning` payload to +`InferenceSession.step()` asks the session to update that session-global state. +The model/session owns whether that update is supported. ```python -from flashdreams.runtime import SESSION_START_ONLY, InferenceInputSchema, InputField +from flashdreams.runtime import InferenceInput, InferenceInputSchema, InputField schema = InferenceInputSchema( - global_fields=( - InputField(name="prompt", update_policy="step_boundary"), - InputField(name="scene_id", update_policy=SESSION_START_ONLY), + global_conditioning_fields=( + InputField(name="prompt"), + InputField(name="scene_id"), ) ) -schema.unsupported_global_updates( - InferenceInput(global_conditioning={"prompt": "heavy rain", "scene_id": "town_02"}) +schema.require_global_conditioning( + InferenceInput(global_conditioning={"prompt": "drive", "scene_id": "town_02"}) ) -# ("scene_id",) -``` -`SESSION_START_ONLY` is the one reserved `update_policy` token. Everything else -in that vocabulary, and all of `lifecycle`, is open and adapter-owned; this layer -only carries it as queryable metadata. +step_with_prompt_update = InferenceInput( + global_conditioning={"prompt": "heavy rain"}, + step={"steering": 0.0}, +) +``` -Steady-state steps must leave the global slot empty; otherwise every step reads -as an update request. Converters emit every window, because live control is -level-triggered: a key held across a step emits no events but still means full -throttle. +Per-step conditioning is different: those values are supplied through +`InferenceInput.step` for each generated chunk or frame. Converters still emit +every window, because live control is level-triggered: a key held across a step +emits no events but still means full throttle. ## Raw Inputs @@ -192,8 +182,9 @@ device does not resume from stale state. ## Mapping And Compatibility `InputMapping` is the canonical-to-encoded boundary. `InputMappingSchema` is its -declarative surface: `consumes` names canonical modalities; `produces_global` -and `produces_step` name the `InferenceInput` fields it can build. +declarative surface: `consumes` names canonical modalities; +`produces_global_conditioning` and `produces_step` name the `InferenceInput` +fields it can build. `InputMapping.validate()` raises, which fails a run late and cannot say *which* optional model input a source would enable or *which* missing modality makes a @@ -230,6 +221,13 @@ registered later, with no change to the mapping or the model schema. `undeclared_inference_inputs()` reports payload keys a mapping produced but did not declare, which keeps hand-written schemas honest as the code drifts. +`StepRequest` and `StepResult` sit around a single `InferenceSession.step()` +call. They are not schema declarations. A session returns `StepRequest` from +`next_step_request()` to name the next step, optionally provide a narrower +`InferenceInputSchema`, and request a `TimeWindow` of user inputs. The runner +then builds `InferenceInput` and calls `step()`, which returns a `StepResult` +for the output target and metrics recorder. + ## What This Does Not Validate The schemas intentionally avoid becoming a rich type system. These remain the @@ -237,8 +235,9 @@ responsibility of the model adapter, runtime, session, or mapping: - tensor shape and dtype, image decode details; - camera coordinate systems, pose and timestamp units; -- prompt-embedding swap mechanics; -- whether a model can actually apply a declared update policy at runtime; +- prompt-embedding mechanics; +- whether a model can actually apply a requested global-conditioning update; +- enforcing consumption-cadence metadata; - deep validation of scene, HD map, or actor-state data. The layer answers "can this source plausibly drive this model through this diff --git a/docs/inference_runtime_supported_inputs_inventory.md b/docs/inference_runtime_supported_inputs_inventory.md index ebe9d853a..d56cf1651 100644 --- a/docs/inference_runtime_supported_inputs_inventory.md +++ b/docs/inference_runtime_supported_inputs_inventory.md @@ -17,44 +17,44 @@ FastVideo Causal WAN 2.2 T2V, and Cosmos Predict2 T2V: - Source/app inputs: prompt text or prompt text file, pixel height/width, and fps or block count depending on runner. -- Model-facing initial inputs: prompt text plus latent/output height and width +- Model-facing global conditioning: prompt text plus latent/output height and width derived from run config. -- Model-facing step/update inputs: no live controls; AR loop steps with fixed +- Model-facing per-step inputs: no live controls; AR loop steps with fixed session state. WAN 2.1 I2V, Causal-Forcing I2V, and Cosmos Predict2 I2V: - Source/app inputs: prompt text or prompt file, first-frame image path or URL, and pixel height/width. -- Model-facing initial inputs: prompt text and decoded first-frame tensor. -- Model-facing step/update inputs: no live controls. +- Model-facing global conditioning: prompt text and decoded first-frame tensor. +- Model-facing per-step inputs: no live controls. FlashVSR: - Source/app inputs: input video path or URL, chunk size, crop region, sparse ratio, and optional output FPS. -- Model-facing initial inputs: no explicit prompt at runner time; the prompt +- Model-facing global conditioning: no explicit prompt at runner time; the prompt tensor is configured in the pipeline. Input video dimensions affect per-video runtime/pipeline setup. -- Model-facing step/update inputs: video chunks passed to +- Model-facing per-step inputs: video chunks passed to `pipeline.generate(input=clip)`. LingBot CLI: - Source/app inputs: prompt or prompt path, first-frame image path, pose path, intrinsics path, total blocks, dimensions, and fps. -- Model-facing initial inputs: prompt text and first-frame tensor. -- Model-facing step/update inputs: `CamCtrlInput` with intrinsics, camera poses, +- Model-facing global conditioning: prompt text and first-frame tensor. +- Model-facing per-step inputs: `CamCtrlInput` with intrinsics, camera poses, and world scale. LingBot WebRTC: - Source/app inputs: session prompt, uploaded/remote/default first-frame image, keyboard events, reset requests, text-event catalog, and trigger events. -- Model-facing initial inputs: prompt text, first-frame tensor, base text +- Model-facing global conditioning: prompt text, first-frame tensor, base text embeddings, precomputed text-event embeddings, base intrinsics, and world scale. -- Model-facing step/update inputs: keyboard event windows become pose segments +- Model-facing per-step inputs: keyboard event windows become pose segments and camera trajectories. Text-event triggers can replace rollout text embeddings when the model supports it. @@ -63,9 +63,9 @@ HY-WorldPlay WAN I2V: - Source/app inputs: prompt or prompt path, first-frame image path or example image, pose string or pose JSON, memory-selection settings, dimensions, fps, and seed. -- Model-facing initial inputs: prompt text and first-frame tensor for cache - initialization. -- Model-facing step/update inputs: pose data is bound for the rollout as action +- Model-facing global conditioning: prompt text and first-frame tensor for + session setup. +- Model-facing per-step inputs: pose data is bound for the rollout as action labels, view matrices, intrinsics, and memory-selection state before AR steps. Omnidreams CLI: @@ -73,18 +73,18 @@ Omnidreams CLI: - Source/app inputs: shared prompt or per-camera prompts, HDMap video paths, first-frame image/video paths, camera names, example-data UUID, and optional embedding save/load paths. -- Model-facing initial inputs: prompt list, first-frame tensor, view names; or +- Model-facing global conditioning: prompt list, first-frame tensor, view names; or precomputed text/image/negative-text embeddings. -- Model-facing step/update inputs: HDMap video chunks passed per AR step. +- Model-facing per-step inputs: HDMap video chunks passed per AR step. Omnidreams WebRTC: - Source/app inputs: scene directory or scene UUID, scene variant, camera name, prompt/first-frame assets resolved from the scene, keyboard events, reset requests, and optional postprocess preset. -- Model-facing initial inputs: scene data, renderer, first-frame tensor, prompt, +- Model-facing global conditioning: scene data, renderer, first-frame tensor, prompt, camera calibration/extrinsics, initial ego pose, and initial timestamp. -- Model-facing step/update inputs: keyboard event windows become ego poses, +- Model-facing per-step inputs: keyboard event windows become ego poses, camera poses per view, and frame timestamps. The wrapper renders HDMap conditioning internally for each step. @@ -92,26 +92,26 @@ Omnidreams interactive drive: - Source/app inputs: scene bundle, keyboard events or wheel/controller samples, view-mode/reset/scene-exit controls, and vehicle/chunk config. -- Model-facing initial inputs: scene bundle, selected camera, prompt, initial +- Model-facing global conditioning: scene bundle, selected camera, prompt, initial RGB frame, initial rig pose, and initial timestamp. -- Model-facing step/update inputs: `DriverCommand` samples become trajectory +- Model-facing per-step inputs: `DriverCommand` samples become trajectory chunks, rendered frames, and world-model conditioning. Template recipe: - Source/app inputs: synthetic runner config: batch size, height, width, context tokens, AR steps, and seed. -- Model-facing initial inputs: synthetic transformer context, optional negative +- Model-facing global conditioning: synthetic transformer context, optional negative context, height, and width. -- Model-facing step/update inputs: optional synthetic control tensor. +- Model-facing per-step inputs: optional synthetic control tensor. WAN 2.2 TI2V pipeline config: - Source/app inputs: downstream runners use this rather than a standalone runner in this tree. -- Model-facing initial inputs: prompt text and first-frame image for TI2V-style - cache initialization. -- Model-facing step/update inputs: downstream runners decide controls; +- Model-facing global conditioning: prompt text and first-frame image for + TI2V-style session setup. +- Model-facing per-step inputs: downstream runners decide controls; HY-WorldPlay currently binds action/camera state around it. SANA-WM bidirectional and streaming on `main`: @@ -120,10 +120,10 @@ SANA-WM bidirectional and streaming on `main`: negative prompt, camera trajectory path or action DSL, optional intrinsics path or derived intrinsics, frame count, fps, Stage-1 sampling knobs, seed, precision/refiner options, and streaming chunk/block settings. -- Model-facing initial inputs: decoder context such as prompt, fps, +- Model-facing global conditioning: decoder context such as prompt, fps, `save_stage1`, refiner seed, sink size, and streaming refiner window/block parameters. -- Model-facing step/update inputs: bidirectional passes one +- Model-facing per-step inputs: bidirectional passes one `SanaWMI2VConditioningRequest` into the single generation step. Streaming passes one `SanaWMStreamingI2VConditioningRequest` repeatedly; the conditioning encoder caches rollout-wide prompt, first-frame, camera, latent @@ -134,7 +134,7 @@ SANA-WM bidirectional and streaming on `main`: ## API Implications -The inventory changes the T2/T3 shape in four concrete ways. +The inventory changes the T2/T3 shape in five concrete ways. First, a selected mapping is often a composition. A LingBot-like run needs prompt mapping, first-frame mapping, and keyboard-to-camera mapping. Omnidreams may add @@ -142,25 +142,24 @@ scene selection, camera selection, and HDMap mapping. The implementation should support checking a set of mapping schemas as one compatibility surface, while still allowing a single mapping object when that is simpler. -Second, `InferenceInputSchema` needs a lightweight lifecycle tag in addition to the -`initial` versus `step` phase. The phase answers when the value is needed at the -standard-loop level. The lifecycle tag distinguishes where the model adapter -uses it, such as: - -- `runtime_config`: values that affect setup before model/runtime construction, - such as FlashVSR input-video dimensions; -- `cache_init`: values passed when initializing or resetting a rollout cache, - such as prompts, first frames, view names, and precomputed embeddings; -- `rollout_binding`: values bound after cache initialization but before AR - steps, such as HY-WorldPlay action labels, camera tensors, and memory state; -- `step_input`: values consumed for one generated chunk, such as HDMap frames, - camera trajectories, driver commands, video chunks, and timestamps; -- `session_update`: values that can update an active session when supported, - such as LingBot text-event embedding swaps. - -The lifecycle tag is metadata, not a new deep type system. If both a model field -and mapping output specify lifecycle, compatibility should require them to agree. -If either side omits it, matching stays permissive for simple schemas. +Second, `InferenceInputSchema` needs explicit global-conditioning and per-step +schema slots. `global_conditioning_fields` describe the session-global state +carried through `InferenceInput.global_conditioning`. Start/reset establishes +that state; a non-empty global-conditioning payload in a step context asks the +session to update it when the model supports that. `step_fields` arrive through +`InferenceInput.step` for one generated chunk or frame window. + +This distinction matters for rollout-wide values such as full camera +trajectories, action labels, intrinsics sequences, and memory-selection config. +Those can be supplied in the global-conditioning slot, even if the adapter later +slices them internally while executing steps. If the caller must supply a fresh +value for every generated chunk, that value belongs in `step_fields`. + +`frequency_consumed` is a separate optional hint for how the adapter uses a +field internally, such as `once` or `per_step`. It does not decide where the +caller provides the value. A field can live in `global_conditioning_fields` and +still have `frequency_consumed="per_step"` when the adapter slices or reads +rollout-wide state during step execution. Third, `semantic_type` should be treated as a representation hint rather than a universal semantic type. For example, `prompt` may arrive as inline text or a @@ -173,7 +172,7 @@ Fourth, schema objects need open-ended metadata for future adapters. This lets a SANA-WM-like adapter advertise that `camera_trajectory_c2w` uses an `[F,4,4]` OpenCV camera-to-world sequence, or lets another model advertise a schema URI, units, coordinate frame, accepted file suffixes, cardinality hints, -or update notes. Metadata should remain query information and should not become +or adapter notes. Metadata should remain query information and should not become the compatibility type system. Fifth, `UserInputSchema` describes raw source capabilities, `CanonicalModality` @@ -181,7 +180,7 @@ describes what an application consumes, and mapping schemas describe derived model-facing semantics. A browser may provide `key_down`, `key_up`, `prompt_set`, and `initial_frame_set` events. Those become canonical modalities such as `driver_command` or `conditioning_prompt`; whether they can then drive -`steering`, `camera_trajectory`, or text embedding updates depends on the +`steering`, `camera_trajectory`, or text embeddings depends on the selected mapping and model schema. ## Implemented T2/T3 Shape @@ -189,23 +188,24 @@ selected mapping and model schema. The implementation that came out of this inventory is: 1. Keep `UserInputEvent` and `UserInputs` as the raw event API, sliced by a - half-open `TimeWindow`. Static startup values remain timestamp-zero events. + half-open `TimeWindow`. Static session-start values remain timestamp-zero + events. 2. Keep `UserInputSchema` lightweight and source-facing. `event_types` declares that an event type exists; `UserInputCapability` additionally pins the payload fields it carries. 3. Add a canonical layer between raw and encoded. `CanonicalModality` names a - device-independent input and its conditioning phase; `InputCanonicalizer` + device-independent input and its payload fields; `InputCanonicalizer` registers per-device converters and produces `CanonicalInputs`. Applications and mappings consume canonical inputs and never read raw device events. 4. Split `InferenceInput` (formerly `ModelInputs`) into `global_conditioning` - and `step`. A non-empty global slot mid-rollout is an update request, not a - reset; `InputField.update_policy` declares whether the model can apply it. -5. Extend `InputField` with `update_policy`, `lifecycle`, and `metadata` so - models can distinguish runtime config, cache initialization, rollout binding, - per-step inputs, and supported active-session updates. + and `step`. Global conditioning is session-global state; `step` is the + payload for one generated chunk or frame window. +5. Keep `InputField.semantic_type` and `metadata` as lightweight query hints, + while leaving tensor shape, cadence, and model-specific validation to + adapters and sessions. 6. Keep `InputMappingSchema` as the canonical-to-encoded boundary, with mapping-set compatibility helpers for composed mappings. -7. Keep input names, semantic types, lifecycle labels, and metadata open-ended. +7. Keep input names, semantic types, and metadata open-ended. Adding a new model should usually mean adding adapter-owned schema declarations and mappings, not changing the core input dataclasses. 8. Leave deep validation to model adapters, sessions, and mappings. The schema @@ -227,11 +227,9 @@ Use these conventions when adding future model schemas: instead of `array`, or `hdmap_frames` instead of `image`. - Use `semantic_type` for a coarse representation hint, such as `path`, `decoded_tensor`, `c2w_sequence`, `intrinsics_vec4_sequence`, or `embedding`. -- Use `lifecycle` to say where the adapter consumes the value, such as - `runtime_config`, `cache_init`, `rollout_binding`, `step_input`, or - `session_update`. -- Use `update_policy` to say when a value may change. `SESSION_START_ONLY` is - the one reserved token, meaning the value cannot be swapped mid-rollout. +- Use `frequency_consumed` for adapter-consumption cadence, such as `once` or + `per_step`; keep it independent from whether the field is declared under + `global_conditioning_fields` or `step_fields`. - Use `metadata` for query hints: units, coordinate frame, shape summary, accepted suffixes, schema URI, model family, value ranges, or cardinality. - Keep deep validation in the adapter/mapping. The lightweight schemas answer @@ -247,18 +245,13 @@ can describe the supported input surfaces. All use ```python lingbot_model = InferenceInputSchema( description="lingbot-world", - global_fields=( - InputField(name="prompt", lifecycle="cache_init"), - InputField(name="global_conditioning_frame", lifecycle="cache_init"), + global_conditioning_fields=( + InputField(name="prompt", frequency_consumed="once"), + InputField(name="global_conditioning_frame", frequency_consumed="once"), + InputField(name="text_embeddings", required=False, frequency_consumed="once"), ), step_fields=( - InputField(name="camera_trajectory", lifecycle="step_input"), - InputField( - name="text_embeddings", - required=False, - update_policy="step_boundary", - lifecycle="session_update", - ), + InputField(name="camera_trajectory", frequency_consumed="per_step"), ), ) ``` @@ -266,27 +259,29 @@ lingbot_model = InferenceInputSchema( ```python omnidreams_model = InferenceInputSchema( description="omnidreams", - global_fields=( - InputField(name="prompts", lifecycle="cache_init"), - InputField(name="global_conditioning_frames", lifecycle="cache_init"), - InputField(name="view_names", lifecycle="cache_init"), - InputField(name="text_embeddings", required=False, lifecycle="cache_init"), - InputField(name="image_embeddings", required=False, lifecycle="cache_init"), + global_conditioning_fields=( + InputField(name="prompts", frequency_consumed="once"), + InputField(name="global_conditioning_frames", frequency_consumed="once"), + InputField(name="view_names", frequency_consumed="once"), + InputField(name="text_embeddings", required=False, frequency_consumed="once"), + InputField(name="image_embeddings", required=False, frequency_consumed="once"), + ), + step_fields=( + InputField(name="hdmap_frames", frequency_consumed="per_step"), ), - step_fields=(InputField(name="hdmap_frames", lifecycle="step_input"),), ) ``` ```python hy_worldplay_model = InferenceInputSchema( description="hy-worldplay", - global_fields=( - InputField(name="prompt", lifecycle="cache_init"), - InputField(name="global_conditioning_frame", lifecycle="cache_init"), - InputField(name="action_labels", lifecycle="rollout_binding"), - InputField(name="camera_viewmats", lifecycle="rollout_binding"), - InputField(name="camera_intrinsics", lifecycle="rollout_binding"), - InputField(name="memory_config", lifecycle="rollout_binding"), + global_conditioning_fields=( + InputField(name="prompt", frequency_consumed="once"), + InputField(name="global_conditioning_frame", frequency_consumed="once"), + InputField(name="action_labels", frequency_consumed="per_step"), + InputField(name="camera_viewmats", frequency_consumed="per_step"), + InputField(name="camera_intrinsics", frequency_consumed="per_step"), + InputField(name="memory_config", frequency_consumed="per_step"), ), ) ``` @@ -294,21 +289,21 @@ hy_worldplay_model = InferenceInputSchema( ```python sana_wm_model = InferenceInputSchema( description="sana-wm", - global_fields=( - InputField(name="prompt", lifecycle="cache_init"), - InputField(name="negative_prompt", required=False, lifecycle="cache_init"), - InputField(name="global_conditioning_frame", lifecycle="cache_init"), + global_conditioning_fields=( + InputField(name="prompt", frequency_consumed="once"), + InputField(name="negative_prompt", required=False, frequency_consumed="once"), + InputField(name="global_conditioning_frame", frequency_consumed="once"), InputField( name="camera_trajectory_c2w", semantic_type="c2w_sequence", - lifecycle="rollout_binding", + frequency_consumed="per_step", metadata={"shape": "[F,4,4]", "coordinates": "opencv_c2w"}, ), InputField( name="camera_intrinsics_vec4", required=False, semantic_type="intrinsics_vec4_sequence", - lifecycle="rollout_binding", + frequency_consumed="per_step", metadata={"shape": "[F,4]"}, ), ), diff --git a/flashdreams/flashdreams/runtime/__init__.py b/flashdreams/flashdreams/runtime/__init__.py index ab303c745..04f84ae54 100644 --- a/flashdreams/flashdreams/runtime/__init__.py +++ b/flashdreams/flashdreams/runtime/__init__.py @@ -19,7 +19,6 @@ from flashdreams.runtime.config import ExecutionBackend, InferenceConfig, Precision from flashdreams.runtime.inputs import ( INPUT_PHASES, - SESSION_START_ONLY, CanonicalInputs, CanonicalInputSchema, CanonicalModality, @@ -96,7 +95,6 @@ "Precision", "RuntimeMetricSample", "ScriptedModality", - "SESSION_START_ONLY", "StepRequest", "StepResult", "TimeWindow", diff --git a/flashdreams/flashdreams/runtime/inputs.py b/flashdreams/flashdreams/runtime/inputs.py index f0be31bea..d88fbdb7a 100644 --- a/flashdreams/flashdreams/runtime/inputs.py +++ b/flashdreams/flashdreams/runtime/inputs.py @@ -12,23 +12,17 @@ from flashdreams.runtime._utils import freeze_mapping -InputPhase = Literal["global", "step"] +InputPhase = Literal["global_conditioning", "step"] -INPUT_PHASES: tuple[InputPhase, ...] = ("global", "step") - -SESSION_START_ONLY = "session_start" -"""``InputField.update_policy`` value meaning "supply at session start only". - -``update_policy`` is otherwise an open, adapter-owned vocabulary. This is the -one reserved token, because the runtime needs to distinguish a conditioning -value that can be swapped mid-rollout from one that cannot. -""" +INPUT_PHASES: tuple[InputPhase, ...] = ("global_conditioning", "step") def validate_phase(value: str) -> InputPhase: """Return ``value`` as a validated :data:`InputPhase`.""" if value not in INPUT_PHASES: - raise ValueError(f"phase must be 'global' or 'step', got {value!r}.") + raise ValueError( + f"phase must be 'global_conditioning' or 'step', got {value!r}." + ) return cast(InputPhase, value) @@ -56,17 +50,15 @@ def contains(self, timestamp_s: float) -> bool: class InputField: """Lightweight schema field for user snapshots or model inputs. - ``update_policy`` and ``lifecycle`` are plain query metadata. They let a - model advertise facts such as "prompt updates land at step boundaries" or - "this value is consumed at cache init" without making this layer - responsible for implementing or deeply validating that behavior. + ``semantic_type``, ``frequency_consumed``, and ``metadata`` are query hints + only. Adapter-owned validation still decides concrete shape, dtype, units, + and tensor layout. """ name: str required: bool = True semantic_type: str | None = None - update_policy: str | None = None - lifecycle: str | None = None + frequency_consumed: str | None = None metadata: Mapping[str, Any] = field( default_factory=dict, compare=False, @@ -205,21 +197,21 @@ def require_snapshot(self, inputs: "UserInputs") -> None: @dataclass(frozen=True, kw_only=True, slots=True) class InferenceInputSchema: - """Minimal metadata for model-facing initial and per-step inputs.""" + """Minimal metadata for global conditioning and per-step inputs.""" - global_fields: tuple[InputField, ...] = () - """Model inputs required before starting the initial generation/session.""" + global_conditioning_fields: tuple[InputField, ...] = () + """Model inputs carried in the global conditioning slot.""" step_fields: tuple[InputField, ...] = () - """Per-step model inputs required after the session starts.""" + """Model inputs required for one session step.""" description: str = "" def fields_for(self, phase: InputPhase) -> tuple[InputField, ...]: """Return every declared field for ``phase``.""" return ( - self.global_fields - if validate_phase(phase) == "global" + self.global_conditioning_fields + if validate_phase(phase) == "global_conditioning" else self.step_fields ) @@ -258,32 +250,20 @@ def _select( if input_field.required is required ) - def unsupported_global_updates(self, inputs: "InferenceInput") -> tuple[str, ...]: - """Return requested conditioning updates this model cannot apply. - - A field whose ``update_policy`` is :data:`SESSION_START_ONLY` can be - supplied when the session starts but not changed mid-rollout. Any other - policy, including ``None``, is treated as permissive here; the adapter - still owns whether the swap actually succeeds. - """ - return tuple( - name - for name in inputs.global_conditioning - if (declared := self.field_for(name=name, phase="global")) is not None - and declared.update_policy == SESSION_START_ONLY + def missing_global_conditioning(self, inputs: "InferenceInput") -> tuple[str, ...]: + """Return required global conditioning fields absent from ``inputs``.""" + return _missing_required( + self.global_conditioning_fields, + inputs.global_conditioning, ) - def missing_global(self, inputs: "InferenceInput") -> tuple[str, ...]: - """Return required initial fields absent from ``inputs``.""" - return _missing_required(self.global_fields, inputs.global_conditioning) - def missing_step(self, inputs: "InferenceInput") -> tuple[str, ...]: """Return required per-step fields absent from ``inputs``.""" return _missing_required(self.step_fields, inputs.step) - def require_global(self, inputs: "InferenceInput") -> None: - """Raise if required initial fields are absent.""" - missing = self.missing_global(inputs) + def require_global_conditioning(self, inputs: "InferenceInput") -> None: + """Raise if required global conditioning fields are absent.""" + missing = self.missing_global_conditioning(inputs) if missing: raise ValueError( f"Missing required global conditioning input(s): {missing}" @@ -447,16 +427,10 @@ class InferenceInput: Two conditioning slots: - ``global_conditioning``: values that condition the whole rollout, such as - the conditioning frame or prompt. Normally supplied when the session - starts. + the conditioning frame or prompt. Session start/reset establishes this + state; a step call may carry a non-empty payload to request an update when + the model supports it. - ``step``: values needed to generate the next chunk or frame. - - A non-empty ``global_conditioning`` on a mid-rollout input is an *update - request*, not a reset. The session should apply it when the model supports - that; resetting rollout state is a separate, explicit - :meth:`InferenceSession.reset` call. Whether a given value can be updated - mid-rollout is declared per field by ``InputField.update_policy``; see - :meth:`InferenceInputSchema.unsupported_global_updates`. """ __hash__ = None @@ -472,42 +446,12 @@ def __post_init__(self) -> None: object.__setattr__(self, "step", freeze_mapping(self.step)) object.__setattr__(self, "metadata", freeze_mapping(self.metadata)) - @property - def requests_global_update(self) -> bool: - """Return whether this input asks the session to update conditioning.""" - return bool(self.global_conditioning) - - def with_step(self, step: Mapping[str, Any]) -> "InferenceInput": - """Return a copy with replaced per-step payload. - - The global slot is carried through unchanged, so a mid-rollout input - built this way keeps whatever update request it already had. Use - :meth:`without_global_update` for the common steady-state case. - """ - return InferenceInput( - global_conditioning=self.global_conditioning, - step=step, - metadata=self.metadata, - ) - - def with_global_update( - self, global_conditioning: Mapping[str, Any] - ) -> "InferenceInput": - """Return a copy requesting a mid-rollout conditioning update.""" - return InferenceInput( - global_conditioning=global_conditioning, - step=self.step, - metadata=self.metadata, - ) - - def without_global_update(self) -> "InferenceInput": - """Return a copy that requests no conditioning update.""" - return InferenceInput(step=self.step, metadata=self.metadata) - def for_phase(self, phase: InputPhase) -> Mapping[str, Any]: """Return the payload mapping for ``phase``.""" return ( - self.global_conditioning if validate_phase(phase) == "global" else self.step + self.global_conditioning + if validate_phase(phase) == "global_conditioning" + else self.step ) diff --git a/flashdreams/flashdreams/runtime/interfaces.py b/flashdreams/flashdreams/runtime/interfaces.py index 852a77f1c..5c5054dc4 100644 --- a/flashdreams/flashdreams/runtime/interfaces.py +++ b/flashdreams/flashdreams/runtime/interfaces.py @@ -22,7 +22,7 @@ class InferenceSession(Protocol): """One rollout or stream with isolated model/cache state.""" def next_step_request(self) -> StepRequest | None: - """Describe the next step's inputs, or return ``None`` when complete.""" + """Return the next step's runtime request, or ``None`` when complete.""" ... def step(self, inputs: InferenceInput) -> StepResult: @@ -69,7 +69,7 @@ def model_id(self) -> str: @property def inference_input_schema(self) -> InferenceInputSchema: - """Model-facing initial and per-step input requirements.""" + """Model-facing global conditioning and per-step input requirements.""" ... @property diff --git a/flashdreams/flashdreams/runtime/mapping.py b/flashdreams/flashdreams/runtime/mapping.py index 94f481406..bbf11616e 100644 --- a/flashdreams/flashdreams/runtime/mapping.py +++ b/flashdreams/flashdreams/runtime/mapping.py @@ -41,13 +41,13 @@ def validate( """Fail early for obvious app, event-source, and model mismatches.""" ... - def map_global_inputs( + def map_global_conditioning_inputs( self, *, canonical_inputs: CanonicalInputs, inference_input: InferenceInput, ) -> InferenceInput: - """Build global conditioning inputs before a session starts.""" + """Build global conditioning inputs for session start or reset.""" ... def map_step_inputs( @@ -72,7 +72,7 @@ def validate( ) -> None: del canonical_schema, inference_input_schema - def map_global_inputs( + def map_global_conditioning_inputs( self, *, canonical_inputs: CanonicalInputs, @@ -104,7 +104,7 @@ class InputMappingSchema: name: str = "input-mapping" consumes: tuple[CanonicalModality, ...] = () - produces_global: tuple[InputField, ...] = () + produces_global_conditioning: tuple[InputField, ...] = () produces_step: tuple[InputField, ...] = () metadata: Mapping[str, Any] = field( default_factory=dict, @@ -119,7 +119,11 @@ def __post_init__(self) -> None: def produces_for(self, phase: InputPhase) -> tuple[InputField, ...]: """Return the fields this mapping produces for ``phase``.""" - return self.produces_global if phase == "global" else self.produces_step + return ( + self.produces_global_conditioning + if phase == "global_conditioning" + else self.produces_step + ) def can_produce(self, phase: InputPhase, required: InputField) -> bool: """Return whether this mapping can produce ``required`` in ``phase``.""" @@ -136,12 +140,7 @@ def _field_matches(produced: InputField, required: InputField) -> bool: or required.semantic_type is None or produced.semantic_type == required.semantic_type ) - lifecycle_ok = ( - produced.lifecycle is None - or required.lifecycle is None - or produced.lifecycle == required.lifecycle - ) - return semantic_ok and lifecycle_ok + return semantic_ok @dataclass(frozen=True, kw_only=True, slots=True) @@ -223,7 +222,10 @@ def combine_mapping_schemas( first declaration winning on conflicting keys. """ consumes: list[CanonicalModality] = [] - produces: dict[InputPhase, list[InputField]] = {"global": [], "step": []} + produces: dict[InputPhase, list[InputField]] = { + "global_conditioning": [], + "step": [], + } def _merge(target: list[Any], value: Any) -> None: for index, existing in enumerate(target): @@ -248,7 +250,7 @@ def _merge(target: list[Any], value: Any) -> None: return InputMappingSchema( name=name, consumes=tuple(consumes), - produces_global=tuple(produces["global"]), + produces_global_conditioning=tuple(produces["global_conditioning"]), produces_step=tuple(produces["step"]), ) @@ -358,8 +360,9 @@ def undeclared_inference_inputs( """Return payload keys a mapping produced but did not declare. Mapping schemas are hand-written, so they drift from what - ``map_global_inputs``/``map_step_inputs`` actually return. Mapping tests - can use this to keep the declared compatibility surface honest. + ``map_global_conditioning_inputs``/``map_step_inputs`` actually return. + Mapping tests can use this to keep the declared compatibility surface + honest. """ return tuple( (phase, key) diff --git a/flashdreams/flashdreams/runtime/types.py b/flashdreams/flashdreams/runtime/types.py index 467753026..51d3846db 100644 --- a/flashdreams/flashdreams/runtime/types.py +++ b/flashdreams/flashdreams/runtime/types.py @@ -15,10 +15,11 @@ @dataclass(frozen=True, kw_only=True, slots=True) class StepRequest: - """Model-session request for the next step's inputs. + """Per-step runtime request emitted by an inference session. - ``user_input_window`` lets a runner drain or slice timestamped user events for - the current step before invoking the selected ``InputMapping``. + This is not a schema declaration. ``user_input_window`` lets a runner drain + or slice timestamped user events for the current step before invoking the + selected ``InputMapping``. """ __hash__ = None @@ -36,7 +37,7 @@ def __post_init__(self) -> None: @dataclass(frozen=True, kw_only=True, slots=True) class StepResult: - """Generated output and metadata for one inference step.""" + """Generated output and metadata returned by one inference step.""" __hash__ = None diff --git a/flashdreams/tests/test_inference_runtime_api.py b/flashdreams/tests/test_inference_runtime_api.py index edfafa634..d454f6205 100644 --- a/flashdreams/tests/test_inference_runtime_api.py +++ b/flashdreams/tests/test_inference_runtime_api.py @@ -106,9 +106,9 @@ def test_runtime_metric_sample_rejects_bool_values() -> None: RuntimeMetricSample(name="sample", value=True) -def test_inference_input_schema_validates_initial_and_step_payloads() -> None: +def test_schema_validates_global_conditioning_and_step_payloads() -> None: schema = InferenceInputSchema( - global_fields=( + global_conditioning_fields=( InputField(name="prompt"), InputField(name="global_conditioning_frame"), ), @@ -118,7 +118,7 @@ def test_inference_input_schema_validates_initial_and_step_payloads() -> None: global_conditioning={"prompt": "drive", "global_conditioning_frame": object()} ) - schema.require_global(inputs) + schema.require_global_conditioning(inputs) assert schema.missing_step(inputs) == ("camera_poses",) with pytest.raises(ValueError, match="camera_poses"): @@ -190,7 +190,7 @@ def test_identity_input_mapping_leaves_inference_input_unchanged() -> None: request = StepRequest(step_index=0) assert ( - mapping.map_global_inputs( + mapping.map_global_conditioning_inputs( canonical_inputs=CanonicalInputs(), inference_input=inference_input, ) @@ -367,7 +367,7 @@ def _drive_two_step_session( canonical_schema=adapter.canonical_input_schema, inference_input_schema=adapter.inference_input_schema, ) - initial_inputs = mapping.map_global_inputs( + initial_inputs = mapping.map_global_conditioning_inputs( canonical_inputs=canonicalizer.canonicalize( user_inputs, window=TimeWindow(start_s=0.0, end_s=_SESSION_HORIZON_S), @@ -390,9 +390,8 @@ def _drive_two_step_session( or TimeWindow(start_s=0.0, end_s=_SESSION_HORIZON_S), source_schema=source_schema, ), - # The global slot stays empty in steady state. A mapping that - # sees ``canonical_inputs.has_global_change`` fills it via - # ``with_global_update`` to request a mid-rollout swap. + # Per-step calls carry only the step payload. A changed prompt + # or scene starts or resets a session outside this loop. inference_input=InferenceInput( step={"chunk_index": request.step_index}, ), @@ -417,7 +416,7 @@ def _drive_two_step_session( class _FakeAdapter: model_id = "fake-model" inference_input_schema = InferenceInputSchema( - global_fields=(InputField(name="prompt"),), + global_conditioning_fields=(InputField(name="prompt"),), step_fields=(InputField(name="chunk_index"),), ) canonical_input_schema = CanonicalInputSchema() @@ -440,7 +439,7 @@ def __init__(self, *, inference_input_schema: InferenceInputSchema) -> None: self.closed = False def start_session(self, inputs: InferenceInput) -> InferenceSession: - self._inference_input_schema.require_global(inputs) + self._inference_input_schema.require_global_conditioning(inputs) return _FakeSession(inference_input_schema=self._inference_input_schema) def close(self) -> None: diff --git a/flashdreams/tests/test_runtime_canonical.py b/flashdreams/tests/test_runtime_canonical.py index 1ad48d39e..cfe2e3646 100644 --- a/flashdreams/tests/test_runtime_canonical.py +++ b/flashdreams/tests/test_runtime_canonical.py @@ -217,13 +217,14 @@ def test_canonical_inputs_carry_live_control_only() -> None: def test_application_supplies_global_conditioning_directly() -> None: - """A prompt swap reaches the session without touching canonicalization.""" - update = InferenceInput(step={"steering": 0.0}).with_global_update( - {"prompt": "heavy rain"} + """A prompt reaches session start without touching canonicalization.""" + inputs = InferenceInput( + global_conditioning={"prompt": "heavy rain"}, + step={"steering": 0.0}, ) - assert update.requests_global_update - assert update.global_conditioning["prompt"] == "heavy rain" + assert inputs.global_conditioning["prompt"] == "heavy rain" + assert inputs.step["steering"] == 0.0 # --- device independence ------------------------------------------------ diff --git a/flashdreams/tests/test_runtime_input_mapping.py b/flashdreams/tests/test_runtime_input_mapping.py index 00cd9758f..0750e733e 100644 --- a/flashdreams/tests/test_runtime_input_mapping.py +++ b/flashdreams/tests/test_runtime_input_mapping.py @@ -4,9 +4,10 @@ """Tests for declarative input-mapping compatibility in the runtime API. These cover the T2/T3 contract: sources declare what user events they can -provide at payload granularity, models declare required and optional -initial/per-step inputs, and a mapping declares what it consumes and produces so -compatibility can be answered before expensive runtime initialization. +provide at payload granularity, models declare required and optional global +conditioning/per-step inputs, and a mapping declares what it consumes and +produces so compatibility can be answered before expensive runtime +initialization. """ from __future__ import annotations @@ -17,7 +18,6 @@ from flashdreams.runtime import ( DRIVER_COMMAND, - SESSION_START_ONLY, CanonicalInputs, CanonicalInputSchema, CanonicalModality, @@ -66,11 +66,13 @@ # modality, so this mapping consumes nothing and only declares what it produces. PROMPT_MAPPING = InputMappingSchema( name="prompt", - produces_global=(InputField(name="prompt", semantic_type="text"),), + produces_global_conditioning=(InputField(name="prompt", semantic_type="text"),), ) FRAME_MAPPING = InputMappingSchema( name="conditioning-frame", - produces_global=(InputField(name="global_conditioning_frame", required=False),), + produces_global_conditioning=( + InputField(name="global_conditioning_frame", required=False), + ), ) STEERING_MAPPING = InputMappingSchema( name="driver-command-to-steering", @@ -84,12 +86,10 @@ ) DRIVING_MODEL = InferenceInputSchema( - global_fields=( - InputField(name="prompt", semantic_type="text", lifecycle="cache_init"), - ), + global_conditioning_fields=(InputField(name="prompt", semantic_type="text"),), step_fields=( - InputField(name="steering", lifecycle="step_input"), - InputField(name="camera_delta", required=False, lifecycle="step_input"), + InputField(name="steering"), + InputField(name="camera_delta", required=False), ), ) @@ -97,7 +97,7 @@ # --- user input events and windowing ------------------------------------ -def test_startup_values_are_represented_as_events() -> None: +def test_session_start_values_are_represented_as_events() -> None: inputs = UserInputs( events=( UserInputEvent( @@ -198,7 +198,7 @@ def test_model_declares_required_and_optional_fields_per_phase() -> None: optional = DRIVING_MODEL.optional_fields() assert {(phase, f.name) for phase, f in required} == { - ("global", "prompt"), + ("global_conditioning", "prompt"), ("step", "steering"), } assert {(phase, f.name) for phase, f in optional} == {("step", "camera_delta")} @@ -211,7 +211,10 @@ def test_required_fields_can_be_filtered_by_phase() -> None: def test_field_lookup_is_phase_scoped() -> None: - assert DRIVING_MODEL.field_for(name="prompt", phase="global") is not None + assert ( + DRIVING_MODEL.field_for(name="prompt", phase="global_conditioning") + is not None + ) assert DRIVING_MODEL.field_for(name="prompt", phase="step") is None @@ -227,20 +230,28 @@ def test_inference_input_expose_payload_per_phase() -> None: global_conditioning={"prompt": "drive"}, step={"steering": 0.25} ) - assert inputs.for_phase("global")["prompt"] == "drive" + assert inputs.for_phase("global_conditioning")["prompt"] == "drive" assert inputs.for_phase("step")["steering"] == 0.25 -def test_lifecycle_and_update_policy_are_queryable_metadata() -> None: +def test_step_context_can_carry_global_conditioning_update_payload() -> None: + inputs = InferenceInput( + global_conditioning={"prompt": "heavy rain"}, + step={"steering": 0.25}, + ) + + assert inputs.global_conditioning["prompt"] == "heavy rain" + assert inputs.step["steering"] == 0.25 + + +def test_field_metadata_is_queryable() -> None: field = InputField( name="prompt", - update_policy="step_boundary", - lifecycle="cache_init", + frequency_consumed="once", metadata={"coordinates": "opencv_c2w"}, ) - assert field.update_policy == "step_boundary" - assert field.lifecycle == "cache_init" + assert field.frequency_consumed == "once" assert field.metadata["coordinates"] == "opencv_c2w" @@ -263,7 +274,7 @@ def test_compatible_source_model_and_mapping_can_drive() -> None: assert compatibility.can_drive assert {(p, f.name) for p, f in compatibility.satisfied_required_model_fields} == { - ("global", "prompt"), + ("global_conditioning", "prompt"), ("step", "steering"), } assert {(p, f.name) for p, f in compatibility.available_optional_model_fields} == { @@ -325,13 +336,17 @@ def test_optional_field_needs_mapping_support_to_be_available() -> None: assert compatibility.available_optional_model_fields == () -def test_lifecycle_disagreement_blocks_a_field_match() -> None: +def test_global_conditioning_mapping_matches_global_conditioning_field() -> None: model = InferenceInputSchema( - global_fields=(InputField(name="prompt", lifecycle="rollout_binding"),) + global_conditioning_fields=( + InputField(name="camera_trajectory", frequency_consumed="per_step"), + ) ) mapping = InputMappingSchema( - name="prompt", - produces_global=(InputField(name="prompt", lifecycle="cache_init"),), + name="trajectory", + produces_global_conditioning=( + InputField(name="camera_trajectory", frequency_consumed="once"), + ), ) compatibility = check_mapping_compatibility( @@ -340,11 +355,13 @@ def test_lifecycle_disagreement_blocks_a_field_match() -> None: mapping_schema=mapping, ) - assert not compatibility.can_drive + assert compatibility.can_drive -def test_unspecified_lifecycle_stays_permissive() -> None: - model = InferenceInputSchema(global_fields=(InputField(name="prompt"),)) +def test_unspecified_semantic_type_stays_permissive() -> None: + model = InferenceInputSchema( + global_conditioning_fields=(InputField(name="prompt"),) + ) compatibility = check_mapping_compatibility( canonical_schema=CANONICAL_ALL, @@ -398,26 +415,28 @@ def test_combining_mappings_unions_their_surfaces() -> None: combined = combine_mapping_schemas((PROMPT_MAPPING, STEERING_MAPPING)) assert {m.name for m in combined.consumes} == {"driver_command"} - assert [f.name for f in combined.produces_global] == ["prompt"] + assert [f.name for f in combined.produces_global_conditioning] == ["prompt"] assert [f.name for f in combined.produces_step] == ["steering"] def test_duplicate_declarations_collapse_and_merge_metadata() -> None: first = InputMappingSchema( name="a", - produces_global=(InputField(name="prompt", metadata={"source": "a"}),), + produces_global_conditioning=( + InputField(name="prompt", metadata={"source": "a"}), + ), ) second = InputMappingSchema( name="b", - produces_global=( + produces_global_conditioning=( InputField(name="prompt", metadata={"source": "b", "extra": "kept"}), ), ) combined = combine_mapping_schemas((first, second)) - assert len(combined.produces_global) == 1 - metadata = combined.produces_global[0].metadata + assert len(combined.produces_global_conditioning) == 1 + metadata = combined.produces_global_conditioning[0].metadata assert metadata["source"] == "a" assert metadata["extra"] == "kept" @@ -489,85 +508,3 @@ def test_model_with_no_requirements_is_always_drivable() -> None: ) assert compatibility.can_drive - - -# --- global conditioning updates vs reset ------------------------------- - - -def test_empty_global_slot_requests_no_update() -> None: - steady_state = InferenceInput(step={"steering": 0.25}) - - assert not steady_state.requests_global_update - - -def test_non_empty_global_slot_mid_rollout_is_an_update_request() -> None: - """Changing weather mid-run updates conditioning; it is not a reset.""" - updated = InferenceInput(step={"steering": 0.0}).with_global_update( - {"prompt": "heavy rain"} - ) - - assert updated.requests_global_update - assert updated.global_conditioning["prompt"] == "heavy rain" - assert updated.step["steering"] == 0.0 - - -def test_with_step_carries_the_global_slot_through() -> None: - started = InferenceInput(global_conditioning={"prompt": "drive"}) - - stepped = started.with_step({"steering": 0.5}) - - assert stepped.global_conditioning["prompt"] == "drive" - - -def test_without_global_update_clears_the_request() -> None: - started = InferenceInput( - global_conditioning={"prompt": "drive"}, step={"steering": 0.5} - ) - - steady_state = started.without_global_update() - - assert not steady_state.requests_global_update - assert steady_state.step["steering"] == 0.5 - - -def test_model_can_declare_conditioning_it_cannot_swap_mid_rollout() -> None: - schema = InferenceInputSchema( - global_fields=( - InputField(name="prompt", update_policy="step_boundary"), - InputField(name="scene_id", update_policy=SESSION_START_ONLY), - ) - ) - update = InferenceInput( - global_conditioning={"prompt": "heavy rain", "scene_id": "town_02"} - ) - - assert schema.unsupported_global_updates(update) == ("scene_id",) - - -def test_permissive_when_no_update_policy_is_declared() -> None: - schema = InferenceInputSchema(global_fields=(InputField(name="prompt"),)) - update = InferenceInput(global_conditioning={"prompt": "heavy rain"}) - - assert schema.unsupported_global_updates(update) == () - - -def test_undeclared_global_values_are_left_to_the_adapter() -> None: - schema = InferenceInputSchema(global_fields=(InputField(name="prompt"),)) - update = InferenceInput(global_conditioning={"mystery": 1}) - - assert schema.unsupported_global_updates(update) == () - - -def test_steady_state_steps_do_not_request_a_global_update() -> None: - """Carrying session-start conditioning forward would look like an update.""" - started = InferenceInput(global_conditioning={"prompt": "drive"}) - - steady_state = InferenceInput(step={"chunk_index": 1}) - - assert started.requests_global_update - assert not steady_state.requests_global_update - assert ( - not started.with_step({"chunk_index": 1}) - .without_global_update() - .requests_global_update - ) From e1fa7d0da7113a0aebfad3d7a70b542b31710b2c Mon Sep 17 00:00:00 2001 From: Aidan Foster Date: Wed, 5 Aug 2026 19:14:21 -0700 Subject: [PATCH 12/30] Rename misleading `semantic_type` to `input_modality` --- ...ence_runtime_supported_inputs_inventory.md | 94 +++++++++++++------ flashdreams/flashdreams/runtime/inputs.py | 23 +++-- flashdreams/flashdreams/runtime/mapping.py | 13 +-- .../tests/test_runtime_input_mapping.py | 41 ++++---- 4 files changed, 108 insertions(+), 63 deletions(-) diff --git a/docs/inference_runtime_supported_inputs_inventory.md b/docs/inference_runtime_supported_inputs_inventory.md index d56cf1651..f82f3192b 100644 --- a/docs/inference_runtime_supported_inputs_inventory.md +++ b/docs/inference_runtime_supported_inputs_inventory.md @@ -161,12 +161,12 @@ caller provides the value. A field can live in `global_conditioning_fields` and still have `frequency_consumed="per_step"` when the adapter slices or reads rollout-wide state during step execution. -Third, `semantic_type` should be treated as a representation hint rather than a -universal semantic type. For example, `prompt` may arrive as inline text or a -path but become prompt text or text embeddings; the global conditioning frame -may arrive as a path, URL, bytes, or decoded tensor; camera motion may arrive as keys, pose JSON, -Numpy arrays, or integrated tensors. The semantic input name is still the main -contract. +Third, `name` is the semantic model input role, while `input_modality` is only +a coarse value-kind hint. For example, `prompt` and `negative_prompt` are +different semantic names even though both usually have `input_modality="text"`. +The semantic input name is the main contract; source details such as path, URL, +bytes, decoded tensor layout, accepted suffixes, or file schema belong in +adapter validation or `metadata`. Fourth, schema objects need open-ended metadata for future adapters. This lets a SANA-WM-like adapter advertise that `camera_trajectory_c2w` uses an @@ -200,12 +200,13 @@ The implementation that came out of this inventory is: 4. Split `InferenceInput` (formerly `ModelInputs`) into `global_conditioning` and `step`. Global conditioning is session-global state; `step` is the payload for one generated chunk or frame window. -5. Keep `InputField.semantic_type` and `metadata` as lightweight query hints, - while leaving tensor shape, cadence, and model-specific validation to - adapters and sessions. +5. Keep `InputField.input_modality`, `frequency_consumed`, and `metadata` as + lightweight query hints, while leaving tensor shape and model-specific + validation to adapters and sessions. `InputField.name` remains the semantic + payload key. 6. Keep `InputMappingSchema` as the canonical-to-encoded boundary, with mapping-set compatibility helpers for composed mappings. -7. Keep input names, semantic types, and metadata open-ended. +7. Keep input names, input modalities, and metadata open-ended. Adding a new model should usually mean adding adapter-owned schema declarations and mappings, not changing the core input dataclasses. 8. Leave deep validation to model adapters, sessions, and mappings. The schema @@ -225,13 +226,14 @@ Use these conventions when adding future model schemas: - Prefer semantic names over modality names, such as `camera_trajectory_c2w` instead of `array`, or `hdmap_frames` instead of `image`. -- Use `semantic_type` for a coarse representation hint, such as `path`, - `decoded_tensor`, `c2w_sequence`, `intrinsics_vec4_sequence`, or `embedding`. +- Use `input_modality` for a coarse value-kind hint, such as `text`, `image`, + `embedding`, `c2w_sequence`, or `intrinsics_vec4_sequence`. +- Use `metadata` for representation details such as paths, decoded tensor + layout, units, coordinate frame, shape summary, accepted suffixes, schema URI, + model family, value ranges, or cardinality. - Use `frequency_consumed` for adapter-consumption cadence, such as `once` or `per_step`; keep it independent from whether the field is declared under `global_conditioning_fields` or `step_fields`. -- Use `metadata` for query hints: units, coordinate frame, shape summary, - accepted suffixes, schema URI, model family, value ranges, or cardinality. - Keep deep validation in the adapter/mapping. The lightweight schemas answer whether the selected source and mapping can plausibly drive the model before expensive initialization. @@ -246,9 +248,18 @@ can describe the supported input surfaces. All use lingbot_model = InferenceInputSchema( description="lingbot-world", global_conditioning_fields=( - InputField(name="prompt", frequency_consumed="once"), - InputField(name="global_conditioning_frame", frequency_consumed="once"), - InputField(name="text_embeddings", required=False, frequency_consumed="once"), + InputField(name="prompt", input_modality="text", frequency_consumed="once"), + InputField( + name="global_conditioning_frame", + input_modality="image", + frequency_consumed="once", + ), + InputField( + name="text_embeddings", + required=False, + input_modality="embedding", + frequency_consumed="once", + ), ), step_fields=( InputField(name="camera_trajectory", frequency_consumed="per_step"), @@ -260,11 +271,25 @@ lingbot_model = InferenceInputSchema( omnidreams_model = InferenceInputSchema( description="omnidreams", global_conditioning_fields=( - InputField(name="prompts", frequency_consumed="once"), - InputField(name="global_conditioning_frames", frequency_consumed="once"), + InputField(name="prompts", input_modality="text", frequency_consumed="once"), + InputField( + name="global_conditioning_frames", + input_modality="image", + frequency_consumed="once", + ), InputField(name="view_names", frequency_consumed="once"), - InputField(name="text_embeddings", required=False, frequency_consumed="once"), - InputField(name="image_embeddings", required=False, frequency_consumed="once"), + InputField( + name="text_embeddings", + required=False, + input_modality="embedding", + frequency_consumed="once", + ), + InputField( + name="image_embeddings", + required=False, + input_modality="embedding", + frequency_consumed="once", + ), ), step_fields=( InputField(name="hdmap_frames", frequency_consumed="per_step"), @@ -276,8 +301,12 @@ omnidreams_model = InferenceInputSchema( hy_worldplay_model = InferenceInputSchema( description="hy-worldplay", global_conditioning_fields=( - InputField(name="prompt", frequency_consumed="once"), - InputField(name="global_conditioning_frame", frequency_consumed="once"), + InputField(name="prompt", input_modality="text", frequency_consumed="once"), + InputField( + name="global_conditioning_frame", + input_modality="image", + frequency_consumed="once", + ), InputField(name="action_labels", frequency_consumed="per_step"), InputField(name="camera_viewmats", frequency_consumed="per_step"), InputField(name="camera_intrinsics", frequency_consumed="per_step"), @@ -290,19 +319,28 @@ hy_worldplay_model = InferenceInputSchema( sana_wm_model = InferenceInputSchema( description="sana-wm", global_conditioning_fields=( - InputField(name="prompt", frequency_consumed="once"), - InputField(name="negative_prompt", required=False, frequency_consumed="once"), - InputField(name="global_conditioning_frame", frequency_consumed="once"), + InputField(name="prompt", input_modality="text", frequency_consumed="once"), + InputField( + name="negative_prompt", + required=False, + input_modality="text", + frequency_consumed="once", + ), + InputField( + name="global_conditioning_frame", + input_modality="image", + frequency_consumed="once", + ), InputField( name="camera_trajectory_c2w", - semantic_type="c2w_sequence", + input_modality="c2w_sequence", frequency_consumed="per_step", metadata={"shape": "[F,4,4]", "coordinates": "opencv_c2w"}, ), InputField( name="camera_intrinsics_vec4", required=False, - semantic_type="intrinsics_vec4_sequence", + input_modality="intrinsics_vec4_sequence", frequency_consumed="per_step", metadata={"shape": "[F,4]"}, ), diff --git a/flashdreams/flashdreams/runtime/inputs.py b/flashdreams/flashdreams/runtime/inputs.py index d88fbdb7a..9174b6a84 100644 --- a/flashdreams/flashdreams/runtime/inputs.py +++ b/flashdreams/flashdreams/runtime/inputs.py @@ -50,14 +50,15 @@ def contains(self, timestamp_s: float) -> bool: class InputField: """Lightweight schema field for user snapshots or model inputs. - ``semantic_type``, ``frequency_consumed``, and ``metadata`` are query hints - only. Adapter-owned validation still decides concrete shape, dtype, units, - and tensor layout. + ``name`` is the model-facing input role and payload key, such as ``prompt`` + or ``negative_prompt``. ``input_modality``, ``frequency_consumed``, and + ``metadata`` are query hints only. Adapter-owned validation still decides + concrete shape, dtype, units, and tensor layout. """ name: str required: bool = True - semantic_type: str | None = None + input_modality: str | None = None frequency_consumed: str | None = None metadata: Mapping[str, Any] = field( default_factory=dict, @@ -83,7 +84,7 @@ class UserInputCapability: """ event_type: str - semantic_type: str | None = None + input_modality: str | None = None payload_fields: frozenset[str] = field(default_factory=frozenset) metadata: Mapping[str, Any] = field( default_factory=dict, @@ -104,12 +105,14 @@ def is_satisfied_by(self, provider: "UserInputCapability") -> bool: """Return whether ``provider`` can satisfy this consumed capability.""" if self.event_type != provider.event_type: return False - semantic_ok = ( - self.semantic_type is None - or provider.semantic_type is None - or self.semantic_type == provider.semantic_type + input_modality_ok = ( + self.input_modality is None + or provider.input_modality is None + or self.input_modality == provider.input_modality + ) + return input_modality_ok and self.payload_fields.issubset( + provider.payload_fields ) - return semantic_ok and self.payload_fields.issubset(provider.payload_fields) @dataclass(frozen=True, kw_only=True, slots=True) diff --git a/flashdreams/flashdreams/runtime/mapping.py b/flashdreams/flashdreams/runtime/mapping.py index bbf11616e..710ad8043 100644 --- a/flashdreams/flashdreams/runtime/mapping.py +++ b/flashdreams/flashdreams/runtime/mapping.py @@ -135,12 +135,12 @@ def can_produce(self, phase: InputPhase, required: InputField) -> bool: def _field_matches(produced: InputField, required: InputField) -> bool: if produced.name != required.name: return False - semantic_ok = ( - produced.semantic_type is None - or required.semantic_type is None - or produced.semantic_type == required.semantic_type + input_modality_ok = ( + produced.input_modality is None + or required.input_modality is None + or produced.input_modality == required.input_modality ) - return semantic_ok + return input_modality_ok @dataclass(frozen=True, kw_only=True, slots=True) @@ -369,7 +369,8 @@ def undeclared_inference_inputs( for phase in INPUT_PHASES for key in inputs.for_phase(phase) if not any( - declared.name == key for declared in mapping_schema.produces_for(phase) + declared.name == key + for declared in mapping_schema.produces_for(phase) ) ) diff --git a/flashdreams/tests/test_runtime_input_mapping.py b/flashdreams/tests/test_runtime_input_mapping.py index 0750e733e..b5f0744b9 100644 --- a/flashdreams/tests/test_runtime_input_mapping.py +++ b/flashdreams/tests/test_runtime_input_mapping.py @@ -44,7 +44,7 @@ KEY_UP = UserInputCapability(event_type="key_up", payload_fields=frozenset({"key"})) PROMPT_SET = UserInputCapability( event_type="prompt_set", - semantic_type="text", + input_modality="text", payload_fields=frozenset({"prompt"}), ) FRAME_SET = UserInputCapability( @@ -66,7 +66,7 @@ # modality, so this mapping consumes nothing and only declares what it produces. PROMPT_MAPPING = InputMappingSchema( name="prompt", - produces_global_conditioning=(InputField(name="prompt", semantic_type="text"),), + produces_global_conditioning=(InputField(name="prompt", input_modality="text"),), ) FRAME_MAPPING = InputMappingSchema( name="conditioning-frame", @@ -86,7 +86,7 @@ ) DRIVING_MODEL = InferenceInputSchema( - global_conditioning_fields=(InputField(name="prompt", semantic_type="text"),), + global_conditioning_fields=(InputField(name="prompt", input_modality="text"),), step_fields=( InputField(name="steering"), InputField(name="camera_delta", required=False), @@ -164,15 +164,15 @@ def test_capabilities_widen_declared_event_types() -> None: assert BROWSER_SOURCE.supports_event_types({"key_down", "prompt_set"}) -def test_semantic_type_mismatch_blocks_capability_match() -> None: +def test_input_modality_mismatch_blocks_capability_match() -> None: source = UserInputSchema( capabilities=( - UserInputCapability(event_type="prompt_set", semantic_type="embedding"), + UserInputCapability(event_type="prompt_set", input_modality="embedding"), ) ) assert not source.supports( - UserInputCapability(event_type="prompt_set", semantic_type="text") + UserInputCapability(event_type="prompt_set", input_modality="text") ) @@ -201,7 +201,9 @@ def test_model_declares_required_and_optional_fields_per_phase() -> None: ("global_conditioning", "prompt"), ("step", "steering"), } - assert {(phase, f.name) for phase, f in optional} == {("step", "camera_delta")} + assert {(phase, f.name) for phase, f in optional} == { + ("step", "camera_delta") + } def test_required_fields_can_be_filtered_by_phase() -> None: @@ -273,13 +275,12 @@ def test_compatible_source_model_and_mapping_can_drive() -> None: ) assert compatibility.can_drive - assert {(p, f.name) for p, f in compatibility.satisfied_required_model_fields} == { - ("global_conditioning", "prompt"), - ("step", "steering"), - } - assert {(p, f.name) for p, f in compatibility.available_optional_model_fields} == { - ("step", "camera_delta") - } + assert { + (p, f.name) for p, f in compatibility.satisfied_required_model_fields + } == {("global_conditioning", "prompt"), ("step", "steering")} + assert { + (p, f.name) for p, f in compatibility.available_optional_model_fields + } == {("step", "camera_delta")} def test_missing_required_model_field_blocks_the_run() -> None: @@ -290,9 +291,9 @@ def test_missing_required_model_field_blocks_the_run() -> None: ) assert not compatibility.can_drive - assert [f.name for _, f in compatibility.missing_required_model_fields] == [ - "steering" - ] + assert [ + f.name for _, f in compatibility.missing_required_model_fields + ] == ["steering"] def test_missing_source_capability_is_reported_when_it_blocks() -> None: @@ -358,7 +359,7 @@ def test_global_conditioning_mapping_matches_global_conditioning_field() -> None assert compatibility.can_drive -def test_unspecified_semantic_type_stays_permissive() -> None: +def test_unspecified_input_modality_stays_permissive() -> None: model = InferenceInputSchema( global_conditioning_fields=(InputField(name="prompt"),) ) @@ -415,7 +416,9 @@ def test_combining_mappings_unions_their_surfaces() -> None: combined = combine_mapping_schemas((PROMPT_MAPPING, STEERING_MAPPING)) assert {m.name for m in combined.consumes} == {"driver_command"} - assert [f.name for f in combined.produces_global_conditioning] == ["prompt"] + assert [f.name for f in combined.produces_global_conditioning] == [ + "prompt" + ] assert [f.name for f in combined.produces_step] == ["steering"] From 4964503aa70b9e4dccbb3bccb39e115572aa7449 Mon Sep 17 00:00:00 2001 From: Aidan Foster Date: Wed, 5 Aug 2026 20:06:02 -0700 Subject: [PATCH 13/30] Correct disagreements in docs from PR #413 --- docs/inference_runtime_api_design.md | 44 +++++++++++++++------------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/docs/inference_runtime_api_design.md b/docs/inference_runtime_api_design.md index b6314a205..d1365d77e 100644 --- a/docs/inference_runtime_api_design.md +++ b/docs/inference_runtime_api_design.md @@ -319,13 +319,16 @@ UserInputs -> CanonicalInputs -> InferenceInput raw canonicalized encoded ``` -Raw device events are canonicalized into device-independent modalities before an -application sees them, so adding a keyboard, gamepad, or wheel is a converter -registration rather than an application change. `InferenceInput` is what an -`InferenceSession` actually receives. +Raw device events for live control are canonicalized into device-independent +modalities before application or mapping logic consumes them, so adding a +keyboard, gamepad, or wheel is a converter registration rather than an +application change. Global conditioning is application-owned and reaches +`InferenceInput` directly; it does not pass through live device canonicalization. +`InferenceInput` is what an `InferenceSession` actually receives. -`InferenceInput` describes the data the model or inference pipeline actually -requires. Both it and `CanonicalInputs` distinguish two conditioning slots: +`CanonicalInputs` describes device-independent live control for one requested +input window. `InferenceInput` describes the data the model or inference +pipeline actually requires, split into two conditioning slots: - global conditioning: values that condition the whole rollout; - per-step conditioning: values needed for one generated chunk or frame window. @@ -348,11 +351,11 @@ Inference input payloads should use semantic names, not only modality names. For example, a first frame and an HD map frame should be distinct inputs even if both are image-like values. -Model input names, payload kinds, semantic-type hints, and schema metadata -should be open-ended. Supported integrations such as SANA-WM, LingBot, -Omnidreams, and future external adapters may need different semantic fields. -Adding a new model should usually mean adding adapter-owned schema declarations -and mappings, not changing a central FlashDreams enum. +Model input names, input modalities, and schema metadata should be open-ended. +Supported integrations such as SANA-WM, LingBot, Omnidreams, and future +external adapters may need different semantic fields. Adding a new model should +usually mean adding adapter-owned schema declarations and mappings, not changing +a central FlashDreams enum. Consumption cadence is a separate hint from input scope. A field may be provided through global conditioning because it is session-global state, while @@ -360,9 +363,10 @@ the adapter consumes or slices it during every step. That can be recorded as `frequency_consumed` metadata without changing whether the field belongs in `global_conditioning_fields` or `step_fields`. -For interactive runs, most `InferenceInput` values will be global conditioning -plus per-step inputs produced by input mapping. For MP4 generation and benchmarking, the API -should also support fixed per-step model inputs so runs can be deterministic. +For interactive runs, most `InferenceInput` values will be app-owned global +conditioning plus per-step inputs produced by input mapping. For MP4 generation +and benchmarking, the API should also support fixed per-step model inputs so +runs can be deterministic. ## Schemas @@ -385,8 +389,8 @@ Schema objects may carry open-ended metadata for query-time hints such as coordinate frame, units, rough shape summary, accepted file suffixes, schema URI, model family, or source/transport details. Metadata should help humans and adapter selection code, but compatibility should still be based on the declared -event capabilities, semantic model fields, payload representation hints, and -schema phases. Consumption-cadence hints are descriptive and adapter-owned. +event capabilities, semantic model fields, input modalities, and schema phases. +Consumption-cadence hints are descriptive and adapter-owned. For simple CLI text-to-video or image-to-video runs, `UserInputSchema` can be trivial or omitted because there may be no live controls. `InferenceInputSchema` is @@ -468,10 +472,10 @@ There are two separate moments to keep clear: - before runtime initialization, FlashDreams should select the mapping or mapper set and check obvious compatibility between the app event source and the model; -- during the standard loop, the runtime or runner queues and timestamps user - events, then uses the selected mapping to build initial or per-step - `InferenceInput` from the relevant event window, often after the session reports - what it needs next. +- during the standard loop, the runtime or runner passes app-owned global + `InferenceInput` through the selected mapping before session start, then + queues and timestamps user events, canonicalizes the session-requested window, + and uses the selected mapping to build per-step `InferenceInput`. This keeps the Reactor-style contract intact: the model-side integration can declare user inputs, declare model inputs, and provide a default mapping, while From cced4c743443f58bef16702647e4ea45c9478f75 Mon Sep 17 00:00:00 2001 From: Aidan Foster Date: Wed, 5 Aug 2026 21:32:39 -0700 Subject: [PATCH 14/30] Fix input canonicalization in tests --- .../tests/test_inference_runtime_api.py | 109 +++++++++++++++++- 1 file changed, 104 insertions(+), 5 deletions(-) diff --git a/flashdreams/tests/test_inference_runtime_api.py b/flashdreams/tests/test_inference_runtime_api.py index d454f6205..890d7efb9 100644 --- a/flashdreams/tests/test_inference_runtime_api.py +++ b/flashdreams/tests/test_inference_runtime_api.py @@ -3,6 +3,7 @@ from __future__ import annotations +from collections.abc import Mapping from dataclasses import fields from typing import Any, cast @@ -11,6 +12,8 @@ from flashdreams.runtime import ( CanonicalInputs, CanonicalInputSchema, + CanonicalModality, + DeviceConverterSchema, IdentityInputMapping, InferenceConfig, InferenceInput, @@ -325,6 +328,40 @@ def test_reference_loop_validates_mapping_before_runtime_creation() -> None: assert adapter.created_runtime_after_validate +def test_reference_loop_does_not_canonicalize_global_conditioning() -> None: + mapping = _CanonicalRecordingMapping() + adapter = _FakeAdapter() + canonicalizer = InputCanonicalizer([_CountingDeviceConverter()]) + source_schema = UserInputSchema( + capabilities=( + UserInputCapability( + event_type="stateful_event", + payload_fields=frozenset(), + ), + ) + ) + + _drive_two_step_session( + adapter=adapter, + config=InferenceConfig(model_id="fake-model"), + mapping=mapping, + canonicalizer=canonicalizer, + source_schema=source_schema, + user_inputs=UserInputs( + events=(UserInputEvent(timestamp_s=0.75, event_type="stateful_event"),) + ), + inference_input=InferenceInput(global_conditioning={"prompt": "drive forward"}), + output=NullOutputTarget(), + metrics=InMemoryMetricsRecorder(), + ) + + assert mapping.global_canonical_values == {} + assert mapping.step_canonical_values == ( + {"stateful_counter": {"count": 0}}, + {"stateful_counter": {"count": 1}}, + ) + + def test_reference_loop_closes_runtime_when_session_start_fails() -> None: adapter = _FailingStartAdapter() output = NullOutputTarget() @@ -367,12 +404,9 @@ def _drive_two_step_session( canonical_schema=adapter.canonical_input_schema, inference_input_schema=adapter.inference_input_schema, ) + canonicalizer.reset() initial_inputs = mapping.map_global_conditioning_inputs( - canonical_inputs=canonicalizer.canonicalize( - user_inputs, - window=TimeWindow(start_s=0.0, end_s=_SESSION_HORIZON_S), - source_schema=source_schema, - ), + canonical_inputs=CanonicalInputs(), inference_input=inference_input, ) runtime = adapter.create_runtime(config) @@ -510,6 +544,71 @@ def validate( self.validated = True +class _CanonicalRecordingMapping(IdentityInputMapping): + def __init__(self) -> None: + self.global_canonical_values: Mapping[str, Any] | None = None + self._step_canonical_values: list[Mapping[str, Any]] = [] + + @property + def step_canonical_values(self) -> tuple[Mapping[str, Any], ...]: + return tuple(self._step_canonical_values) + + def map_global_conditioning_inputs( + self, + *, + canonical_inputs: CanonicalInputs, + inference_input: InferenceInput, + ) -> InferenceInput: + self.global_canonical_values = canonical_inputs.values + return super().map_global_conditioning_inputs( + canonical_inputs=canonical_inputs, + inference_input=inference_input, + ) + + def map_step_inputs( + self, + *, + canonical_inputs: CanonicalInputs, + inference_input: InferenceInput, + request: StepRequest, + ) -> InferenceInput: + self._step_canonical_values.append(canonical_inputs.values) + return super().map_step_inputs( + canonical_inputs=canonical_inputs, + inference_input=inference_input, + request=request, + ) + + +_STATEFUL_COUNTER = CanonicalModality( + name="stateful_counter", + payload_fields=frozenset({"count"}), +) + + +class _CountingDeviceConverter: + schema = DeviceConverterSchema( + name="stateful-counter", + produces=_STATEFUL_COUNTER, + consumes=(UserInputCapability(event_type="stateful_event"),), + ) + + def __init__(self) -> None: + self.count = 0 + + def reset(self) -> None: + self.count = 0 + + def convert( + self, + user_inputs: UserInputs, + window: TimeWindow, + ) -> Mapping[str, Any] | None: + del window + self.count += len(user_inputs.events) + return _STATEFUL_COUNTER.value({"count": self.count}) + + class _OrderCheckingAdapter(_FakeAdapter): canonical_input_schema = CanonicalInputSchema() From 8610c0f1d03e3366762f45c9e210bde7b99a7ba5 Mon Sep 17 00:00:00 2001 From: jarcherNV Date: Thu, 6 Aug 2026 02:40:21 -0700 Subject: [PATCH 15/30] Add experimental inference runtime and shared demo API (#422) Introduce the experimental runtime/session/input envelopes and a shared demo-level API for replay and WebRTC flows. Add the shared runner, output target plumbing, fake-model coverage, and benchmark hooks. Port OmniDreams replay and WebRTC onto the shared demo path via a thin model-owned adapter, add local/remote validation docs, and update the migration plan to track remaining output/stat work and legacy demo cleanup. --- .../omnidreams_demo_replay_benchmarks.json | 88 +++ docs/inference_runtime_api_design.md | 64 +- ...inference_runtime_inputs_implementation.md | 13 +- .../developer_guides/local_benchmarks.rst | 21 + flashdreams/flashdreams/runtime/__init__.py | 4 + .../flashdreams/runtime/demo/__init__.py | 31 + flashdreams/flashdreams/runtime/demo/app.py | 36 + .../flashdreams/runtime/demo/outputs.py | 45 ++ .../flashdreams/runtime/demo/replay.py | 93 +++ flashdreams/flashdreams/runtime/demo/spec.py | 176 +++++ .../flashdreams/runtime/demo/webrtc.py | 274 +++++++ flashdreams/flashdreams/runtime/mapping.py | 3 +- flashdreams/flashdreams/runtime/runner.py | 199 +++++ .../flashdreams/runtime/video_output.py | 157 ++++ flashdreams/tests/test_benchmark_harness.py | 44 ++ .../tests/test_inference_runtime_api.py | 391 ---------- flashdreams/tests/test_runtime_demo_api.py | 458 +++++++++++ .../tests/test_runtime_input_mapping.py | 30 +- flashdreams/tests/test_runtime_runner.py | 660 ++++++++++++++++ .../tests/test_runtime_video_output.py | 91 +++ .../omnidreams/omnidreams/demo/README.md | 66 ++ .../omnidreams/omnidreams/demo/__init__.py | 20 + .../omnidreams/omnidreams/demo/adapter.py | 279 +++++++ .../omnidreams/omnidreams/demo/cli.py | 187 +++++ .../omnidreams/omnidreams/demo/replay.py | 277 +++++++ .../omnidreams/omnidreams/demo/spec.py | 271 +++++++ .../omnidreams/omnidreams/demo/webrtc.py | 178 +++++ integrations/omnidreams/pyproject.toml | 7 +- .../omnidreams/tests/test_demo_api.py | 577 ++++++++++++++ uv.lock | 720 +----------------- 30 files changed, 4313 insertions(+), 1147 deletions(-) create mode 100644 configs/omnidreams_demo_replay_benchmarks.json create mode 100644 flashdreams/flashdreams/runtime/demo/__init__.py create mode 100644 flashdreams/flashdreams/runtime/demo/app.py create mode 100644 flashdreams/flashdreams/runtime/demo/outputs.py create mode 100644 flashdreams/flashdreams/runtime/demo/replay.py create mode 100644 flashdreams/flashdreams/runtime/demo/spec.py create mode 100644 flashdreams/flashdreams/runtime/demo/webrtc.py create mode 100644 flashdreams/flashdreams/runtime/runner.py create mode 100644 flashdreams/flashdreams/runtime/video_output.py create mode 100644 flashdreams/tests/test_runtime_demo_api.py create mode 100644 flashdreams/tests/test_runtime_runner.py create mode 100644 flashdreams/tests/test_runtime_video_output.py create mode 100644 integrations/omnidreams/omnidreams/demo/README.md create mode 100644 integrations/omnidreams/omnidreams/demo/__init__.py create mode 100644 integrations/omnidreams/omnidreams/demo/adapter.py create mode 100644 integrations/omnidreams/omnidreams/demo/cli.py create mode 100644 integrations/omnidreams/omnidreams/demo/replay.py create mode 100644 integrations/omnidreams/omnidreams/demo/spec.py create mode 100644 integrations/omnidreams/omnidreams/demo/webrtc.py create mode 100644 integrations/omnidreams/tests/test_demo_api.py diff --git a/configs/omnidreams_demo_replay_benchmarks.json b/configs/omnidreams_demo_replay_benchmarks.json new file mode 100644 index 000000000..3ac6e4019 --- /dev/null +++ b/configs/omnidreams_demo_replay_benchmarks.json @@ -0,0 +1,88 @@ +{ + "schema_version": 1, + "description": "Manual one-minute local benchmark scenarios for comparing the legacy Omnidreams single-view runner against the experimental shared demo replay path. The runner writes the legacy stacked HDMap/RGB canvas while the shared demo writes generated RGB output, so use the report for manual MP4 comparison rather than automatic pixel quality scoring.", + "scenarios": [ + { + "id": "omnidreams-sv-runner-baseline", + "name": "Omnidreams single-view runner baseline", + "description": "Runs the stable legacy Omnidreams single-view runner with the bundled example data for the same one-minute block count used by the shipped Omnidreams baseline.", + "report_group": { + "id": "omnidreams-demo", + "name": "Omnidreams Demo Comparison" + }, + "tags": [ + "manual", + "gpu", + "real-demo", + "omnidreams", + "i2v", + "replay", + "baseline" + ], + "env": { + "CUBLAS_WORKSPACE_CONFIG": ":4096:8", + "PYTORCH_CUDA_ALLOC_CONF": "expandable_segments:True" + }, + "command": [ + "uv", + "run", + "--project", + "integrations/omnidreams", + "flashdreams-run", + "omnidreams-sv-2steps-chunk2-loc6-lightvae-lighttae", + "--example-data", + "True", + "--example-data-uuid", + "239560dc-33d1-11ef-9720-00044bcbccac", + "--total-blocks", + "226" + ], + "warmup_steps": 1, + "quality_baseline_compare": false, + "timeout_s": 7200 + }, + { + "id": "omnidreams-sv-demo-replay", + "name": "Omnidreams shared demo replay", + "description": "Runs the experimental shared demo API replay path with the same stable non-perf preset, bundled example data, and one-minute block count as the legacy runner.", + "report_group": { + "id": "omnidreams-demo", + "name": "Omnidreams Demo Comparison" + }, + "tags": [ + "manual", + "gpu", + "real-demo", + "omnidreams", + "i2v", + "replay", + "shared-demo" + ], + "env": { + "CUBLAS_WORKSPACE_CONFIG": ":4096:8", + "PYTORCH_CUDA_ALLOC_CONF": "expandable_segments:True" + }, + "command": [ + "uv", + "run", + "--project", + "integrations/omnidreams", + "omnidreams-demo", + "replay", + "--preset-id", + "omnidreams-sv-2steps-chunk2-loc6-lightvae-lighttae", + "--example-data", + "--example-data-uuid", + "239560dc-33d1-11ef-9720-00044bcbccac", + "--total-blocks", + "226", + "--output", + "{output_dir}/omnidreams-sv-demo-replay.mp4" + ], + "output_dir_arg": null, + "warmup_steps": 1, + "quality_baseline_compare": false, + "timeout_s": 7200 + } + ] +} diff --git a/docs/inference_runtime_api_design.md b/docs/inference_runtime_api_design.md index d1365d77e..6bd1018d9 100644 --- a/docs/inference_runtime_api_design.md +++ b/docs/inference_runtime_api_design.md @@ -44,22 +44,24 @@ model. ## Current Implementation Plan Implementation should happen on an experimental integration branch. PRs for this -work should target that branch until the API shape, LingBot migration, and -OmniDreams migration are all working well enough to merge to `main` together. +work should target that branch until the API shape and OmniDreams migration are +working well enough to merge to `main` together. LingBot migration is deferred +to a separate follow-up after the OmniDreams path has clarified the shared demo +API shape. The experimental branch can temporarily break or simplify command-line options -while the demos are being moved to the new API. The required outcome is that the -LingBot and OmniDreams demos still run through the new runtime path, and that -benchmark tooling can confirm they are at least broadly healthy before the -branch is merged back to `main`. +while the demos are being moved to the new API. The required outcome for this +branch is that the OmniDreams demo runs through the new shared demo/runtime path, +and that benchmark and manual WebRTC checks can confirm it is at least broadly +healthy before the branch is merged back to `main`. Initial scope: - define the minimal runtime API envelope; -- migrate LingBot and OmniDreams to use it; +- migrate OmniDreams to use it through a shared demo-level API; - support selectable output modes such as MP4, JPEG/MJPEG stream, WebRTC, and headless/null where appropriate; -- use or update benchmark tooling to verify the migrated demos; +- use or update benchmark tooling to verify the migrated OmniDreams demo; - defer broader model migrations, hosted execution, full autotune, and polished metrics until the first branch proves the API shape. @@ -71,14 +73,30 @@ Initial scope: | T1 | Complete | Minimal API envelope and naming. | Partly. | T0. | `InferenceConfig`, `UserInputs`, `InferenceInput`, runtime/session, output target, and mapping boundaries are defined well enough for demos to use. | | T2 | Complete | Event-based `UserInputs`. | Yes, after T1 direction is agreed. | T1. | User inputs are primarily timestamped events; replay traces and derived snapshots are supported where needed. | | T3 | Complete | `CanonicalInputs`, `InferenceInput`, schemas, and mapping boundary. | Yes, after T1 direction is agreed. | T1. | Models can declare required global/per-step inputs, and mappings can convert canonical inputs into inference inputs. | -| T4 | Planned | `ModelRunner`, `InferenceRuntime`, and `InferenceSession` skeleton. | Partly. | T1. | A minimal standard loop can initialize a runtime, run at least one sequential session, and close cleanly. | +| T4 | Complete | `ModelRunner`, `InferenceRuntime`, and `InferenceSession` skeleton. | Partly. | T1. | A minimal standard loop can initialize a runtime, run at least one sequential session, and close cleanly. | | T5 | Planned | Output mode selection. | Yes, after the result/output shape is agreed. | T1, T4. | A run can choose output behavior such as MP4, JPEG/MJPEG stream, WebRTC, benchmark artifact, or headless/null without changing model code. | -| T6 | Planned | LingBot migration. | Yes, once T2-T4 have a usable skeleton. | T2, T3, T4. | LingBot runs through the new API path with its event inputs mapped into model inputs. | -| T7 | Planned | OmniDreams migration. | Yes, once T2-T4 have a usable skeleton. | T2, T3, T4. | OmniDreams runs through the new API path with its model-specific inputs and mapping preserved. | -| T8 | Planned | Benchmark/smoke verification for LingBot and OmniDreams. | Preparation can run early; final gate is late. | T5, T6, T7. | Existing or updated benchmark tooling can run both migrated demos and produce enough evidence that they still work. | +| T6 | Deferred | LingBot migration. | Yes, but out of scope for this branch. | T2, T3, T4. | LingBot runs through the new API path with its event inputs mapped into model inputs. | +| T7 | Partially complete | OmniDreams migration. | Yes, once T2-T4 have a usable skeleton. | T2, T3, T4. | OmniDreams replay and WebRTC run through the shared demo API path; remaining work is output/stat integration, legacy demo retirement, and cleanup. | +| T8 | Partially complete | Benchmark/smoke verification for OmniDreams. | Preparation can run early; final gate is late. | T5, T7. | Existing or updated benchmark tooling can run the migrated OmniDreams demo and produce enough evidence that it still works. | | T9 | Planned | Metrics and profiling normalization for the branch. | Yes, but final integration is late. | T4, T5, T8. | Basic canonical metrics are emitted for migrated demos; deeper metrics can remain follow-up work. | -| T10 | Planned | CLI compatibility and migration cleanup. | Yes, after demo migrations start. | T6, T7. | Required demo commands are restored or replaced, temporary hacks are removed, and user-facing docs/notes match the branch behavior. | -| T11 | Planned | Stabilize and merge experimental branch to `main`. | No, final integration step. | T6-T10. | LingBot and OmniDreams pass agreed smoke/benchmark checks, review feedback is addressed, and the branch can merge as one API transition. | +| T10 | Planned | CLI compatibility, legacy retirement, and migration cleanup. | Yes, after demo migrations start. | T5, T7, T8. | Required demo commands are restored or replaced, old interactive-drive and old OmniDreams demo/server paths are removed or reduced to compatibility shims, code used only by retired demos is removed, and user-facing docs/notes match the branch behavior. | +| T11 | Planned | Stabilize and merge experimental branch to `main`. | No, final integration step. | T5, T7-T10. | OmniDreams passes agreed smoke/benchmark checks, review feedback is addressed, and the branch can merge as one API transition. | + +Current OmniDreams migration status: + +- The shared `flashdreams.runtime.demo` API and OmniDreams demo adapter exist. +- OmniDreams MP4 replay runs through the shared replay runner and MP4 output + target. +- The one-minute benchmark comparison can run the legacy replay path and the new + shared demo replay path side by side. +- OmniDreams WebRTC runs through `serve_flashdreams_demo(...)` and the shared + WebRTC manager path while still using the existing OmniDreams runtime and + packaged browser app. +- The migration is not complete until the new output target/stat artifact work + lands, the new OmniDreams path is updated to use it, the old interactive-drive + and old OmniDreams demo/server paths are removed or reduced to deliberate + compatibility shims, code used only by retired demos is deleted, and the + experimental demo/runtime/input code is cleaned up. Suggested parallel split: @@ -88,7 +106,8 @@ Suggested parallel split: stay coherent; - one person owns T5/T8/T9, because outputs, benchmarks, and metrics are tightly related; -- LingBot and OmniDreams can be assigned separately once the skeleton is usable; +- LingBot should be tracked as a separate follow-up once OmniDreams has settled + the shared demo API shape; - one person should track branch health, CLI compatibility, and merge readiness. ## Architecture @@ -510,6 +529,11 @@ adapter/runtime still owns deep tensor validation and model semantics. The standard loop should be shared by CLI generation, headless playback, MP4 generation, benchmarks, and simple realtime applications. +The current v0 production loop is `flashdreams.runtime.run_inference_session()`. +It is intentionally narrow: one adapter, one config, one canonicalizer/source, +one selected mapping, one initial input, one output target, one metrics +recorder, and one synchronous sequential session. + A run should: 1. Discover the model or preset without loading checkpoints. @@ -645,9 +669,11 @@ The new API should reuse existing code instead of replacing everything: The task tracker near the start of this document is the source of truth for the first implementation branch. The first milestone is intentionally narrower than -the full design: prove the API with LingBot and OmniDreams, selectable output -modes, and enough benchmark/smoke coverage to merge the experimental branch -back to `main` safely. +the full design: prove the API with OmniDreams, add shared output/stat artifact +selection, retire the old OmniDreams demo paths, clean up the experimental +runtime/demo code, and collect enough benchmark/smoke evidence to merge the +experimental branch back to `main` safely. LingBot should be handled in a +separate follow-up plan. ## Design Risks @@ -709,7 +735,7 @@ registry, standard loop, concrete output modes, or model migrations: registering it? - What package registration mechanism should third-party and internal adapters use for CLI discovery and benchmarks? -- What is the first public model to migrate? +- Which model should migrate after OmniDreams settles the shared demo API shape? - What metrics are required for every benchmark run? - What metadata must be discoverable without loading checkpoints? - What requirements do Dynamo/Reactor-style backends need before we commit to diff --git a/docs/inference_runtime_inputs_implementation.md b/docs/inference_runtime_inputs_implementation.md index 763b9810c..75460cced 100644 --- a/docs/inference_runtime_inputs_implementation.md +++ b/docs/inference_runtime_inputs_implementation.md @@ -18,8 +18,9 @@ Implementation lives in `flashdreams.runtime`: and compatibility - `flashdreams/tests/test_runtime_canonical.py` - `flashdreams/tests/test_runtime_input_mapping.py` -- `flashdreams/tests/test_inference_runtime_api.py` — the T1 envelope tests, - including a reference loop that exercises all three layers +- `flashdreams/tests/test_inference_runtime_api.py` — the T1 envelope tests +- `flashdreams/tests/test_runtime_runner.py` — the production standard loop + tests that exercise all three input layers with runtime/session cleanup The supported-model input inventory that informed this work is in `docs/inference_runtime_supported_inputs_inventory.md`. @@ -269,8 +270,9 @@ layer: to `OutputTarget.write()`. Output shape is T5. - **Declared output modalities**, so an output target or quality-eval can state what it requires and be matched the way inputs now are. T5/T8. -- **`Application`**, the class that has-a input system, input map, global - conditioning, session, and output target. T4. +- **Full `Application` ownership**, the class that has-a input system, input + map, global conditioning, session, and output target. T4 now provides the + narrow synchronous runner; richer application ownership remains outside T4. - **Loop ownership** — whether the application or the runtime/session drives the main event loop, and whether inputs are queued and batched. @@ -279,7 +281,8 @@ layer: ```bash .venv/bin/pytest flashdreams/tests/test_runtime_canonical.py \ flashdreams/tests/test_runtime_input_mapping.py \ - flashdreams/tests/test_inference_runtime_api.py -q + flashdreams/tests/test_inference_runtime_api.py \ + flashdreams/tests/test_runtime_runner.py -q .venv/bin/ty check flashdreams/flashdreams/runtime ``` diff --git a/docs/source/developer_guides/local_benchmarks.rst b/docs/source/developer_guides/local_benchmarks.rst index 687d95aa2..62842ba32 100644 --- a/docs/source/developer_guides/local_benchmarks.rst +++ b/docs/source/developer_guides/local_benchmarks.rst @@ -132,6 +132,27 @@ input stream is shorter than the requested duration. ``interactive-drive`` is left out of this shipped MP4 suite for now because its public CLI is a live presenter rather than a file-writing runner. +Omnidreams Shared Demo Comparison +--------------------------------- + +``configs/omnidreams_demo_replay_benchmarks.json`` contains a one-minute manual +comparison between the legacy Omnidreams single-view runner and the experimental +shared demo replay path: + +.. code-block:: bash + + uv run flashdreams-benchmark \ + --scenario-file configs/omnidreams_demo_replay_benchmarks.json \ + --scenario omnidreams-sv-runner-baseline \ + --scenario omnidreams-sv-demo-replay \ + --output-dir artifacts/benchmarks/omnidreams-demo-replay-compare + +Use the generated report's MP4 links for side-by-side manual review. The legacy +runner writes the stacked HDMap/RGB canvas while the shared demo writes generated +RGB output, so this comparison intentionally disables automatic baseline quality +scoring until those output layouts are aligned. Both scenarios use ``226`` +blocks, matching the shipped Omnidreams one-minute baseline. + Quality Hooks ------------- diff --git a/flashdreams/flashdreams/runtime/__init__.py b/flashdreams/flashdreams/runtime/__init__.py index 04f84ae54..5f89196ba 100644 --- a/flashdreams/flashdreams/runtime/__init__.py +++ b/flashdreams/flashdreams/runtime/__init__.py @@ -56,7 +56,9 @@ RuntimeMetricSample, ) from flashdreams.runtime.output import NullOutputTarget, OutputArtifact, OutputTarget +from flashdreams.runtime.runner import run_inference_session from flashdreams.runtime.types import StepRequest, StepResult +from flashdreams.runtime.video_output import Mp4VideoOutputTarget __all__ = [ "CanonicalInputs", @@ -88,6 +90,7 @@ "MappingCompatibility", "MetricsRecorder", "ModelAdapter", + "Mp4VideoOutputTarget", "NullMetricsRecorder", "NullOutputTarget", "OutputArtifact", @@ -98,6 +101,7 @@ "StepRequest", "StepResult", "TimeWindow", + "run_inference_session", "undeclared_inference_inputs", "UserInputCapability", "UserInputEvent", diff --git a/flashdreams/flashdreams/runtime/demo/__init__.py b/flashdreams/flashdreams/runtime/demo/__init__.py new file mode 100644 index 000000000..3d9d99919 --- /dev/null +++ b/flashdreams/flashdreams/runtime/demo/__init__.py @@ -0,0 +1,31 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Experimental shared demo API above the inference runtime API.""" + +from flashdreams.runtime.demo.app import run_flashdreams_demo, serve_flashdreams_demo +from flashdreams.runtime.demo.outputs import build_output_target +from flashdreams.runtime.demo.replay import run_replay_demo +from flashdreams.runtime.demo.spec import ( + DemoAdapter, + DemoSpec, + Mp4OutputSpec, + NullOutputSpec, + OutputSpec, + PreparedScenario, + WebRTCOutputSpec, +) + +__all__ = [ + "DemoAdapter", + "DemoSpec", + "Mp4OutputSpec", + "NullOutputSpec", + "OutputSpec", + "PreparedScenario", + "WebRTCOutputSpec", + "build_output_target", + "run_flashdreams_demo", + "run_replay_demo", + "serve_flashdreams_demo", +] diff --git a/flashdreams/flashdreams/runtime/demo/app.py b/flashdreams/flashdreams/runtime/demo/app.py new file mode 100644 index 000000000..7659859b4 --- /dev/null +++ b/flashdreams/flashdreams/runtime/demo/app.py @@ -0,0 +1,36 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Experimental shared demo entrypoints.""" + +from __future__ import annotations + +from typing import Any + +from .replay import run_replay_demo +from .spec import DemoAdapter, DemoSpec + + +def run_flashdreams_demo( + *, + spec: DemoSpec, + adapter: DemoAdapter, + **kwargs: Any, +) -> object: + """Run a synchronous replay demo through the shared runtime runner.""" + return run_replay_demo(spec=spec, adapter=adapter, **kwargs) + + +def serve_flashdreams_demo( + *, + spec: DemoSpec, + adapter: DemoAdapter, + **kwargs: Any, +) -> object: + """Serve a WebRTC demo through the shared serving manager.""" + from .webrtc import serve_webrtc_demo + + return serve_webrtc_demo(spec=spec, adapter=adapter, **kwargs) + + +__all__ = ["run_flashdreams_demo", "serve_flashdreams_demo"] diff --git a/flashdreams/flashdreams/runtime/demo/outputs.py b/flashdreams/flashdreams/runtime/demo/outputs.py new file mode 100644 index 000000000..421ec3bb4 --- /dev/null +++ b/flashdreams/flashdreams/runtime/demo/outputs.py @@ -0,0 +1,45 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Shared demo output-target construction.""" + +from __future__ import annotations + +from pathlib import Path + +from flashdreams.runtime.output import NullOutputTarget, OutputTarget +from flashdreams.runtime.video_output import Mp4VideoOutputTarget, VideoWriter + +from .spec import Mp4OutputSpec, NullOutputSpec, OutputSpec, WebRTCOutputSpec + + +def build_output_target( + output: OutputSpec, + *, + mp4_writer: VideoWriter | None = None, +) -> OutputTarget: + """Build a replay output target from a demo output spec.""" + if isinstance(output, NullOutputSpec): + return NullOutputTarget(store_results=output.store_results) + if isinstance(output, Mp4OutputSpec): + output_path = Path(output.path) + if mp4_writer is not None: + return Mp4VideoOutputTarget( + output_path=output_path, + fps=output.fps, + output_layout=output.output_layout, + writer=mp4_writer, + move_to_cpu=output.move_to_cpu, + ) + return Mp4VideoOutputTarget( + output_path=output_path, + fps=output.fps, + output_layout=output.output_layout, + move_to_cpu=output.move_to_cpu, + ) + if isinstance(output, WebRTCOutputSpec): + raise ValueError("WebRTC output does not create a replay OutputTarget.") + raise TypeError(f"Unsupported demo output spec: {type(output).__name__}.") + + +__all__ = ["build_output_target"] diff --git a/flashdreams/flashdreams/runtime/demo/replay.py b/flashdreams/flashdreams/runtime/demo/replay.py new file mode 100644 index 000000000..18b873254 --- /dev/null +++ b/flashdreams/flashdreams/runtime/demo/replay.py @@ -0,0 +1,93 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Shared replay demo runner.""" + +from __future__ import annotations + +from collections.abc import Callable, Sequence + +from flashdreams.runtime.metrics import MetricsRecorder, NullMetricsRecorder +from flashdreams.runtime.output import OutputArtifact, OutputTarget +from flashdreams.runtime.runner import run_inference_session + +from .outputs import build_output_target +from .spec import DemoAdapter, DemoSpec, OutputSpec, WebRTCOutputSpec + +OutputTargetFactory = Callable[[OutputSpec], OutputTarget] +InferenceSessionRunner = Callable[..., Sequence[OutputArtifact]] + + +def run_replay_demo( + *, + spec: DemoSpec, + adapter: DemoAdapter, + output_target_factory: OutputTargetFactory = build_output_target, + metrics: MetricsRecorder | None = None, + runner: InferenceSessionRunner = run_inference_session, +) -> tuple[OutputArtifact, ...]: + """Run one prepared demo scenario through the shared runtime runner.""" + _require_supported_mode( + mode=spec.input_mode, + supported=adapter.supported_input_modes(), + label="input_mode", + ) + if spec.input_mode != "replay": + raise ValueError( + "run_replay_demo requires input_mode='replay', " + f"got input_mode={spec.input_mode!r}." + ) + _require_supported_mode( + mode=spec.output.mode, + supported=adapter.supported_output_modes(), + label="output.mode", + ) + if isinstance(spec.output, WebRTCOutputSpec): + raise ValueError("run_replay_demo does not support WebRTC output.") + + prepared = adapter.prepare_scenario(spec) + mapping = prepared.mapping or adapter.default_input_mapping() + if mapping is None: + raise ValueError( + "Demo scenario did not provide an input mapping, and the adapter " + "has no default input mapping." + ) + if spec.config is None: + raise RuntimeError("DemoSpec.config was not initialized.") + + output = output_target_factory(spec.output) + metrics_recorder = metrics or NullMetricsRecorder() + return tuple( + runner( + adapter=adapter, + config=spec.config, + mapping=mapping, + canonicalizer=prepared.canonicalizer, + source_schema=prepared.source_schema, + user_inputs=prepared.user_inputs, + initial_inputs=prepared.initial_inputs, + output=output, + metrics=metrics_recorder, + ) + ) + + +def _require_supported_mode( + *, + mode: str, + supported: tuple[str, ...], + label: str, +) -> None: + if mode in supported: + return + supported_text = ", ".join(repr(each) for each in supported) or "" + raise ValueError( + f"Unsupported demo {label}={mode!r}; supported modes: {supported_text}." + ) + + +__all__ = [ + "InferenceSessionRunner", + "OutputTargetFactory", + "run_replay_demo", +] diff --git a/flashdreams/flashdreams/runtime/demo/spec.py b/flashdreams/flashdreams/runtime/demo/spec.py new file mode 100644 index 000000000..bc2884ab3 --- /dev/null +++ b/flashdreams/flashdreams/runtime/demo/spec.py @@ -0,0 +1,176 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Experimental shared demo API data shapes.""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass, field, replace +from pathlib import Path +from typing import Any, Literal, Protocol, TypeAlias + +from flashdreams.infra.postprocess import VideoTensorLayout +from flashdreams.runtime._utils import freeze_mapping +from flashdreams.runtime.canonical import InputCanonicalizer +from flashdreams.runtime.config import InferenceConfig +from flashdreams.runtime.inputs import InferenceInput, UserInputs, UserInputSchema +from flashdreams.runtime.interfaces import ModelAdapter +from flashdreams.runtime.mapping import InputMapping + + +@dataclass(frozen=True, kw_only=True, slots=True) +class NullOutputSpec: + """Headless/null replay output.""" + + mode: Literal["null"] = "null" + store_results: bool = False + + +@dataclass(frozen=True, kw_only=True, slots=True) +class Mp4OutputSpec: + """MP4 replay output.""" + + path: str | Path + fps: int | float + mode: Literal["mp4"] = "mp4" + output_layout: VideoTensorLayout = "bvtchw" + move_to_cpu: bool = True + + def __post_init__(self) -> None: + if float(self.fps) <= 0: + raise ValueError("Mp4OutputSpec.fps must be > 0.") + object.__setattr__(self, "path", Path(self.path)) + + +@dataclass(frozen=True, kw_only=True, slots=True) +class WebRTCOutputSpec: + """Shared WebRTC serving output.""" + + mode: Literal["webrtc"] = "webrtc" + host: str = "127.0.0.1" + port: int = 8080 + fps: int = 30 + video_width: int = 1280 + video_height: int = 720 + warmup_chunks: int = 0 + warmup_timeout_s: float = 30.0 + client_liveness_timeout_s: float = 30.0 + web_dir: str | Path | None = None + request_session_path: str = "/request_session" + preload_name: str | None = None + + def __post_init__(self) -> None: + if not self.host.strip(): + raise ValueError("WebRTCOutputSpec.host must be non-empty.") + if not (0 < int(self.port) < 65536): + raise ValueError("WebRTCOutputSpec.port must be between 1 and 65535.") + if self.fps <= 0: + raise ValueError("WebRTCOutputSpec.fps must be > 0.") + if self.video_width <= 0 or self.video_height <= 0: + raise ValueError("WebRTCOutputSpec video dimensions must be > 0.") + if self.warmup_chunks < 0: + raise ValueError("WebRTCOutputSpec.warmup_chunks must be >= 0.") + if self.warmup_timeout_s <= 0: + raise ValueError("WebRTCOutputSpec.warmup_timeout_s must be > 0.") + if self.client_liveness_timeout_s <= 0: + raise ValueError("WebRTCOutputSpec.client_liveness_timeout_s must be > 0.") + if not self.request_session_path.startswith("/"): + raise ValueError( + "WebRTCOutputSpec.request_session_path must start with '/'." + ) + if self.web_dir is not None: + object.__setattr__(self, "web_dir", Path(self.web_dir)) + + +OutputSpec: TypeAlias = NullOutputSpec | Mp4OutputSpec | WebRTCOutputSpec + + +@dataclass(frozen=True, kw_only=True, slots=True) +class DemoSpec: + """User-facing shared demo run description.""" + + __hash__ = None + + model_id: str + input_mode: str + output: OutputSpec + preset_id: str | None = None + scenario: Any | None = None + config: InferenceConfig | None = None + metadata: Mapping[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + if not self.model_id.strip(): + raise ValueError("DemoSpec.model_id must be non-empty.") + if not self.input_mode.strip(): + raise ValueError("DemoSpec.input_mode must be non-empty.") + config = self.config + if config is None: + config = InferenceConfig( + model_id=self.model_id, + preset_id=self.preset_id, + ) + else: + if config.model_id != self.model_id: + raise ValueError( + "DemoSpec.model_id must match InferenceConfig.model_id." + ) + if self.preset_id is None: + object.__setattr__(self, "preset_id", config.preset_id) + elif config.preset_id is None: + config = replace(config, preset_id=self.preset_id) + elif config.preset_id != self.preset_id: + raise ValueError( + "DemoSpec.preset_id must match InferenceConfig.preset_id." + ) + object.__setattr__(self, "config", config) + object.__setattr__(self, "metadata", freeze_mapping(self.metadata)) + + +@dataclass(frozen=True, kw_only=True, slots=True) +class PreparedScenario: + """Runtime-ready scenario prepared by a model demo adapter.""" + + __hash__ = None + + initial_inputs: InferenceInput + user_inputs: UserInputs = field(default_factory=UserInputs) + source_schema: UserInputSchema = field(default_factory=UserInputSchema) + canonicalizer: InputCanonicalizer = field(default_factory=InputCanonicalizer) + mapping: InputMapping | None = None + metadata: Mapping[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + object.__setattr__(self, "metadata", freeze_mapping(self.metadata)) + + +class DemoAdapter(ModelAdapter, Protocol): + """Model-owned adapter surface consumed by shared demo launchers.""" + + def supported_input_modes(self) -> tuple[str, ...]: + """Return demo input modes this adapter can prepare.""" + ... + + def supported_output_modes(self) -> tuple[str, ...]: + """Return demo output modes this adapter can run.""" + ... + + def prepare_scenario(self, spec: DemoSpec) -> PreparedScenario: + """Validate and materialize scenario inputs before runtime creation.""" + ... + + def create_webrtc_runtime(self, spec: DemoSpec) -> Any: + """Create the model-owned runtime consumed by the shared WebRTC manager.""" + ... + + +__all__ = [ + "DemoAdapter", + "DemoSpec", + "Mp4OutputSpec", + "NullOutputSpec", + "OutputSpec", + "PreparedScenario", + "WebRTCOutputSpec", +] diff --git a/flashdreams/flashdreams/runtime/demo/webrtc.py b/flashdreams/flashdreams/runtime/demo/webrtc.py new file mode 100644 index 000000000..f93a855db --- /dev/null +++ b/flashdreams/flashdreams/runtime/demo/webrtc.py @@ -0,0 +1,274 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Shared WebRTC demo construction.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from aiohttp import web + +from flashdreams.serving.webrtc.bootstrap import run_webrtc_server +from flashdreams.serving.webrtc.manager import BaseWebRTCSessionManager +from flashdreams.serving.webrtc.server import create_webrtc_app + +from .replay import _require_supported_mode +from .spec import DemoAdapter, DemoSpec, WebRTCOutputSpec + + +@dataclass(frozen=True, kw_only=True, slots=True) +class WebRTCDemoRuntimeConfig: + """Runtime config consumed by the shared WebRTC session manager.""" + + video_width: int + video_height: int + warmup_chunks: int + warmup_timeout_s: float + + +class SharedDemoWebRTCSessionManager(BaseWebRTCSessionManager[Any, Any]): + """Generic session manager wrapper for demo adapters.""" + + def __init__( + self, + *, + model_name: str, + runtime: Any, + runtime_config: Any, + fps: int, + client_liveness_timeout_s: float, + ) -> None: + self._demo_model_name = model_name + super().__init__( + runtime=runtime, + runtime_config=runtime_config, + fps=fps, + client_liveness_timeout_s=client_liveness_timeout_s, + ) + + def _model_name(self) -> str: + return self._demo_model_name + + +@dataclass(frozen=True, kw_only=True, slots=True) +class WebRTCDemo: + """Constructed WebRTC demo pieces, before or after serving.""" + + runtime: Any + runtime_config: Any + session_manager: BaseWebRTCSessionManager[Any, Any] + app: web.Application | None + host: str + port: int + + +CreateWebRTCApp = Callable[..., web.Application] +RunWebRTCServer = Callable[..., None] + + +def build_webrtc_demo( + *, + spec: DemoSpec, + adapter: DemoAdapter, + create_app: bool = False, + create_app_fn: CreateWebRTCApp = create_webrtc_app, +) -> WebRTCDemo: + """Build shared WebRTC manager/app pieces for a demo adapter runtime.""" + if not isinstance(spec.output, WebRTCOutputSpec): + raise ValueError("build_webrtc_demo requires WebRTCOutputSpec output.") + _require_supported_mode( + mode=spec.input_mode, + supported=adapter.supported_input_modes(), + label="input_mode", + ) + _require_supported_mode( + mode=spec.output.mode, + supported=adapter.supported_output_modes(), + label="output.mode", + ) + + output = spec.output + runtime = adapter.create_webrtc_runtime(spec) + runtime_config = _create_runtime_config( + spec=spec, + adapter=adapter, + runtime=runtime, + ) + manager = _create_session_manager( + spec=spec, + adapter=adapter, + runtime=runtime, + runtime_config=runtime_config, + fps=output.fps, + client_liveness_timeout_s=output.client_liveness_timeout_s, + ) + app = ( + _create_app( + spec=spec, + adapter=adapter, + session_manager=manager, + create_app_fn=create_app_fn, + ) + if create_app + else None + ) + return WebRTCDemo( + runtime=runtime, + runtime_config=runtime_config, + session_manager=manager, + app=app, + host=output.host, + port=output.port, + ) + + +def serve_webrtc_demo( + *, + spec: DemoSpec, + adapter: DemoAdapter, + world_rank: int = 0, + create_app_fn: CreateWebRTCApp = create_webrtc_app, + server_runner: RunWebRTCServer = run_webrtc_server, +) -> WebRTCDemo: + """Build and serve a shared WebRTC demo.""" + demo = build_webrtc_demo( + spec=spec, + adapter=adapter, + create_app=world_rank == 0, + create_app_fn=create_app_fn, + ) + server_runner( + world_rank=world_rank, + session_manager=demo.session_manager, + app=demo.app, + host=demo.host, + port=demo.port, + ) + return demo + + +def _create_runtime_config( + *, + spec: DemoSpec, + adapter: DemoAdapter, + runtime: Any, +) -> Any: + factory = getattr(adapter, "create_webrtc_runtime_config", None) + if callable(factory): + return factory(spec=spec, runtime=runtime) + + runtime_config = getattr(runtime, "config", None) + if _looks_like_webrtc_runtime_config(runtime_config): + return runtime_config + + output = spec.output + if not isinstance(output, WebRTCOutputSpec): + raise ValueError("WebRTC runtime config creation requires WebRTCOutputSpec.") + return WebRTCDemoRuntimeConfig( + video_width=output.video_width, + video_height=output.video_height, + warmup_chunks=output.warmup_chunks, + warmup_timeout_s=output.warmup_timeout_s, + ) + + +def _looks_like_webrtc_runtime_config(value: Any) -> bool: + return all( + hasattr(value, name) + for name in ( + "video_width", + "video_height", + "warmup_chunks", + "warmup_timeout_s", + ) + ) + + +def _create_session_manager( + *, + spec: DemoSpec, + adapter: DemoAdapter, + runtime: Any, + runtime_config: Any, + fps: int, + client_liveness_timeout_s: float, +) -> BaseWebRTCSessionManager[Any, Any]: + factory = getattr(adapter, "create_webrtc_session_manager", None) + if callable(factory): + return factory( + spec=spec, + runtime=runtime, + runtime_config=runtime_config, + fps=fps, + client_liveness_timeout_s=client_liveness_timeout_s, + ) + + return SharedDemoWebRTCSessionManager( + model_name=spec.model_id, + runtime=runtime, + runtime_config=runtime_config, + fps=fps, + client_liveness_timeout_s=client_liveness_timeout_s, + ) + + +def _create_app( + *, + spec: DemoSpec, + adapter: DemoAdapter, + session_manager: BaseWebRTCSessionManager[Any, Any], + create_app_fn: CreateWebRTCApp, +) -> web.Application: + output = spec.output + if not isinstance(output, WebRTCOutputSpec): + raise ValueError("WebRTC app creation requires WebRTCOutputSpec output.") + factory = getattr(adapter, "create_webrtc_app", None) + if callable(factory): + return factory( + spec=spec, + session_manager=session_manager, + request_session_url=_request_session_url(output), + ) + return _build_webrtc_app( + output=output, + session_manager=session_manager, + create_app_fn=create_app_fn, + preload_name=output.preload_name or spec.model_id, + ) + + +def _build_webrtc_app( + *, + output: WebRTCOutputSpec, + session_manager: BaseWebRTCSessionManager[Any, Any], + create_app_fn: CreateWebRTCApp, + preload_name: str, +) -> web.Application: + if output.web_dir is None: + raise ValueError("WebRTC app creation requires output.web_dir.") + return create_app_fn( + web_dir=Path(output.web_dir), + session_manager=session_manager, + request_session_url=_request_session_url(output), + preload_name=preload_name, + ) + + +def _request_session_url(output: WebRTCOutputSpec) -> str: + host = "127.0.0.1" if output.host in {"0.0.0.0", "::"} else output.host + return f"http://{host}:{output.port}{output.request_session_path}" + + +__all__ = [ + "CreateWebRTCApp", + "RunWebRTCServer", + "SharedDemoWebRTCSessionManager", + "WebRTCDemo", + "WebRTCDemoRuntimeConfig", + "build_webrtc_demo", + "serve_webrtc_demo", +] diff --git a/flashdreams/flashdreams/runtime/mapping.py b/flashdreams/flashdreams/runtime/mapping.py index 710ad8043..6dfb5cc45 100644 --- a/flashdreams/flashdreams/runtime/mapping.py +++ b/flashdreams/flashdreams/runtime/mapping.py @@ -369,8 +369,7 @@ def undeclared_inference_inputs( for phase in INPUT_PHASES for key in inputs.for_phase(phase) if not any( - declared.name == key - for declared in mapping_schema.produces_for(phase) + declared.name == key for declared in mapping_schema.produces_for(phase) ) ) diff --git a/flashdreams/flashdreams/runtime/runner.py b/flashdreams/flashdreams/runtime/runner.py new file mode 100644 index 000000000..03d814472 --- /dev/null +++ b/flashdreams/flashdreams/runtime/runner.py @@ -0,0 +1,199 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Minimal synchronous standard runner for the runtime API.""" + +from __future__ import annotations + +import math + +from flashdreams.runtime.canonical import InputCanonicalizer +from flashdreams.runtime.config import InferenceConfig +from flashdreams.runtime.inputs import ( + CanonicalInputs, + CanonicalInputSchema, + InferenceInput, + TimeWindow, + UserInputs, + UserInputSchema, +) +from flashdreams.runtime.interfaces import ( + InferenceRuntime, + InferenceSession, + ModelAdapter, +) +from flashdreams.runtime.mapping import ( + DeclaresMappingSchema, + InputMapping, + check_mapping_compatibility, +) +from flashdreams.runtime.metrics import MetricsRecorder +from flashdreams.runtime.output import OutputArtifact, OutputTarget +from flashdreams.runtime.types import StepResult + +_DEFAULT_SESSION_HORIZON_S = 3600.0 + + +def run_inference_session( + *, + adapter: ModelAdapter, + config: InferenceConfig, + mapping: InputMapping, + canonicalizer: InputCanonicalizer, + source_schema: UserInputSchema, + user_inputs: UserInputs, + initial_inputs: InferenceInput, + output: OutputTarget, + metrics: MetricsRecorder, +) -> tuple[OutputArtifact, ...]: + """Run one sequential inference session through the standard loop. + + This v0 loop intentionally handles one adapter/runtime/session, one selected + input mapping, one replay/live input batch, one output target, and one + metrics recorder. It is synchronous and owns only orchestration. + """ + + runtime: InferenceRuntime | None = None + session: InferenceSession | None = None + output_opened = False + output_artifacts: tuple[OutputArtifact, ...] = () + primary_error: BaseException | None = None + + try: + adapter.validate_config(config) + canonical_schema = canonicalizer.canonical_schema(source_schema) + _check_declared_mapping_compatibility( + mapping=mapping, + canonical_schema=canonical_schema, + adapter=adapter, + ) + mapping.validate( + canonical_schema=canonical_schema, + inference_input_schema=adapter.inference_input_schema, + ) + canonicalizer.reset() + mapped_initial_inputs = mapping.map_global_conditioning_inputs( + canonical_inputs=CanonicalInputs(), + inference_input=initial_inputs, + ) + runtime = adapter.create_runtime(config) + session = runtime.start_session(mapped_initial_inputs) + output.open() + output_opened = True + step_base_inputs = InferenceInput( + step=initial_inputs.step, + metadata=initial_inputs.metadata, + ) + + while (request := session.next_step_request()) is not None: + step_inputs = mapping.map_step_inputs( + canonical_inputs=canonicalizer.canonicalize( + user_inputs, + window=request.user_input_window + or _all_user_inputs_window(user_inputs), + source_schema=source_schema, + ), + inference_input=step_base_inputs, + request=request, + ) + result = session.step(step_inputs) + output.write(result) + _record_timing_metrics(metrics, result) + except BaseException as exc: + primary_error = exc + raise + finally: + cleanup_error, output_artifacts = _close_run_resources( + output=output if output_opened else None, + session=session, + runtime=runtime, + metrics=metrics, + ) + if cleanup_error is not None and primary_error is None: + raise cleanup_error + + return output_artifacts + + +def _check_declared_mapping_compatibility( + *, + mapping: InputMapping, + canonical_schema: CanonicalInputSchema, + adapter: ModelAdapter, +) -> None: + if not isinstance(mapping, DeclaresMappingSchema): + return + compatibility = check_mapping_compatibility( + canonical_schema=canonical_schema, + inference_input_schema=adapter.inference_input_schema, + mapping_schema=mapping.mapping_schema, + ) + compatibility.raise_if_incompatible() + + +def _all_user_inputs_window(user_inputs: UserInputs) -> TimeWindow: + if not user_inputs.events: + return TimeWindow(start_s=0.0, end_s=_DEFAULT_SESSION_HORIZON_S) + return TimeWindow( + start_s=0.0, + end_s=max( + _DEFAULT_SESSION_HORIZON_S, + math.nextafter(user_inputs.events[-1].timestamp_s, math.inf), + ), + ) + + +def _record_timing_metrics(metrics: MetricsRecorder, result: StepResult) -> None: + for name, value in result.metrics.items(): + if not name.endswith("_s") or isinstance(value, bool): + continue + sample_name = name[:-2] or name + metrics.record_timing( + sample_name, + float(value), + step_index=result.step_index, + ) + + +def _close_run_resources( + *, + output: OutputTarget | None, + session: InferenceSession | None, + runtime: InferenceRuntime | None, + metrics: MetricsRecorder, +) -> tuple[BaseException | None, tuple[OutputArtifact, ...]]: + cleanup_error: BaseException | None = None + artifacts: tuple[OutputArtifact, ...] = () + + def remember_error(exc: BaseException) -> None: + nonlocal cleanup_error + if cleanup_error is None: + cleanup_error = exc + + if output is not None: + try: + artifacts = tuple(output.close()) + except BaseException as exc: + remember_error(exc) + + if session is not None: + try: + session.close() + except BaseException as exc: + remember_error(exc) + + if runtime is not None: + try: + runtime.close() + except BaseException as exc: + remember_error(exc) + + try: + metrics.close() + except BaseException as exc: + remember_error(exc) + + return cleanup_error, artifacts + + +__all__ = ["run_inference_session"] diff --git a/flashdreams/flashdreams/runtime/video_output.py b/flashdreams/flashdreams/runtime/video_output.py new file mode 100644 index 000000000..b5c372125 --- /dev/null +++ b/flashdreams/flashdreams/runtime/video_output.py @@ -0,0 +1,157 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Video output targets for the runtime API.""" + +from __future__ import annotations + +from collections.abc import Callable, Sequence +from dataclasses import dataclass, field +from pathlib import Path +from typing import cast + +import torch + +from flashdreams.infra.postprocess import VideoTensorLayout +from flashdreams.infra.runner_io import ( + DEFAULT_RUNNER_INSTALL_HINT, + write_video_tensor, +) +from flashdreams.infra.runner_io import ( + VideoTensorLayout as WritableVideoTensorLayout, +) +from flashdreams.infra.video_output import RunnerVideoOutputStream, VideoStepResult +from flashdreams.runtime.output import OutputArtifact +from flashdreams.runtime.types import StepResult + +VideoWriter = Callable[..., Path] + + +@dataclass(slots=True) +class Mp4VideoOutputTarget: + """Write runtime ``VideoStepResult`` chunks to one MP4 artifact.""" + + output_path: Path + fps: int | float + output_layout: VideoTensorLayout = "bvtchw" + writer: VideoWriter = field(default=write_video_tensor, repr=False) + install_hint: str = DEFAULT_RUNNER_INSTALL_HINT + move_to_cpu: bool = True + _opened: bool = field(default=False, init=False, repr=False) + _stream: RunnerVideoOutputStream | None = field( + default=None, + init=False, + repr=False, + ) + + @property + def closed(self) -> bool: + return not self._opened + + def open(self) -> None: + self._stream = RunnerVideoOutputStream( + postprocess_stream=None, + output_layout=self.output_layout, + collect_output=True, + move_to_cpu=self.move_to_cpu, + ) + self._opened = True + + def write(self, result: StepResult) -> None: + if not self._opened or self._stream is None: + raise RuntimeError("Cannot write to a closed output target.") + video_result = result.output + if not isinstance(video_result, VideoStepResult): + raise TypeError( + "Mp4VideoOutputTarget requires StepResult.output to be " + f"VideoStepResult, got {type(video_result).__name__}." + ) + if video_result.layout != self.output_layout: + raise ValueError( + "Mp4VideoOutputTarget received layout " + f"{video_result.layout!r}; expected {self.output_layout!r}." + ) + stats = dict(video_result.stats or result.metrics) + stats_extra: dict[str, object] = { + "step_index": result.step_index, + "frames": video_result.num_frames, + } + if result.output_window is not None: + stats_extra["output_start_s"] = result.output_window.start_s + stats_extra["output_end_s"] = result.output_window.end_s + self._stream.process( + video_result.video_chunk, + autoregressive_index=video_result.chunk_index, + stats=stats if stats else None, + stats_extra=stats_extra, + ) + + def close(self) -> Sequence[OutputArtifact]: + if self._stream is None: + self._opened = False + return () + + stream = self._stream + self._stream = None + self._opened = False + video = stream.finish() + if video is None: + return () + + writable_video, writable_layout = _prepare_video_for_mp4( + video, + layout=self.output_layout, + ) + path = self.writer( + writable_video, + self.output_path, + fps=self.fps, + layout=writable_layout, + install_hint=self.install_hint, + ) + return ( + OutputArtifact( + kind="video/mp4", + uri=str(path), + metadata={ + "fps": self.fps, + "source_layout": self.output_layout, + "write_layout": writable_layout, + "shape": tuple(int(dim) for dim in writable_video.shape), + "stats_history": tuple(stream.stats_history), + }, + ), + ) + + +def _prepare_video_for_mp4( + video: torch.Tensor, + *, + layout: VideoTensorLayout, +) -> tuple[torch.Tensor, WritableVideoTensorLayout]: + """Convert runtime video layouts into layouts accepted by runner I/O.""" + if layout in {"tchw", "btchw", "bcthw"}: + return video, cast(WritableVideoTensorLayout, layout) + if layout == "bvtchw": + if video.ndim != 6: + raise ValueError( + "layout='bvtchw' expects a 6D [B,V,T,C,H,W] tensor, " + f"got {tuple(video.shape)}." + ) + if video.shape[0] != 1: + raise ValueError( + "layout='bvtchw' MP4 writing expects a single batch element, " + f"got {tuple(video.shape)}." + ) + _, views, frames, channels, height, width = video.shape + canvas = ( + video[0] + .permute(1, 3, 0, 4, 2) + .contiguous() + .reshape(frames, height, views * width, channels) + ) + return canvas, "thwc" + raise ValueError(f"unsupported runtime video layout for MP4: {layout!r}") + + +__all__ = ["Mp4VideoOutputTarget"] diff --git a/flashdreams/tests/test_benchmark_harness.py b/flashdreams/tests/test_benchmark_harness.py index d70f9eb5d..1de03b5db 100644 --- a/flashdreams/tests/test_benchmark_harness.py +++ b/flashdreams/tests/test_benchmark_harness.py @@ -245,6 +245,50 @@ def test_shipped_one_minute_demo_scenarios_load() -> None: } +def test_shipped_omnidreams_demo_replay_scenarios_load() -> None: + repo_root = Path(__file__).resolve().parents[2] + scenarios = load_scenario_file( + repo_root / "configs" / "omnidreams_demo_replay_benchmarks.json" + ) + + assert set(scenarios) == { + "omnidreams-sv-runner-baseline", + "omnidreams-sv-demo-replay", + } + + baseline = scenarios["omnidreams-sv-runner-baseline"] + assert baseline.report_group is not None + assert baseline.report_group.id == "omnidreams-demo" + assert _command_value(baseline.command, "--total-blocks") == "226" + assert "omnidreams-sv-2steps-chunk2-loc6-lightvae-lighttae" in baseline.command + assert "omnidreams-sv-2steps-chunk2-loc6-lightvae-lighttae-perf" not in ( + baseline.command + ) + assert baseline.quality_baseline_compare is False + + demo = scenarios["omnidreams-sv-demo-replay"] + assert demo.output_dir_arg is None + assert demo.command[:5] == ( + "uv", + "run", + "--project", + "integrations/omnidreams", + "omnidreams-demo", + ) + assert demo.command[5] == "replay" + assert _command_value(demo.command, "--preset-id") == ( + "omnidreams-sv-2steps-chunk2-loc6-lightvae-lighttae" + ) + assert _command_value(demo.command, "--total-blocks") == "226" + assert _command_value(demo.command, "--output") == ( + "{output_dir}/omnidreams-sv-demo-replay.mp4" + ) + assert "omnidreams-sv-2steps-chunk2-loc6-lightvae-lighttae-perf" not in ( + demo.command + ) + assert demo.quality_baseline_compare is False + + def test_shipped_deterministic_quality_scenarios_load() -> None: repo_root = Path(__file__).resolve().parents[2] scenarios = load_scenario_file( diff --git a/flashdreams/tests/test_inference_runtime_api.py b/flashdreams/tests/test_inference_runtime_api.py index 890d7efb9..42f75d688 100644 --- a/flashdreams/tests/test_inference_runtime_api.py +++ b/flashdreams/tests/test_inference_runtime_api.py @@ -3,7 +3,6 @@ from __future__ import annotations -from collections.abc import Mapping from dataclasses import fields from typing import Any, cast @@ -11,29 +10,18 @@ from flashdreams.runtime import ( CanonicalInputs, - CanonicalInputSchema, - CanonicalModality, - DeviceConverterSchema, IdentityInputMapping, InferenceConfig, InferenceInput, InferenceInputSchema, - InferenceRuntime, - InferenceSession, InMemoryMetricsRecorder, - InputCanonicalizer, InputField, - InputMapping, - MetricsRecorder, - ModelAdapter, NullOutputTarget, OutputArtifact, - OutputTarget, RuntimeMetricSample, StepRequest, StepResult, TimeWindow, - UserInputCapability, UserInputEvent, UserInputs, UserInputSchema, @@ -42,18 +30,6 @@ pytestmark = pytest.mark.ci_cpu -_SESSION_HORIZON_S = 3600.0 - -_KEYBOARD_SOURCE = UserInputSchema( - capabilities=( - UserInputCapability( - event_type="keyboard.keydown", payload_fields=frozenset({"key"}) - ), - ) -) -_KEYBOARD_CANONICALIZER = InputCanonicalizer() - - def test_inference_config_keeps_runtime_settings_separate() -> None: denied_app_fields = {"prompt", "output_dir", "browser_settings"} config = InferenceConfig( @@ -267,370 +243,3 @@ def test_timing_metric_samples_must_use_seconds() -> None: unit="ms", category="timing", ) - - -def test_runtime_api_components_compose_for_sequential_session() -> None: - adapter = _FakeAdapter() - config = InferenceConfig(model_id="fake-model") - user_inputs = UserInputs( - events=( - UserInputEvent( - timestamp_s=0.25, - event_type="keyboard.keydown", - payload={"key": "w"}, - ), - ) - ) - inference_input = InferenceInput(global_conditioning={"prompt": "drive forward"}) - output = NullOutputTarget(store_results=True) - metrics = InMemoryMetricsRecorder() - - adapter.validate_config(config) - mapping = adapter.default_input_mapping() - assert mapping is not None - _drive_two_step_session( - adapter=adapter, - config=config, - mapping=mapping, - canonicalizer=_KEYBOARD_CANONICALIZER, - source_schema=_KEYBOARD_SOURCE, - user_inputs=user_inputs, - inference_input=inference_input, - output=output, - metrics=metrics, - ) - - assert output.output_count == 2 - assert [result.output for result in output.results] == ["chunk-0", "chunk-1"] - assert [result.frame_count for result in output.results] == [3, 3] - assert output.results[0].output_window == TimeWindow(start_s=0.0, end_s=0.5) - assert [sample.step_index for sample in metrics.samples] == [0, 1] - assert metrics.closed - - -def test_reference_loop_validates_mapping_before_runtime_creation() -> None: - mapping = _OrderCheckingMapping() - adapter = _OrderCheckingAdapter(mapping=mapping) - - _drive_two_step_session( - adapter=adapter, - config=InferenceConfig(model_id="fake-model"), - mapping=mapping, - canonicalizer=_KEYBOARD_CANONICALIZER, - source_schema=_KEYBOARD_SOURCE, - user_inputs=UserInputs(), - inference_input=InferenceInput(global_conditioning={"prompt": "drive forward"}), - output=NullOutputTarget(), - metrics=InMemoryMetricsRecorder(), - ) - - assert mapping.validated - assert adapter.created_runtime_after_validate - - -def test_reference_loop_does_not_canonicalize_global_conditioning() -> None: - mapping = _CanonicalRecordingMapping() - adapter = _FakeAdapter() - canonicalizer = InputCanonicalizer([_CountingDeviceConverter()]) - source_schema = UserInputSchema( - capabilities=( - UserInputCapability( - event_type="stateful_event", - payload_fields=frozenset(), - ), - ) - ) - - _drive_two_step_session( - adapter=adapter, - config=InferenceConfig(model_id="fake-model"), - mapping=mapping, - canonicalizer=canonicalizer, - source_schema=source_schema, - user_inputs=UserInputs( - events=(UserInputEvent(timestamp_s=0.75, event_type="stateful_event"),) - ), - inference_input=InferenceInput(global_conditioning={"prompt": "drive forward"}), - output=NullOutputTarget(), - metrics=InMemoryMetricsRecorder(), - ) - - assert mapping.global_canonical_values == {} - assert mapping.step_canonical_values == ( - {"stateful_counter": {"count": 0}}, - {"stateful_counter": {"count": 1}}, - ) - - -def test_reference_loop_closes_runtime_when_session_start_fails() -> None: - adapter = _FailingStartAdapter() - output = NullOutputTarget() - metrics = InMemoryMetricsRecorder() - - with pytest.raises(RuntimeError, match="start failed"): - _drive_two_step_session( - adapter=adapter, - config=InferenceConfig(model_id="fake-model"), - mapping=IdentityInputMapping(), - canonicalizer=_KEYBOARD_CANONICALIZER, - source_schema=_KEYBOARD_SOURCE, - user_inputs=UserInputs(), - inference_input=InferenceInput( - global_conditioning={"prompt": "drive forward"} - ), - output=output, - metrics=metrics, - ) - - assert adapter.runtime is not None - assert adapter.runtime.closed - assert output.closed - assert metrics.closed - - -def _drive_two_step_session( - *, - adapter: ModelAdapter, - config: InferenceConfig, - mapping: InputMapping, - canonicalizer: InputCanonicalizer, - source_schema: UserInputSchema, - user_inputs: UserInputs, - inference_input: InferenceInput, - output: OutputTarget, - metrics: MetricsRecorder, -) -> None: - mapping.validate( - canonical_schema=adapter.canonical_input_schema, - inference_input_schema=adapter.inference_input_schema, - ) - canonicalizer.reset() - initial_inputs = mapping.map_global_conditioning_inputs( - canonical_inputs=CanonicalInputs(), - inference_input=inference_input, - ) - runtime = adapter.create_runtime(config) - session: InferenceSession | None = None - output_opened = False - try: - session = runtime.start_session(initial_inputs) - output.open() - output_opened = True - while (request := session.next_step_request()) is not None: - step_inputs = mapping.map_step_inputs( - canonical_inputs=canonicalizer.canonicalize( - user_inputs, - window=request.user_input_window - or TimeWindow(start_s=0.0, end_s=_SESSION_HORIZON_S), - source_schema=source_schema, - ), - # Per-step calls carry only the step payload. A changed prompt - # or scene starts or resets a session outside this loop. - inference_input=InferenceInput( - step={"chunk_index": request.step_index}, - ), - request=request, - ) - result = session.step(step_inputs) - output.write(result) - metrics.record_timing( - "model_step", - float(result.metrics["model_step_s"]), - step_index=result.step_index, - ) - finally: - if output_opened: - output.close() - if session is not None: - session.close() - runtime.close() - metrics.close() - - -class _FakeAdapter: - model_id = "fake-model" - inference_input_schema = InferenceInputSchema( - global_conditioning_fields=(InputField(name="prompt"),), - step_fields=(InputField(name="chunk_index"),), - ) - canonical_input_schema = CanonicalInputSchema() - - def default_input_mapping(self) -> InputMapping: - return IdentityInputMapping() - - def validate_config(self, config: InferenceConfig) -> None: - if config.model_id != self.model_id: - raise ValueError(f"Unsupported model_id={config.model_id!r}.") - - def create_runtime(self, config: InferenceConfig) -> InferenceRuntime: - self.validate_config(config) - return _FakeRuntime(inference_input_schema=self.inference_input_schema) - - -class _FakeRuntime: - def __init__(self, *, inference_input_schema: InferenceInputSchema) -> None: - self._inference_input_schema = inference_input_schema - self.closed = False - - def start_session(self, inputs: InferenceInput) -> InferenceSession: - self._inference_input_schema.require_global_conditioning(inputs) - return _FakeSession(inference_input_schema=self._inference_input_schema) - - def close(self) -> None: - self.closed = True - - -class _FailingRuntime(_FakeRuntime): - def start_session(self, inputs: InferenceInput) -> InferenceSession: - del inputs - raise RuntimeError("start failed") - - -class _FakeSession: - def __init__(self, *, inference_input_schema: InferenceInputSchema) -> None: - self._inference_input_schema = inference_input_schema - self.step_index = 0 - self.closed = False - - def next_step_request(self) -> StepRequest | None: - if self.step_index >= 2: - return None - return StepRequest( - step_index=self.step_index, - inference_input_schema=self._inference_input_schema, - user_input_window=TimeWindow( - start_s=0.5 * self.step_index, - end_s=0.5 * (self.step_index + 1), - ), - ) - - def step(self, inputs: InferenceInput) -> StepResult: - self._inference_input_schema.require_step(inputs) - result = StepResult( - step_index=self.step_index, - output=f"chunk-{self.step_index}", - frame_count=3, - output_window=TimeWindow( - start_s=0.5 * self.step_index, - end_s=0.5 * (self.step_index + 1), - ), - metrics={"model_step_s": 0.01}, - ) - self.step_index += 1 - return result - - def reset(self, inputs: InferenceInput | None = None) -> None: - del inputs - self.step_index = 0 - - def close(self) -> None: - self.closed = True - - -class _OrderCheckingMapping(IdentityInputMapping): - def __init__(self) -> None: - self.validated = False - - def validate( - self, - *, - canonical_schema: CanonicalInputSchema | None = None, - inference_input_schema: InferenceInputSchema | None = None, - ) -> None: - super().validate( - canonical_schema=canonical_schema, - inference_input_schema=inference_input_schema, - ) - self.validated = True - - -class _CanonicalRecordingMapping(IdentityInputMapping): - def __init__(self) -> None: - self.global_canonical_values: Mapping[str, Any] | None = None - self._step_canonical_values: list[Mapping[str, Any]] = [] - - @property - def step_canonical_values(self) -> tuple[Mapping[str, Any], ...]: - return tuple(self._step_canonical_values) - - def map_global_conditioning_inputs( - self, - *, - canonical_inputs: CanonicalInputs, - inference_input: InferenceInput, - ) -> InferenceInput: - self.global_canonical_values = canonical_inputs.values - return super().map_global_conditioning_inputs( - canonical_inputs=canonical_inputs, - inference_input=inference_input, - ) - - def map_step_inputs( - self, - *, - canonical_inputs: CanonicalInputs, - inference_input: InferenceInput, - request: StepRequest, - ) -> InferenceInput: - self._step_canonical_values.append(canonical_inputs.values) - return super().map_step_inputs( - canonical_inputs=canonical_inputs, - inference_input=inference_input, - request=request, - ) - - -_STATEFUL_COUNTER = CanonicalModality( - name="stateful_counter", - payload_fields=frozenset({"count"}), -) - - -class _CountingDeviceConverter: - schema = DeviceConverterSchema( - name="stateful-counter", - produces=_STATEFUL_COUNTER, - consumes=(UserInputCapability(event_type="stateful_event"),), - ) - - def __init__(self) -> None: - self.count = 0 - - def reset(self) -> None: - self.count = 0 - - def convert( - self, - user_inputs: UserInputs, - window: TimeWindow, - ) -> Mapping[str, Any] | None: - del window - self.count += len(user_inputs.events) - return _STATEFUL_COUNTER.value({"count": self.count}) - - -class _OrderCheckingAdapter(_FakeAdapter): - canonical_input_schema = CanonicalInputSchema() - - def __init__(self, *, mapping: _OrderCheckingMapping) -> None: - self._mapping = mapping - self.created_runtime_after_validate = False - - def create_runtime(self, config: InferenceConfig) -> InferenceRuntime: - self.validate_config(config) - self.created_runtime_after_validate = self._mapping.validated - return _FakeRuntime(inference_input_schema=self.inference_input_schema) - - -class _FailingStartAdapter(_FakeAdapter): - canonical_input_schema = CanonicalInputSchema() - - def __init__(self) -> None: - self.runtime: _FailingRuntime | None = None - - def create_runtime(self, config: InferenceConfig) -> InferenceRuntime: - self.validate_config(config) - self.runtime = _FailingRuntime( - inference_input_schema=self.inference_input_schema - ) - return self.runtime diff --git a/flashdreams/tests/test_runtime_demo_api.py b/flashdreams/tests/test_runtime_demo_api.py new file mode 100644 index 000000000..7719c7c49 --- /dev/null +++ b/flashdreams/tests/test_runtime_demo_api.py @@ -0,0 +1,458 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from collections.abc import Sequence +from pathlib import Path +from typing import Any + +import pytest +import torch + +from flashdreams.infra.video_output import VideoStepResult +from flashdreams.runtime import ( + CanonicalInputs, + CanonicalInputSchema, + IdentityInputMapping, + InferenceConfig, + InferenceInput, + InferenceInputSchema, + InferenceRuntime, + InferenceSession, + InputCanonicalizer, + InputField, + InputMapping, + InputMappingSchema, + NullMetricsRecorder, + NullOutputTarget, + OutputArtifact, + OutputTarget, + StepRequest, + StepResult, + TimeWindow, + UserInputs, + UserInputSchema, +) +from flashdreams.runtime.demo import ( + DemoSpec, + Mp4OutputSpec, + NullOutputSpec, + PreparedScenario, + WebRTCOutputSpec, + build_output_target, + run_replay_demo, +) +from flashdreams.runtime.demo.webrtc import build_webrtc_demo +from flashdreams.serving.webrtc.manager import BaseWebRTCSessionManager + +pytestmark = pytest.mark.ci_cpu + + +def test_replay_demo_uses_shared_runner() -> None: + adapter = _FakeDemoAdapter() + output = _RecordingOutputTarget() + calls: list[dict[str, Any]] = [] + + def fake_runner(**kwargs: Any) -> Sequence[OutputArtifact]: + calls.append(kwargs) + return (OutputArtifact(kind="test/artifact", uri="memory://artifact"),) + + spec = DemoSpec( + model_id="fake-demo", + scenario="valid-scenario", + input_mode="replay", + output=NullOutputSpec(), + ) + + artifacts = run_replay_demo( + spec=spec, + adapter=adapter, + output_target_factory=lambda output_spec: output, + metrics=NullMetricsRecorder(), + runner=fake_runner, + ) + + assert artifacts == (OutputArtifact(kind="test/artifact", uri="memory://artifact"),) + assert len(calls) == 1 + assert calls[0]["adapter"] is adapter + assert calls[0]["config"] == spec.config + assert calls[0]["mapping"] is adapter.prepared_scenario.mapping + assert calls[0]["canonicalizer"] is adapter.prepared_scenario.canonicalizer + assert calls[0]["source_schema"] is adapter.prepared_scenario.source_schema + assert calls[0]["user_inputs"] is adapter.prepared_scenario.user_inputs + assert calls[0]["initial_inputs"] is adapter.prepared_scenario.initial_inputs + assert calls[0]["output"] is output + assert adapter.prepare_scenario_calls == [spec] + assert not adapter.create_runtime_called + + +def test_replay_demo_builds_output_target_from_spec(tmp_path: Path) -> None: + writer_calls: list[dict[str, Any]] = [] + + def fake_writer( + video: torch.Tensor, + path: Path, + *, + fps: int | float, + layout: str, + install_hint: str, + ) -> Path: + del install_hint + writer_calls.append( + { + "shape": tuple(video.shape), + "path": path, + "fps": fps, + "layout": layout, + } + ) + return path + + spec = DemoSpec( + model_id="fake-demo", + scenario="valid-scenario", + input_mode="replay", + output=Mp4OutputSpec(path=tmp_path / "demo.mp4", fps=12), + ) + + artifacts = run_replay_demo( + spec=spec, + adapter=_FakeDemoAdapter(video_output=True), + output_target_factory=lambda output_spec: build_output_target( + output_spec, + mp4_writer=fake_writer, + ), + ) + + assert len(artifacts) == 1 + assert artifacts[0].kind == "video/mp4" + assert artifacts[0].uri == str(tmp_path / "demo.mp4") + assert writer_calls == [ + { + "shape": (2, 2, 2, 3), + "path": tmp_path / "demo.mp4", + "fps": 12, + "layout": "thwc", + } + ] + + +def test_replay_demo_fails_before_runtime_creation_when_scenario_invalid() -> None: + adapter = _FakeDemoAdapter(scenario_valid=False) + output_factory_calls = 0 + + def output_factory(output_spec: object) -> OutputTarget: + nonlocal output_factory_calls + del output_spec + output_factory_calls += 1 + return NullOutputTarget() + + spec = DemoSpec( + model_id="fake-demo", + scenario="missing-scenario", + input_mode="replay", + output=NullOutputSpec(), + ) + + with pytest.raises(ValueError, match="invalid scenario"): + run_replay_demo( + spec=spec, + adapter=adapter, + output_target_factory=output_factory, + ) + + assert adapter.prepare_scenario_calls == [spec] + assert not adapter.create_runtime_called + assert output_factory_calls == 0 + + +def test_demo_adapter_declares_supported_modes() -> None: + adapter = _FakeDemoAdapter( + input_modes=("replay",), + output_modes=("null", "mp4", "webrtc"), + ) + + assert adapter.supported_input_modes() == ("replay",) + assert adapter.supported_output_modes() == ("null", "mp4", "webrtc") + + with pytest.raises(ValueError, match="input_mode='keyboard-driving'"): + run_replay_demo( + spec=DemoSpec( + model_id="fake-demo", + scenario="valid-scenario", + input_mode="keyboard-driving", + output=NullOutputSpec(), + ), + adapter=adapter, + ) + + assert adapter.prepare_scenario_calls == [] + assert not adapter.create_runtime_called + + +def test_webrtc_demo_uses_existing_session_manager_with_adapter_runtime() -> None: + adapter = _FakeDemoAdapter() + spec = DemoSpec( + model_id="fake-demo", + scenario="valid-scenario", + input_mode="keyboard-driving", + output=WebRTCOutputSpec( + host="0.0.0.0", + port=8082, + fps=24, + video_width=16, + video_height=8, + warmup_chunks=0, + warmup_timeout_s=1.0, + ), + ) + + demo = build_webrtc_demo(spec=spec, adapter=adapter) + + assert isinstance(demo.session_manager, BaseWebRTCSessionManager) + assert demo.runtime is adapter.webrtc_runtime + assert demo.session_manager._runtime is adapter.webrtc_runtime + assert demo.session_manager.runtime_config.video_width == 16 + assert demo.session_manager.runtime_config.video_height == 8 + assert demo.session_manager.fps == 24 + assert demo.session_manager._model_name() == "fake-demo" + assert demo.app is None + assert demo.host == "0.0.0.0" + assert demo.port == 8082 + assert adapter.create_webrtc_runtime_calls == [spec] + assert not adapter.create_runtime_called + + +class _ChunkIndexMapping: + mapping_schema = InputMappingSchema( + name="chunk-index", + produces_global_conditioning=(InputField(name="prompt"),), + produces_step=(InputField(name="chunk_index"),), + ) + + def validate( + self, + *, + canonical_schema: CanonicalInputSchema | None = None, + inference_input_schema: InferenceInputSchema | None = None, + ) -> None: + del canonical_schema, inference_input_schema + + def map_global_conditioning_inputs( + self, + *, + canonical_inputs: CanonicalInputs, + inference_input: InferenceInput, + ) -> InferenceInput: + del canonical_inputs + return inference_input + + def map_step_inputs( + self, + *, + canonical_inputs: CanonicalInputs, + inference_input: InferenceInput, + request: StepRequest, + ) -> InferenceInput: + del canonical_inputs + return InferenceInput( + global_conditioning=inference_input.global_conditioning, + step={"chunk_index": request.step_index}, + metadata=inference_input.metadata, + ) + + +class _FakeDemoAdapter: + model_id = "fake-demo" + inference_input_schema = InferenceInputSchema( + global_conditioning_fields=(InputField(name="prompt"),), + step_fields=(InputField(name="chunk_index"),), + ) + canonical_input_schema = CanonicalInputSchema() + + def __init__( + self, + *, + scenario_valid: bool = True, + video_output: bool = False, + input_modes: tuple[str, ...] = ("replay", "keyboard-driving"), + output_modes: tuple[str, ...] = ("null", "mp4", "webrtc"), + ) -> None: + self._scenario_valid = scenario_valid + self._video_output = video_output + self._input_modes = input_modes + self._output_modes = output_modes + self.mapping = _ChunkIndexMapping() + self.prepared_scenario = PreparedScenario( + initial_inputs=InferenceInput( + global_conditioning={"prompt": "drive forward"}, + ), + user_inputs=UserInputs(), + source_schema=UserInputSchema(), + canonicalizer=InputCanonicalizer(), + mapping=self.mapping, + ) + self.prepare_scenario_calls: list[DemoSpec] = [] + self.create_runtime_called = False + self.runtime: _FakeRuntime | None = None + self.webrtc_runtime: _FakeWebRTCRuntime | None = None + self.create_webrtc_runtime_calls: list[DemoSpec] = [] + + def supported_input_modes(self) -> tuple[str, ...]: + return self._input_modes + + def supported_output_modes(self) -> tuple[str, ...]: + return self._output_modes + + def default_input_mapping(self) -> InputMapping: + return IdentityInputMapping() + + def validate_config(self, config: InferenceConfig) -> None: + if config.model_id != self.model_id: + raise ValueError(f"Unsupported model_id={config.model_id!r}.") + + def create_runtime(self, config: InferenceConfig) -> InferenceRuntime: + self.validate_config(config) + self.create_runtime_called = True + self.runtime = _FakeRuntime( + inference_input_schema=self.inference_input_schema, + video_output=self._video_output, + ) + return self.runtime + + def prepare_scenario(self, spec: DemoSpec) -> PreparedScenario: + self.prepare_scenario_calls.append(spec) + if not self._scenario_valid: + raise ValueError("invalid scenario") + return self.prepared_scenario + + def create_webrtc_runtime(self, spec: DemoSpec) -> "_FakeWebRTCRuntime": + self.create_webrtc_runtime_calls.append(spec) + self.webrtc_runtime = _FakeWebRTCRuntime() + return self.webrtc_runtime + + +class _FakeRuntime: + def __init__( + self, + *, + inference_input_schema: InferenceInputSchema, + video_output: bool, + ) -> None: + self._inference_input_schema = inference_input_schema + self._video_output = video_output + self.session: _FakeSession | None = None + self.closed = False + + def start_session(self, inputs: InferenceInput) -> InferenceSession: + self._inference_input_schema.require_global_conditioning(inputs) + self.session = _FakeSession( + inference_input_schema=self._inference_input_schema, + video_output=self._video_output, + ) + return self.session + + def close(self) -> None: + self.closed = True + + +class _FakeSession: + def __init__( + self, + *, + inference_input_schema: InferenceInputSchema, + video_output: bool, + ) -> None: + self._inference_input_schema = inference_input_schema + self._video_output = video_output + self.step_index = 0 + self.closed = False + + def next_step_request(self) -> StepRequest | None: + if self.step_index >= 2: + return None + return StepRequest( + step_index=self.step_index, + user_input_window=TimeWindow( + start_s=0.5 * self.step_index, + end_s=0.5 * (self.step_index + 1), + ), + ) + + def step(self, inputs: InferenceInput) -> StepResult: + self._inference_input_schema.require_step(inputs) + output: object + if self._video_output: + output = VideoStepResult.from_video_chunk( + chunk_index=self.step_index, + video_chunk=torch.full( + (1, 1, 1, 3, 2, 2), + self.step_index, + dtype=torch.float32, + ), + layout="bvtchw", + ) + else: + output = f"chunk-{self.step_index}" + result = StepResult( + step_index=self.step_index, + output=output, + frame_count=1, + output_window=TimeWindow( + start_s=0.5 * self.step_index, + end_s=0.5 * (self.step_index + 1), + ), + ) + self.step_index += 1 + return result + + def reset(self, inputs: InferenceInput | None = None) -> None: + del inputs + self.step_index = 0 + + def close(self) -> None: + self.closed = True + + +class _RecordingOutputTarget: + def open(self) -> None: + return None + + def write(self, result: StepResult) -> None: + del result + + def close(self) -> Sequence[OutputArtifact]: + return () + + +class _FakeWebRTCRuntime: + async def initialize(self) -> None: + return None + + async def reset_for_new_session(self) -> None: + return None + + def peek_steady_chunk_num_frames(self) -> int: + return 1 + + def peek_next_chunk_num_frames(self) -> int: + return 1 + + async def generate_chunk( + self, + *, + segments: list[Any], + frame_times: list[float], + ) -> Any: + del segments, frame_times + return None + + async def close(self) -> None: + return None + + def send_exit_signal(self) -> None: + return None + + def wait_for_termination(self) -> None: + return None diff --git a/flashdreams/tests/test_runtime_input_mapping.py b/flashdreams/tests/test_runtime_input_mapping.py index b5f0744b9..b774371f4 100644 --- a/flashdreams/tests/test_runtime_input_mapping.py +++ b/flashdreams/tests/test_runtime_input_mapping.py @@ -201,9 +201,7 @@ def test_model_declares_required_and_optional_fields_per_phase() -> None: ("global_conditioning", "prompt"), ("step", "steering"), } - assert {(phase, f.name) for phase, f in optional} == { - ("step", "camera_delta") - } + assert {(phase, f.name) for phase, f in optional} == {("step", "camera_delta")} def test_required_fields_can_be_filtered_by_phase() -> None: @@ -214,8 +212,7 @@ def test_required_fields_can_be_filtered_by_phase() -> None: def test_field_lookup_is_phase_scoped() -> None: assert ( - DRIVING_MODEL.field_for(name="prompt", phase="global_conditioning") - is not None + DRIVING_MODEL.field_for(name="prompt", phase="global_conditioning") is not None ) assert DRIVING_MODEL.field_for(name="prompt", phase="step") is None @@ -275,12 +272,13 @@ def test_compatible_source_model_and_mapping_can_drive() -> None: ) assert compatibility.can_drive - assert { - (p, f.name) for p, f in compatibility.satisfied_required_model_fields - } == {("global_conditioning", "prompt"), ("step", "steering")} - assert { - (p, f.name) for p, f in compatibility.available_optional_model_fields - } == {("step", "camera_delta")} + assert {(p, f.name) for p, f in compatibility.satisfied_required_model_fields} == { + ("global_conditioning", "prompt"), + ("step", "steering"), + } + assert {(p, f.name) for p, f in compatibility.available_optional_model_fields} == { + ("step", "camera_delta") + } def test_missing_required_model_field_blocks_the_run() -> None: @@ -291,9 +289,9 @@ def test_missing_required_model_field_blocks_the_run() -> None: ) assert not compatibility.can_drive - assert [ - f.name for _, f in compatibility.missing_required_model_fields - ] == ["steering"] + assert [f.name for _, f in compatibility.missing_required_model_fields] == [ + "steering" + ] def test_missing_source_capability_is_reported_when_it_blocks() -> None: @@ -416,9 +414,7 @@ def test_combining_mappings_unions_their_surfaces() -> None: combined = combine_mapping_schemas((PROMPT_MAPPING, STEERING_MAPPING)) assert {m.name for m in combined.consumes} == {"driver_command"} - assert [f.name for f in combined.produces_global_conditioning] == [ - "prompt" - ] + assert [f.name for f in combined.produces_global_conditioning] == ["prompt"] assert [f.name for f in combined.produces_step] == ["steering"] diff --git a/flashdreams/tests/test_runtime_runner.py b/flashdreams/tests/test_runtime_runner.py new file mode 100644 index 000000000..b755ff48a --- /dev/null +++ b/flashdreams/tests/test_runtime_runner.py @@ -0,0 +1,660 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from typing import Any + +import pytest + +from flashdreams.runtime import ( + DRIVER_COMMAND, + CanonicalInputs, + CanonicalInputSchema, + CanonicalModality, + DeviceConverterSchema, + IdentityInputMapping, + InferenceConfig, + InferenceInput, + InferenceInputSchema, + InferenceRuntime, + InferenceSession, + InMemoryMetricsRecorder, + InputCanonicalizer, + InputField, + InputMapping, + InputMappingSchema, + NullOutputTarget, + OutputArtifact, + RuntimeMetricSample, + StepRequest, + StepResult, + TimeWindow, + UserInputCapability, + UserInputEvent, + UserInputs, + UserInputSchema, + run_inference_session, +) + +pytestmark = pytest.mark.ci_cpu + + +def test_run_inference_session_completes_two_step_run() -> None: + adapter = _FakeAdapter() + output = NullOutputTarget(store_results=True) + metrics = InMemoryMetricsRecorder() + + artifacts = run_inference_session( + adapter=adapter, + config=InferenceConfig(model_id="fake-model"), + mapping=_ChunkIndexMapping(), + canonicalizer=InputCanonicalizer(), + source_schema=UserInputSchema(), + user_inputs=UserInputs(), + initial_inputs=InferenceInput(global_conditioning={"prompt": "drive forward"}), + output=output, + metrics=metrics, + ) + + assert artifacts == () + assert output.closed + assert output.output_count == 2 + assert [result.output for result in output.results] == ["chunk-0", "chunk-1"] + assert [result.frame_count for result in output.results] == [3, 3] + assert output.results[0].output_window == TimeWindow(start_s=0.0, end_s=0.5) + assert adapter.runtime is not None + assert adapter.runtime.closed + assert adapter.runtime.session is not None + assert adapter.runtime.session.closed + assert [sample.name for sample in metrics.samples] == ["model_step", "model_step"] + assert [sample.step_index for sample in metrics.samples] == [0, 1] + assert metrics.closed + + +def test_runner_preserves_initial_step_inputs_for_identity_mapping() -> None: + adapter = _FakeAdapter() + + run_inference_session( + adapter=adapter, + config=InferenceConfig(model_id="fake-model"), + mapping=IdentityInputMapping(), + canonicalizer=InputCanonicalizer(), + source_schema=UserInputSchema(), + user_inputs=UserInputs(), + initial_inputs=InferenceInput( + global_conditioning={"prompt": "drive forward"}, + step={"chunk_index": 42}, + ), + output=NullOutputTarget(), + metrics=InMemoryMetricsRecorder(), + ) + + assert adapter.runtime is not None + assert adapter.runtime.session is not None + assert [dict(inputs.step) for inputs in adapter.runtime.session.step_inputs] == [ + {"chunk_index": 42}, + {"chunk_index": 42}, + ] + assert [ + dict(inputs.global_conditioning) + for inputs in adapter.runtime.session.step_inputs + ] == [{}, {}] + + +def test_runner_validates_mapping_before_runtime_creation() -> None: + mapping = _OrderCheckingMapping() + adapter = _OrderCheckingAdapter(mapping=mapping) + + run_inference_session( + adapter=adapter, + config=InferenceConfig(model_id="fake-model"), + mapping=mapping, + canonicalizer=InputCanonicalizer(), + source_schema=UserInputSchema(), + user_inputs=UserInputs(), + initial_inputs=InferenceInput(global_conditioning={"prompt": "drive forward"}), + output=NullOutputTarget(), + metrics=InMemoryMetricsRecorder(), + ) + + assert mapping.validated + assert adapter.created_runtime_after_validate + + +def test_runner_closes_runtime_when_session_start_fails() -> None: + adapter = _FailingStartAdapter() + output = _RecordingOutputTarget() + metrics = InMemoryMetricsRecorder() + + with pytest.raises(RuntimeError, match="start failed"): + run_inference_session( + adapter=adapter, + config=InferenceConfig(model_id="fake-model"), + mapping=_ChunkIndexMapping(), + canonicalizer=InputCanonicalizer(), + source_schema=UserInputSchema(), + user_inputs=UserInputs(), + initial_inputs=InferenceInput( + global_conditioning={"prompt": "drive forward"} + ), + output=output, + metrics=metrics, + ) + + assert adapter.runtime is not None + assert adapter.runtime.closed + assert output.events == () + assert metrics.closed + + +def test_runner_does_not_canonicalize_global_conditioning() -> None: + mapping = _CanonicalRecordingMapping() + + run_inference_session( + adapter=_FakeAdapter(), + config=InferenceConfig(model_id="fake-model"), + mapping=mapping, + canonicalizer=InputCanonicalizer([_CountingDeviceConverter()]), + source_schema=UserInputSchema( + capabilities=( + UserInputCapability( + event_type="stateful_event", + payload_fields=frozenset(), + ), + ) + ), + user_inputs=UserInputs( + events=(UserInputEvent(timestamp_s=0.75, event_type="stateful_event"),) + ), + initial_inputs=InferenceInput(global_conditioning={"prompt": "drive forward"}), + output=NullOutputTarget(), + metrics=InMemoryMetricsRecorder(), + ) + + assert mapping.global_canonical_values == {} + assert mapping.step_canonical_values == ( + {"stateful_counter": {"count": 0}}, + {"stateful_counter": {"count": 1}}, + ) + + +def test_runner_closes_opened_resources_after_output_failure() -> None: + events: list[str] = [] + adapter = _RecordingAdapter(events=events) + output = _FailingWriteOutputTarget(events=events) + metrics = _RecordingMetricsRecorder(events=events) + + with pytest.raises(RuntimeError, match="write failed"): + run_inference_session( + adapter=adapter, + config=InferenceConfig(model_id="fake-model"), + mapping=_ChunkIndexMapping(), + canonicalizer=InputCanonicalizer(), + source_schema=UserInputSchema(), + user_inputs=UserInputs(), + initial_inputs=InferenceInput( + global_conditioning={"prompt": "drive forward"} + ), + output=output, + metrics=metrics, + ) + + assert events == [ + "runtime.start_session", + "output.open", + "session.step:0", + "output.write:0", + "output.close", + "session.close", + "runtime.close", + "metrics.close", + ] + + +def test_runner_attempts_later_cleanup_when_output_close_fails() -> None: + events: list[str] = [] + adapter = _RecordingAdapter(events=events) + output = _FailingCloseOutputTarget(events=events) + metrics = _RecordingMetricsRecorder(events=events) + + with pytest.raises(RuntimeError, match="close failed"): + run_inference_session( + adapter=adapter, + config=InferenceConfig(model_id="fake-model"), + mapping=_ChunkIndexMapping(), + canonicalizer=InputCanonicalizer(), + source_schema=UserInputSchema(), + user_inputs=UserInputs(), + initial_inputs=InferenceInput( + global_conditioning={"prompt": "drive forward"} + ), + output=output, + metrics=metrics, + ) + + assert events == [ + "runtime.start_session", + "output.open", + "session.step:0", + "output.write:0", + "session.step:1", + "output.write:1", + "output.close", + "session.close", + "runtime.close", + "metrics.close", + ] + + +def test_runner_checks_declared_mapping_compatibility_before_runtime_creation() -> None: + adapter = _DrivingAdapter() + mapping = _UnfeedableDriverCommandMapping() + metrics = InMemoryMetricsRecorder() + + with pytest.raises(ValueError, match="cannot drive this model"): + run_inference_session( + adapter=adapter, + config=InferenceConfig(model_id="fake-model"), + mapping=mapping, + canonicalizer=InputCanonicalizer(), + source_schema=UserInputSchema(), + user_inputs=UserInputs(), + initial_inputs=InferenceInput( + global_conditioning={"prompt": "drive forward"} + ), + output=NullOutputTarget(), + metrics=metrics, + ) + + assert not adapter.create_runtime_called + assert not mapping.validated + assert metrics.closed + + +class _ChunkIndexMapping: + mapping_schema = InputMappingSchema( + name="chunk-index", + produces_global_conditioning=(InputField(name="prompt"),), + produces_step=(InputField(name="chunk_index"),), + ) + + def __init__(self) -> None: + self.validated = False + + def validate( + self, + *, + canonical_schema: CanonicalInputSchema | None = None, + inference_input_schema: InferenceInputSchema | None = None, + ) -> None: + del canonical_schema, inference_input_schema + self.validated = True + + def map_global_conditioning_inputs( + self, + *, + canonical_inputs: CanonicalInputs, + inference_input: InferenceInput, + ) -> InferenceInput: + del canonical_inputs + return inference_input + + def map_step_inputs( + self, + *, + canonical_inputs: CanonicalInputs, + inference_input: InferenceInput, + request: StepRequest, + ) -> InferenceInput: + del canonical_inputs + return InferenceInput( + global_conditioning=inference_input.global_conditioning, + step={"chunk_index": request.step_index}, + metadata=inference_input.metadata, + ) + + +class _UnfeedableDriverCommandMapping(_ChunkIndexMapping): + mapping_schema = InputMappingSchema( + name="driver-command", + consumes=(DRIVER_COMMAND,), + produces_global_conditioning=(InputField(name="prompt"),), + produces_step=(InputField(name="steering"),), + ) + + def map_step_inputs( + self, + *, + canonical_inputs: CanonicalInputs, + inference_input: InferenceInput, + request: StepRequest, + ) -> InferenceInput: + del request + return InferenceInput( + global_conditioning=inference_input.global_conditioning, + step={ + "steering": canonical_inputs.values[DRIVER_COMMAND.name]["steer"], + }, + metadata=inference_input.metadata, + ) + + +class _CanonicalRecordingMapping(_ChunkIndexMapping): + def __init__(self) -> None: + super().__init__() + self.global_canonical_values: Mapping[str, Any] | None = None + self._step_canonical_values: list[Mapping[str, Any]] = [] + + @property + def step_canonical_values(self) -> tuple[Mapping[str, Any], ...]: + return tuple(self._step_canonical_values) + + def map_global_conditioning_inputs( + self, + *, + canonical_inputs: CanonicalInputs, + inference_input: InferenceInput, + ) -> InferenceInput: + self.global_canonical_values = canonical_inputs.values + return super().map_global_conditioning_inputs( + canonical_inputs=canonical_inputs, + inference_input=inference_input, + ) + + def map_step_inputs( + self, + *, + canonical_inputs: CanonicalInputs, + inference_input: InferenceInput, + request: StepRequest, + ) -> InferenceInput: + self._step_canonical_values.append(canonical_inputs.values) + return super().map_step_inputs( + canonical_inputs=canonical_inputs, + inference_input=inference_input, + request=request, + ) + + +_STATEFUL_COUNTER = CanonicalModality( + name="stateful_counter", + payload_fields=frozenset({"count"}), +) + + +class _CountingDeviceConverter: + schema = DeviceConverterSchema( + name="stateful-counter", + produces=_STATEFUL_COUNTER, + consumes=(UserInputCapability(event_type="stateful_event"),), + ) + + def __init__(self) -> None: + self.count = 0 + + def reset(self) -> None: + self.count = 0 + + def convert( + self, + user_inputs: UserInputs, + window: TimeWindow, + ) -> Mapping[str, Any] | None: + del window + self.count += len(user_inputs.events) + return _STATEFUL_COUNTER.value({"count": self.count}) + + +class _FakeAdapter: + model_id = "fake-model" + inference_input_schema = InferenceInputSchema( + global_conditioning_fields=(InputField(name="prompt"),), + step_fields=(InputField(name="chunk_index"),), + ) + canonical_input_schema = CanonicalInputSchema() + + def __init__(self) -> None: + self.runtime: _FakeRuntime | None = None + self.create_runtime_called = False + + def default_input_mapping(self) -> InputMapping: + return _ChunkIndexMapping() + + def validate_config(self, config: InferenceConfig) -> None: + if config.model_id != self.model_id: + raise ValueError(f"Unsupported model_id={config.model_id!r}.") + + def create_runtime(self, config: InferenceConfig) -> InferenceRuntime: + self.validate_config(config) + self.create_runtime_called = True + self.runtime = _FakeRuntime(inference_input_schema=self.inference_input_schema) + return self.runtime + + +class _DrivingAdapter(_FakeAdapter): + inference_input_schema = InferenceInputSchema( + global_conditioning_fields=(InputField(name="prompt"),), + step_fields=(InputField(name="steering"),), + ) + + +class _OrderCheckingMapping(_ChunkIndexMapping): + pass + + +class _OrderCheckingAdapter(_FakeAdapter): + def __init__(self, *, mapping: _OrderCheckingMapping) -> None: + super().__init__() + self._mapping = mapping + self.created_runtime_after_validate = False + + def create_runtime(self, config: InferenceConfig) -> InferenceRuntime: + self.validate_config(config) + self.created_runtime_after_validate = self._mapping.validated + self.create_runtime_called = True + self.runtime = _FakeRuntime(inference_input_schema=self.inference_input_schema) + return self.runtime + + +class _FailingStartAdapter(_FakeAdapter): + def create_runtime(self, config: InferenceConfig) -> InferenceRuntime: + self.validate_config(config) + self.create_runtime_called = True + self.runtime = _FailingRuntime( + inference_input_schema=self.inference_input_schema + ) + return self.runtime + + +class _RecordingAdapter(_FakeAdapter): + def __init__(self, *, events: list[str]) -> None: + super().__init__() + self._events = events + + def create_runtime(self, config: InferenceConfig) -> InferenceRuntime: + self.validate_config(config) + self.create_runtime_called = True + self.runtime = _RecordingRuntime( + inference_input_schema=self.inference_input_schema, + events=self._events, + ) + return self.runtime + + +class _FakeRuntime: + def __init__(self, *, inference_input_schema: InferenceInputSchema) -> None: + self._inference_input_schema = inference_input_schema + self.session: _FakeSession | None = None + self.closed = False + + def start_session(self, inputs: InferenceInput) -> InferenceSession: + self._inference_input_schema.require_global_conditioning(inputs) + self.session = _FakeSession(inference_input_schema=self._inference_input_schema) + return self.session + + def close(self) -> None: + self.closed = True + + +class _FailingRuntime(_FakeRuntime): + def start_session(self, inputs: InferenceInput) -> InferenceSession: + del inputs + raise RuntimeError("start failed") + + +class _RecordingRuntime(_FakeRuntime): + def __init__( + self, + *, + inference_input_schema: InferenceInputSchema, + events: list[str], + ) -> None: + super().__init__(inference_input_schema=inference_input_schema) + self._events = events + + def start_session(self, inputs: InferenceInput) -> InferenceSession: + self._events.append("runtime.start_session") + self._inference_input_schema.require_global_conditioning(inputs) + self.session = _RecordingSession( + inference_input_schema=self._inference_input_schema, + events=self._events, + ) + return self.session + + def close(self) -> None: + self._events.append("runtime.close") + super().close() + + +class _FakeSession: + def __init__(self, *, inference_input_schema: InferenceInputSchema) -> None: + self._inference_input_schema = inference_input_schema + self.step_index = 0 + self.step_inputs: list[InferenceInput] = [] + self.closed = False + + def next_step_request(self) -> StepRequest | None: + if self.step_index >= 2: + return None + return StepRequest( + step_index=self.step_index, + inference_input_schema=self._inference_input_schema, + user_input_window=TimeWindow( + start_s=0.5 * self.step_index, + end_s=0.5 * (self.step_index + 1), + ), + ) + + def step(self, inputs: InferenceInput) -> StepResult: + self._inference_input_schema.require_step(inputs) + self.step_inputs.append(inputs) + result = StepResult( + step_index=self.step_index, + output=f"chunk-{self.step_index}", + frame_count=3, + output_window=TimeWindow( + start_s=0.5 * self.step_index, + end_s=0.5 * (self.step_index + 1), + ), + metrics={"model_step_s": 0.01, "frames": 3}, + ) + self.step_index += 1 + return result + + def reset(self, inputs: InferenceInput | None = None) -> None: + del inputs + self.step_index = 0 + + def close(self) -> None: + self.closed = True + + +class _RecordingSession(_FakeSession): + def __init__( + self, + *, + inference_input_schema: InferenceInputSchema, + events: list[str], + ) -> None: + super().__init__(inference_input_schema=inference_input_schema) + self._events = events + + def step(self, inputs: InferenceInput) -> StepResult: + self._events.append(f"session.step:{self.step_index}") + return super().step(inputs) + + def close(self) -> None: + self._events.append("session.close") + super().close() + + +class _RecordingOutputTarget: + def __init__(self, *, events: list[str] | None = None) -> None: + self._events = events + self._opened = False + + @property + def events(self) -> tuple[str, ...]: + return () if self._events is None else tuple(self._events) + + def open(self) -> None: + self._opened = True + if self._events is not None: + self._events.append("output.open") + + def write(self, result: StepResult) -> None: + if not self._opened: + raise RuntimeError("Cannot write to a closed output target.") + if self._events is not None: + self._events.append(f"output.write:{result.step_index}") + + def close(self) -> Sequence[OutputArtifact]: + self._opened = False + if self._events is not None: + self._events.append("output.close") + return () + + +class _FailingWriteOutputTarget(_RecordingOutputTarget): + def write(self, result: StepResult) -> None: + super().write(result) + raise RuntimeError("write failed") + + +class _FailingCloseOutputTarget(_RecordingOutputTarget): + def close(self) -> Sequence[OutputArtifact]: + super().close() + raise RuntimeError("close failed") + + +class _RecordingMetricsRecorder: + def __init__(self, *, events: list[str]) -> None: + self._events = events + self.samples: list[RuntimeMetricSample] = [] + + def record(self, sample: RuntimeMetricSample) -> None: + self.samples.append(sample) + + def record_timing( + self, + name: str, + duration_s: float, + *, + step_index: int | None = None, + metadata: Mapping[str, Any] | None = None, + ) -> None: + self.record( + RuntimeMetricSample( + name=name, + value=duration_s, + unit="s", + step_index=step_index, + category="timing", + metadata={} if metadata is None else metadata, + ) + ) + + def close(self) -> None: + self._events.append("metrics.close") diff --git a/flashdreams/tests/test_runtime_video_output.py b/flashdreams/tests/test_runtime_video_output.py new file mode 100644 index 000000000..898acf734 --- /dev/null +++ b/flashdreams/tests/test_runtime_video_output.py @@ -0,0 +1,91 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import pytest +import torch + +from flashdreams.infra.video_output import VideoStepResult +from flashdreams.runtime import Mp4VideoOutputTarget, StepResult, TimeWindow + +pytestmark = pytest.mark.ci_cpu + + +def test_mp4_video_output_target_rejects_non_video_payload(tmp_path: Path) -> None: + target = Mp4VideoOutputTarget(output_path=tmp_path / "out.mp4", fps=30) + target.open() + + with pytest.raises(TypeError, match="VideoStepResult"): + target.write(StepResult(step_index=0, output="not-video")) + + +def test_mp4_video_output_target_writes_artifact_on_close(tmp_path: Path) -> None: + calls: list[dict[str, Any]] = [] + + def fake_writer( + video: torch.Tensor, + path: Path, + *, + fps: int | float, + layout: str, + install_hint: str, + ) -> Path: + del install_hint + calls.append( + { + "shape": tuple(video.shape), + "path": path, + "fps": fps, + "layout": layout, + } + ) + return path + + target = Mp4VideoOutputTarget( + output_path=tmp_path / "omnidreams.mp4", + fps=24, + writer=fake_writer, + move_to_cpu=False, + ) + target.open() + target.write( + StepResult( + step_index=3, + output=VideoStepResult.from_video_chunk( + chunk_index=3, + video_chunk=torch.zeros((1, 2, 4, 3, 5, 6)), + layout="bvtchw", + stats={"model_step_s": 0.5}, + ), + frame_count=4, + output_window=TimeWindow(start_s=1.0, end_s=2.0), + ) + ) + + artifacts = target.close() + + assert len(artifacts) == 1 + assert artifacts[0].kind == "video/mp4" + assert artifacts[0].uri == str(tmp_path / "omnidreams.mp4") + assert calls == [ + { + "shape": (4, 5, 12, 3), + "path": tmp_path / "omnidreams.mp4", + "fps": 24, + "layout": "thwc", + } + ] + assert artifacts[0].metadata["stats_history"] == ( + { + "autoregressive_index": 3, + "model_step_s": 0.5, + "step_index": 3, + "frames": 4, + "output_start_s": 1.0, + "output_end_s": 2.0, + }, + ) diff --git a/integrations/omnidreams/omnidreams/demo/README.md b/integrations/omnidreams/omnidreams/demo/README.md new file mode 100644 index 000000000..d69c0170a --- /dev/null +++ b/integrations/omnidreams/omnidreams/demo/README.md @@ -0,0 +1,66 @@ + + +# OmniDreams Shared Demo API + +This folder contains the experimental OmniDreams demo built on +`flashdreams.runtime.demo`. + +Run commands from the FlashDreams workspace root: + +```bash +cd /path/to/flashdreams +export HF_TOKEN= +``` + +## MP4 Replay + +Generate an MP4 from the bundled single-view sample data: + +```bash +mkdir -p outputs +uv run --package flashdreams-omnidreams omnidreams-demo replay \ + --output outputs/omnidreams-demo.mp4 +``` + +This replay path mirrors the benchmark runner path: it uses a prompt, first +frame, and pre-rendered HDMap video. It does not load a Ludus scene or render +HDMaps at runtime. The demo defaults to the stable non-perf OmniDreams preset +used by the benchmark path. + +To provide benchmark-style assets explicitly: + +```bash +uv run --package flashdreams-omnidreams omnidreams-demo replay \ + --prompt "Driving scene from a front-facing car camera." \ + --hdmap-video-paths /path/to/camera_front_wide_120fov_hdmap.mp4 \ + --first-frame-paths /path/to/first_frame.png \ + --camera-names camera_front_wide_120fov \ + --output outputs/omnidreams-demo.mp4 +``` + +Pass `--example-data-uuid ` to select another bundled single-view sample, +or `--no-example-data` to require explicit asset paths. + +The `omnidreams-sv-2steps-chunk2-loc6-lightvae-lighttae-perf` preset remains an +explicit `--preset-id` opt-in. It should become the default only after the +compile/cache behavior is reliable enough for the demo path. + +## WebRTC + +WebRTC uses the shared demo launcher around the existing Omnidreams live WebRTC +runtime. It is still scene-driven and uses Ludus to render HDMap conditioning +from a scene: + +```bash +uv run --package flashdreams-omnidreams omnidreams-demo webrtc \ + --host 0.0.0.0 \ + --port 8082 +``` + +The scene UUID is optional; when omitted, the runtime uses the default +Hugging Face WebRTC scene. Override the scene with `--scene-uuid`, select a +weather variant with `--scene-variant default|rain|snow`, or use +`--scene-dir /path/to/local/scene` for a local staged scene. diff --git a/integrations/omnidreams/omnidreams/demo/__init__.py b/integrations/omnidreams/omnidreams/demo/__init__.py new file mode 100644 index 000000000..6fa3a9b21 --- /dev/null +++ b/integrations/omnidreams/omnidreams/demo/__init__.py @@ -0,0 +1,20 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Experimental OmniDreams demo adapter built on ``flashdreams.runtime.demo``.""" + +from omnidreams.demo.adapter import OmnidreamsDemoAdapter +from omnidreams.demo.spec import ( + DEFAULT_OMNIDREAMS_PRESET, + OMNIDREAMS_MODEL_ID, + OmnidreamsReplayScenario, + OmnidreamsWebRTCScenario, +) + +__all__ = [ + "DEFAULT_OMNIDREAMS_PRESET", + "OMNIDREAMS_MODEL_ID", + "OmnidreamsDemoAdapter", + "OmnidreamsReplayScenario", + "OmnidreamsWebRTCScenario", +] diff --git a/integrations/omnidreams/omnidreams/demo/adapter.py b/integrations/omnidreams/omnidreams/demo/adapter.py new file mode 100644 index 000000000..e16c5c0a1 --- /dev/null +++ b/integrations/omnidreams/omnidreams/demo/adapter.py @@ -0,0 +1,279 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""OmniDreams adapter for the shared demo API.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import replace +from typing import Any + +from omnidreams.config import OMNIDREAMS_CONFIGS, OMNIDREAMS_RUNNERS +from omnidreams.webrtc.session import ( + OmnidreamsInferenceRuntime, + OmnidreamsRuntimeConfig, +) + +from flashdreams.infra.postprocess import VideoPostprocessChainConfig +from flashdreams.runtime import ( + CanonicalInputSchema, + IdentityInputMapping, + InferenceConfig, + InferenceInput, + InferenceInputSchema, + InputCanonicalizer, + InputField, + UserInputSchema, +) +from flashdreams.runtime.demo import ( + DemoSpec, + Mp4OutputSpec, + PreparedScenario, + WebRTCOutputSpec, +) +from flashdreams.runtime.interfaces import InferenceRuntime + +from .replay import ( + OmnidreamsReplayRuntime, + OmnidreamsReplayRuntimeOptions, + PipelineFactory, +) +from .spec import ( + DEFAULT_OMNIDREAMS_PRESET, + OMNIDREAMS_MODEL_ID, + resolve_replay_scenario, + resolve_webrtc_scenario, +) +from .webrtc import ( + OmnidreamsDemoWebRTCSessionManager, + create_omnidreams_webrtc_app, + validate_postprocess_preset, +) + +ReplayRuntimeFactory = Callable[..., InferenceRuntime] +WebRTCRuntimeFactory = Callable[..., Any] + + +class OmnidreamsDemoAdapter: + """Model-owned OmniDreams adapter consumed by shared demo launchers.""" + + def __init__( + self, + *, + replay_runtime_factory: ReplayRuntimeFactory = OmnidreamsReplayRuntime, + webrtc_runtime_factory: WebRTCRuntimeFactory = OmnidreamsInferenceRuntime, + pipeline_factory: PipelineFactory | None = None, + ) -> None: + self._replay_runtime_factory = replay_runtime_factory + self._webrtc_runtime_factory = webrtc_runtime_factory + self._pipeline_factory = pipeline_factory + self._mapping = IdentityInputMapping() + + @property + def model_id(self) -> str: + return OMNIDREAMS_MODEL_ID + + @property + def inference_input_schema(self) -> InferenceInputSchema: + return InferenceInputSchema( + global_conditioning_fields=( + InputField( + name="scenario", + input_modality="omnidreams/replay-scenario", + description="Resolved OmniDreams replay scenario.", + ), + ) + ) + + @property + def canonical_input_schema(self) -> CanonicalInputSchema | None: + return None + + def default_input_mapping(self) -> IdentityInputMapping: + return self._mapping + + def supported_input_modes(self) -> tuple[str, ...]: + return ("replay", "keyboard-driving") + + def supported_output_modes(self) -> tuple[str, ...]: + return ("mp4", "webrtc") + + def prepare_scenario(self, spec: DemoSpec) -> PreparedScenario: + if spec.input_mode != "replay": + raise ValueError( + "OmniDreams prepare_scenario currently supports only " + f"input_mode='replay', got {spec.input_mode!r}." + ) + if not isinstance(spec.output, Mp4OutputSpec): + raise ValueError("OmniDreams replay demo currently requires MP4 output.") + scenario = resolve_replay_scenario( + spec.scenario, + default_prompt=self._default_replay_prompt(spec.config), + ) + return PreparedScenario( + initial_inputs=InferenceInput( + global_conditioning={"scenario": scenario}, + ), + source_schema=UserInputSchema(description="fixed OmniDreams replay input"), + canonicalizer=InputCanonicalizer(), + mapping=self._mapping, + metadata={ + "model_id": self.model_id, + "preset_id": self._preset_id(spec.config), + "num_views": len(scenario.camera_names), + }, + ) + + def validate_config(self, config: InferenceConfig) -> None: + if config.model_id != self.model_id: + raise ValueError( + f"OmniDreams adapter requires model_id={self.model_id!r}, " + f"got {config.model_id!r}." + ) + self._pipeline_config(config) + + def create_runtime(self, config: InferenceConfig) -> InferenceRuntime: + self.validate_config(config) + return self._replay_runtime_factory( + config=config, + options=OmnidreamsReplayRuntimeOptions( + pipeline_config=self._pipeline_config(config), + pipeline_factory=self._pipeline_factory, + ), + ) + + def create_webrtc_runtime(self, spec: DemoSpec) -> Any: + runtime_config = self.create_webrtc_runtime_config(spec=spec, runtime=None) + return self._webrtc_runtime_factory(config=runtime_config) + + def create_webrtc_runtime_config( + self, + *, + spec: DemoSpec, + runtime: Any, + ) -> OmnidreamsRuntimeConfig: + runtime_config = getattr(runtime, "config", None) + if isinstance(runtime_config, OmnidreamsRuntimeConfig): + return runtime_config + if spec.input_mode != "keyboard-driving": + raise ValueError( + "OmniDreams WebRTC requires input_mode='keyboard-driving', " + f"got {spec.input_mode!r}." + ) + if not isinstance(spec.output, WebRTCOutputSpec): + raise ValueError("OmniDreams WebRTC requires WebRTC output.") + config = spec.config + if config is None: + raise RuntimeError("DemoSpec.config was not initialized.") + self.validate_config(config) + scenario = resolve_webrtc_scenario(spec.scenario) + validate_postprocess_preset(scenario.postprocess_preset) + + preset_id = self._preset_id(config) + pipeline_config = self._pipeline_config(config) + seed = _option(config, "seed", 42) + device = config.device or str(_option(config, "device", "cuda:0")) + runtime_config = OmnidreamsRuntimeConfig( + pipeline_config_name=preset_id, + pipeline_config=pipeline_config, + scene_dir=scenario.scene_dir, + scene_uuid=scenario.scene_uuid, + scene_variant=scenario.scene_variant, + seed=None if seed is None else int(seed), + device=device, + video_height=spec.output.video_height, + video_width=spec.output.video_width, + fps=spec.output.fps, + camera_name=scenario.camera_name, + warmup_chunks=spec.output.warmup_chunks, + warmup_timeout_s=spec.output.warmup_timeout_s, + debug_serve_hdmaps=scenario.debug_serve_hdmaps, + postprocess=VideoPostprocessChainConfig(preset=scenario.postprocess_preset), + encoder_backend="default" if scenario.prefer_sw_encoder else "auto", + ) + return _apply_webrtc_runtime_options(runtime_config, config.runtime_options) + + def create_webrtc_session_manager( + self, + *, + spec: DemoSpec, + runtime: Any, + runtime_config: OmnidreamsRuntimeConfig, + fps: int, + client_liveness_timeout_s: float, + ) -> OmnidreamsDemoWebRTCSessionManager: + del spec + return OmnidreamsDemoWebRTCSessionManager( + runtime=runtime, + runtime_config=runtime_config, + fps=fps, + client_liveness_timeout_s=client_liveness_timeout_s, + ) + + def create_webrtc_app( + self, + *, + spec: DemoSpec, + session_manager: Any, + request_session_url: str, + ) -> Any: + return create_omnidreams_webrtc_app( + spec=spec, + session_manager=session_manager, + request_session_url=request_session_url, + ) + + def _preset_id(self, config: InferenceConfig | None) -> str: + return ( + DEFAULT_OMNIDREAMS_PRESET + if config is None or config.preset_id is None + else config.preset_id + ) + + def _pipeline_config(self, config: InferenceConfig) -> Any: + custom = config.runtime_options.get("pipeline_config") + if custom is not None: + return custom + preset_id = self._preset_id(config) + try: + return OMNIDREAMS_CONFIGS[preset_id] + except KeyError as exc: + supported = ", ".join(sorted(OMNIDREAMS_CONFIGS)) + raise ValueError( + f"Unsupported OmniDreams preset_id={preset_id!r}. " + f"Supported presets: {supported}." + ) from exc + + def _default_replay_prompt(self, config: InferenceConfig | None) -> str: + runner = OMNIDREAMS_RUNNERS.get(self._preset_id(config)) + return "" if runner is None else str(getattr(runner, "prompt", "")) + + +def _option(config: InferenceConfig, name: str, default: Any) -> Any: + return config.runtime_options.get(name, default) + + +def _apply_webrtc_runtime_options( + runtime_config: OmnidreamsRuntimeConfig, + options: Any, +) -> OmnidreamsRuntimeConfig: + if not isinstance(options, dict): + options = dict(options) + overrides: dict[str, Any] = {} + for name in ( + "move_speed_per_s", + "rotate_speed_rad_per_s", + "encoder_bitrate_bps", + "encoder_gop", + ): + if name in options: + overrides[name] = options[name] + return replace(runtime_config, **overrides) if overrides else runtime_config + + +__all__ = [ + "OmnidreamsDemoAdapter", + "ReplayRuntimeFactory", + "WebRTCRuntimeFactory", +] diff --git a/integrations/omnidreams/omnidreams/demo/cli.py b/integrations/omnidreams/omnidreams/demo/cli.py new file mode 100644 index 000000000..d35a62b78 --- /dev/null +++ b/integrations/omnidreams/omnidreams/demo/cli.py @@ -0,0 +1,187 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""CLI for the experimental shared OmniDreams demo path.""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +import torch +import torch.distributed as dist +from omnidreams.runner import DEFAULT_EXAMPLE_DATA_UUID_1V + +from flashdreams.core.distributed import init as distributed_init +from flashdreams.runtime import InferenceConfig +from flashdreams.runtime.demo import ( + DemoSpec, + Mp4OutputSpec, + WebRTCOutputSpec, + run_flashdreams_demo, + serve_flashdreams_demo, +) +from flashdreams.serving.webrtc.bootstrap import ( + configure_logging, + initialize_cuda_distributed, +) + +from .adapter import OmnidreamsDemoAdapter +from .spec import ( + DEFAULT_OMNIDREAMS_PRESET, + OMNIDREAMS_MODEL_ID, + OmnidreamsWebRTCScenario, +) + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Experimental OmniDreams demo using flashdreams.runtime.demo." + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + replay = subparsers.add_parser("replay", help="Run an MP4 replay demo.") + replay.add_argument("--preset-id", default=DEFAULT_OMNIDREAMS_PRESET) + replay.add_argument("--device", default="cuda") + replay.add_argument("--prompt", default=None) + replay.add_argument("--hdmap-video-paths", type=_split_paths, default=()) + replay.add_argument("--first-frame-paths", type=_split_paths, default=()) + replay.add_argument("--camera-names", type=_split_strings, default=()) + replay.add_argument( + "--example-data", + action=argparse.BooleanOptionalAction, + default=None, + help=( + "Use the bundled single-view HF sample when asset paths are omitted " + "(default: auto)." + ), + ) + replay.add_argument("--example-data-uuid", default=DEFAULT_EXAMPLE_DATA_UUID_1V) + replay.add_argument("--total-blocks", type=int, default=60) + replay.add_argument("--pixel-height", type=int, default=704) + replay.add_argument("--pixel-width", type=int, default=1280) + replay.add_argument("--fps", type=int, default=30) + replay.add_argument("--output", type=Path, required=True) + + webrtc = subparsers.add_parser("webrtc", help="Serve a WebRTC driving demo.") + webrtc.add_argument("--preset-id", default=DEFAULT_OMNIDREAMS_PRESET) + webrtc.add_argument("--host", default="0.0.0.0") + webrtc.add_argument("--port", type=int, default=8082) + webrtc.add_argument("--device", default="cuda:0") + webrtc.add_argument("--seed", type=int, default=42) + webrtc.add_argument("--scene-dir", type=Path, default=None) + webrtc.add_argument("--scene-uuid", default=None) + webrtc.add_argument("--scene-variant", default="default") + webrtc.add_argument("--camera-name", default="camera_front_wide_120fov") + webrtc.add_argument("--fps", type=int, default=30) + webrtc.add_argument("--video-height", type=int, default=704) + webrtc.add_argument("--video-width", type=int, default=1280) + webrtc.add_argument("--warmup-chunks", type=int, default=10) + webrtc.add_argument("--warmup-timeout-s", type=float, default=600.0) + webrtc.add_argument("--client-liveness-timeout-s", type=float, default=10.0) + webrtc.add_argument("--debug-serve-hdmaps", action="store_true") + webrtc.add_argument("--postprocess-preset", default="") + webrtc.add_argument("--prefer-sw-encoder", action="store_true") + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> None: + configure_logging() + args = parse_args(argv) + adapter = OmnidreamsDemoAdapter() + if args.command == "replay": + run_flashdreams_demo(spec=_replay_spec(args), adapter=adapter) + return + if args.command == "webrtc": + context = initialize_cuda_distributed( + default_device=args.device, + distributed_init_fn=distributed_init, + configure_logging_fn=configure_logging, + torch_module=torch, + dist_module=dist, + ) + serve_flashdreams_demo( + spec=_webrtc_spec(args, device=str(context.device)), + adapter=adapter, + world_rank=context.world_rank, + ) + return + raise AssertionError(f"Unhandled command: {args.command}") + + +def _replay_spec(args: argparse.Namespace) -> DemoSpec: + scenario: dict[str, object] = { + "example_data": args.example_data, + "example_data_uuid": args.example_data_uuid, + "total_blocks": args.total_blocks, + "pixel_height": args.pixel_height, + "pixel_width": args.pixel_width, + "fps": args.fps, + } + if args.prompt: + scenario["prompt"] = args.prompt + if args.hdmap_video_paths: + scenario["hdmap_video_paths"] = args.hdmap_video_paths + if args.first_frame_paths: + scenario["first_frame_paths"] = args.first_frame_paths + if args.camera_names: + scenario["camera_names"] = args.camera_names + + return DemoSpec( + model_id=OMNIDREAMS_MODEL_ID, + preset_id=args.preset_id, + input_mode="replay", + scenario=scenario, + output=Mp4OutputSpec(path=args.output, fps=args.fps), + config=InferenceConfig( + model_id=OMNIDREAMS_MODEL_ID, + preset_id=args.preset_id, + device=args.device, + ), + ) + + +def _webrtc_spec(args: argparse.Namespace, *, device: str) -> DemoSpec: + return DemoSpec( + model_id=OMNIDREAMS_MODEL_ID, + preset_id=args.preset_id, + input_mode="keyboard-driving", + scenario=OmnidreamsWebRTCScenario( + scene_dir=args.scene_dir, + scene_uuid=args.scene_uuid, + scene_variant=args.scene_variant, + camera_name=args.camera_name, + debug_serve_hdmaps=args.debug_serve_hdmaps, + postprocess_preset=args.postprocess_preset, + prefer_sw_encoder=args.prefer_sw_encoder, + ), + output=WebRTCOutputSpec( + host=args.host, + port=args.port, + fps=args.fps, + video_width=args.video_width, + video_height=args.video_height, + warmup_chunks=args.warmup_chunks, + warmup_timeout_s=args.warmup_timeout_s, + client_liveness_timeout_s=args.client_liveness_timeout_s, + preload_name="Omnidreams", + ), + config=InferenceConfig( + model_id=OMNIDREAMS_MODEL_ID, + preset_id=args.preset_id, + device=device, + runtime_options={"seed": args.seed}, + ), + ) + + +def _split_paths(value: str) -> tuple[Path, ...]: + return tuple(Path(part) for part in value.split(",") if part) + + +def _split_strings(value: str) -> tuple[str, ...]: + return tuple(part for part in value.split(",") if part) + + +if __name__ == "__main__": + main() diff --git a/integrations/omnidreams/omnidreams/demo/replay.py b/integrations/omnidreams/omnidreams/demo/replay.py new file mode 100644 index 000000000..908d84568 --- /dev/null +++ b/integrations/omnidreams/omnidreams/demo/replay.py @@ -0,0 +1,277 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""OmniDreams replay runtime for the shared demo runner.""" + +from __future__ import annotations + +import os +import time +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from typing import Any + +import torch +import torch.distributed as dist +from loguru import logger +from omnidreams.runner import _load_video + +from flashdreams.core.distributed import init as init_distributed +from flashdreams.infra.postprocess import VideoTensorLayout +from flashdreams.infra.runner_io import ( + DEFAULT_RUNNER_INSTALL_HINT, + load_first_frame_tensor, +) +from flashdreams.infra.video_output import VideoStepResult +from flashdreams.runtime.config import InferenceConfig +from flashdreams.runtime.inputs import InferenceInput +from flashdreams.runtime.interfaces import InferenceSession +from flashdreams.runtime.types import StepRequest, StepResult + +from .spec import OmnidreamsReplayScenario + +PipelineFactory = Callable[[Any, str], Any] + + +@dataclass(frozen=True, kw_only=True, slots=True) +class OmnidreamsReplayRuntimeOptions: + """Construction knobs for the replay runtime.""" + + pipeline_config: Any + pipeline_factory: PipelineFactory | None = None + output_layout: VideoTensorLayout = "bvtchw" + + +class OmnidreamsReplayRuntime: + """Heavyweight OmniDreams runtime consumed by ``run_inference_session``.""" + + def __init__( + self, + *, + config: InferenceConfig, + options: OmnidreamsReplayRuntimeOptions, + ) -> None: + self.config = config + self.options = options + if _is_torchrun_env() and not dist.is_initialized(): + init_distributed() + + if dist.is_initialized(): + self.local_rank = int(os.environ.get("LOCAL_RANK", "0")) + self.world_size = dist.get_world_size() + self.global_rank = dist.get_rank() + device = f"cuda:{self.local_rank}" + else: + self.local_rank = 0 + self.world_size = 1 + self.global_rank = 0 + device = config.device or "cuda" + + self.is_rank_zero = self.global_rank == 0 + factory = options.pipeline_factory or _default_pipeline_factory + self.pipeline = factory(options.pipeline_config, device) + + def start_session(self, inputs: InferenceInput) -> InferenceSession: + scenario = _scenario_from_inputs(inputs) + return OmnidreamsReplaySession( + pipeline=self.pipeline, + scenario=scenario, + device=torch.device(f"cuda:{self.local_rank}") + if dist.is_initialized() + else torch.device(self.config.device or "cuda"), + is_rank_zero=self.is_rank_zero, + output_layout=self.options.output_layout, + ) + + def close(self) -> None: + pipeline = getattr(self, "pipeline", None) + if pipeline is not None: + close = getattr(pipeline, "close", None) + if callable(close): + close() + del self.pipeline + device = torch.device(self.config.device or "cuda") + if device.type == "cuda" and torch.cuda.is_available(): + torch.cuda.empty_cache() + + +class OmnidreamsReplaySession: + """One MP4 replay rollout over a prepared scenario.""" + + def __init__( + self, + *, + pipeline: Any, + scenario: OmnidreamsReplayScenario, + device: torch.device, + is_rank_zero: bool, + output_layout: VideoTensorLayout, + ) -> None: + self.pipeline = pipeline + self.scenario = scenario + self.device = device + self.is_rank_zero = is_rank_zero + self.output_layout = output_layout + self.dtype = torch.bfloat16 + self._closed = False + self._step_index = 0 + self._frame_start = 0 + self._cache = self._initialize_cache() + self._hdmap_videos = self._load_hdmaps() + if self.device.type == "cuda" and torch.cuda.is_available(): + torch.cuda.synchronize(device=self.device) + if dist.is_initialized(): + dist.barrier() + + def next_step_request(self) -> StepRequest | None: + if self._closed: + return None + if self._step_index >= self.scenario.total_blocks: + return None + num_frames = int(self.pipeline.get_num_frames(self._step_index)) + if self._frame_start + num_frames > self._hdmap_videos.shape[2]: + return None + return StepRequest(step_index=self._step_index) + + def step(self, inputs: InferenceInput) -> StepResult: + del inputs + if self._closed: + raise RuntimeError("OmniDreams replay session is closed.") + + step_index = self._step_index + num_frames = int(self.pipeline.get_num_frames(step_index)) + frame_end = self._frame_start + num_frames + logger.info( + "OmniDreams demo replay step {} frames=[{}, {})", + step_index, + self._frame_start, + frame_end, + ) + start_t = time.perf_counter() + video_chunk = self.pipeline.generate( + autoregressive_index=step_index, + cache=self._cache, + hdmap=self._hdmap_videos[:, :, self._frame_start : frame_end], + ) + stats = self.pipeline.finalize( + autoregressive_index=step_index, + cache=self._cache, + ) + elapsed_s = time.perf_counter() - start_t + self._step_index += 1 + self._frame_start = frame_end + + metrics = _numeric_stats(stats) + metrics.setdefault("model_step_s", elapsed_s) + return StepResult( + step_index=step_index, + output=VideoStepResult.from_video_chunk( + chunk_index=step_index, + video_chunk=video_chunk, + layout=self.output_layout, + stats=metrics, + ), + frame_count=num_frames, + metrics=metrics, + ) + + def reset(self, inputs: InferenceInput | None = None) -> None: + if inputs is not None: + scenario = _scenario_from_inputs(inputs) + if scenario != self.scenario: + raise ValueError("OmniDreams replay reset cannot swap scenarios.") + cache = getattr(self, "_cache", None) + if cache is not None: + del self._cache + self._cache = self._initialize_cache() + self._step_index = 0 + self._frame_start = 0 + + def close(self) -> None: + self._closed = True + cache = getattr(self, "_cache", None) + if cache is not None: + del self._cache + + def _initialize_cache(self) -> Any: + scenario = self.scenario + first_frames = [ + load_first_frame_tensor( + path, + pixel_height=scenario.pixel_height, + pixel_width=scenario.pixel_width, + device=self.device, + dtype=self.dtype, + allow_video=True, + install_hint=DEFAULT_RUNNER_INSTALL_HINT, + ) + for path in scenario.first_frame_paths + ] + first_frames_t = torch.stack(first_frames, dim=0).unsqueeze(0) + cache = self.pipeline.initialize_cache( + text=[list(scenario.prompts)], + image=first_frames_t, + view_names=list(scenario.camera_names), + ) + release = getattr(self.pipeline, "release_oneshot_encoders", None) + if callable(release): + release() + return cache + + def _load_hdmaps(self) -> torch.Tensor: + scenario = self.scenario + videos = [ + _load_video( + path, + pixel_height=scenario.pixel_height, + pixel_width=scenario.pixel_width, + device=self.device, + dtype=self.dtype, + ) + for path in scenario.hdmap_video_paths + ] + # [B=1, V, T, C, H, W] + hdmap_videos = torch.stack(videos, dim=0).unsqueeze(0) + if self.is_rank_zero: + logger.info( + "Loaded OmniDreams demo HDMaps shape={} views={}", + tuple(hdmap_videos.shape), + len(scenario.camera_names), + ) + return hdmap_videos + + +def _default_pipeline_factory(pipeline_config: Any, device: str) -> Any: + return pipeline_config.setup().to(device=device).eval() + + +def _scenario_from_inputs(inputs: InferenceInput) -> OmnidreamsReplayScenario: + scenario = inputs.global_conditioning.get("scenario") + if not isinstance(scenario, OmnidreamsReplayScenario): + raise TypeError( + "OmniDreams replay runtime requires global_conditioning['scenario'] " + "to be an OmnidreamsReplayScenario." + ) + return scenario + + +def _numeric_stats(stats: Any) -> dict[str, float | int]: + if not isinstance(stats, Mapping): + return {} + return { + str(key): value + for key, value in stats.items() + if isinstance(value, (float, int)) and not isinstance(value, bool) + } + + +def _is_torchrun_env() -> bool: + return "RANK" in os.environ and "WORLD_SIZE" in os.environ + + +__all__ = [ + "OmnidreamsReplayRuntime", + "OmnidreamsReplayRuntimeOptions", + "OmnidreamsReplaySession", + "PipelineFactory", +] diff --git a/integrations/omnidreams/omnidreams/demo/spec.py b/integrations/omnidreams/omnidreams/demo/spec.py new file mode 100644 index 000000000..0a5dcc062 --- /dev/null +++ b/integrations/omnidreams/omnidreams/demo/spec.py @@ -0,0 +1,271 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""OmniDreams demo-specific scenario shapes.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from omnidreams.runner import ( + DEFAULT_EXAMPLE_DATA_UUID_1V, + DEFAULT_VIDEO_HEIGHT, + DEFAULT_VIDEO_WIDTH, + _ensure_hf_single_view_example_data_synced, + _example_camera_names, +) +from omnidreams.scenes import SCENE_VARIANT_DEFAULT +from omnidreams.webrtc.session import DEFAULT_WEBRTC_SCENE_UUID + +DEFAULT_OMNIDREAMS_PRESET = "omnidreams-sv-2steps-chunk2-loc6-lightvae-lighttae" +OMNIDREAMS_MODEL_ID = "omnidreams" + + +@dataclass(frozen=True, kw_only=True, slots=True) +class OmnidreamsReplayScenario: + """Resolved replay assets for the shared MP4 demo path.""" + + prompts: tuple[str, ...] + hdmap_video_paths: tuple[Path, ...] + first_frame_paths: tuple[Path, ...] + camera_names: tuple[str, ...] + total_blocks: int = 60 + pixel_height: int = DEFAULT_VIDEO_HEIGHT + pixel_width: int = DEFAULT_VIDEO_WIDTH + fps: int = 30 + + def __post_init__(self) -> None: + if not self.prompts: + raise ValueError("OmnidreamsReplayScenario.prompts must be non-empty.") + num_views = len(self.prompts) + for name, values in ( + ("hdmap_video_paths", self.hdmap_video_paths), + ("first_frame_paths", self.first_frame_paths), + ("camera_names", self.camera_names), + ): + if len(values) != num_views: + raise ValueError( + f"OmnidreamsReplayScenario.{name} has {len(values)} " + f"entries but prompts has {num_views}." + ) + if self.total_blocks <= 0: + raise ValueError("OmnidreamsReplayScenario.total_blocks must be > 0.") + if self.pixel_height <= 0 or self.pixel_width <= 0: + raise ValueError("OmnidreamsReplayScenario pixel dimensions must be > 0.") + if self.fps <= 0: + raise ValueError("OmnidreamsReplayScenario.fps must be > 0.") + object.__setattr__( + self, + "hdmap_video_paths", + tuple(Path(path) for path in self.hdmap_video_paths), + ) + object.__setattr__( + self, + "first_frame_paths", + tuple(Path(path) for path in self.first_frame_paths), + ) + + +@dataclass(frozen=True, kw_only=True, slots=True) +class OmnidreamsWebRTCScenario: + """Scene/options for the shared WebRTC demo path.""" + + scene_dir: Path | None = None + scene_uuid: str | None = DEFAULT_WEBRTC_SCENE_UUID + scene_variant: str = SCENE_VARIANT_DEFAULT + camera_name: str = "camera_front_wide_120fov" + debug_serve_hdmaps: bool = False + postprocess_preset: str = "" + prefer_sw_encoder: bool = False + + def __post_init__(self) -> None: + if self.scene_dir is not None: + object.__setattr__(self, "scene_dir", Path(self.scene_dir)) + if not self.scene_variant.strip(): + raise ValueError("OmnidreamsWebRTCScenario.scene_variant is required.") + if not self.camera_name.strip(): + raise ValueError("OmnidreamsWebRTCScenario.camera_name is required.") + + +def resolve_replay_scenario( + value: Any, + *, + default_prompt: str = "", +) -> OmnidreamsReplayScenario: + """Normalize a user/demo scenario into a validated replay scenario.""" + if isinstance(value, OmnidreamsReplayScenario): + _require_existing_paths(value.hdmap_video_paths, label="hdmap_video_paths") + _require_existing_paths(value.first_frame_paths, label="first_frame_paths") + return value + if value is None: + value = {} + if not isinstance(value, Mapping): + raise TypeError( + "OmniDreams replay scenario must be an OmnidreamsReplayScenario " + "a mapping, or None." + ) + + hdmap_paths = _path_tuple(value.get("hdmap_video_paths", ())) + first_paths = _path_tuple(value.get("first_frame_paths", ())) + example_data = _resolve_example_data_default(value) + if example_data and (not hdmap_paths or not first_paths): + example_hdmaps, example_first_frames = ( + _ensure_hf_single_view_example_data_synced( + str(value.get("example_data_uuid", DEFAULT_EXAMPLE_DATA_UUID_1V)) + ) + ) + if not hdmap_paths: + hdmap_paths = example_hdmaps + if not first_paths: + first_paths = example_first_frames + + _require_existing_paths(hdmap_paths, label="hdmap_video_paths") + _require_existing_paths(first_paths, label="first_frame_paths") + if len(hdmap_paths) != len(first_paths): + raise ValueError( + "OmniDreams replay scenario requires one HDMap video and first " + "frame per view." + ) + + num_views = len(hdmap_paths) + prompts = _resolve_prompts(value, num_views, default_prompt=default_prompt) + camera_names = _string_tuple(value.get("camera_names", ())) + if not camera_names: + camera_names = ( + _example_camera_names(num_views) + if example_data + else tuple(f"view_{i}" for i in range(num_views)) + ) + + return OmnidreamsReplayScenario( + prompts=prompts, + hdmap_video_paths=hdmap_paths, + first_frame_paths=first_paths, + camera_names=camera_names, + total_blocks=int(value.get("total_blocks", 60)), + pixel_height=int(value.get("pixel_height", DEFAULT_VIDEO_HEIGHT)), + pixel_width=int(value.get("pixel_width", DEFAULT_VIDEO_WIDTH)), + fps=int(value.get("fps", 30)), + ) + + +def resolve_webrtc_scenario(value: Any) -> OmnidreamsWebRTCScenario: + """Normalize a user/demo scenario into a WebRTC scenario.""" + if value is None: + return OmnidreamsWebRTCScenario() + if isinstance(value, OmnidreamsWebRTCScenario): + return value + if not isinstance(value, Mapping): + raise TypeError( + "OmniDreams WebRTC scenario must be an OmnidreamsWebRTCScenario, " + "a mapping, or None." + ) + scene_dir = value.get("scene_dir") + return OmnidreamsWebRTCScenario( + scene_dir=Path(scene_dir) if scene_dir is not None else None, + scene_uuid=value.get("scene_uuid", DEFAULT_WEBRTC_SCENE_UUID), + scene_variant=str(value.get("scene_variant", SCENE_VARIANT_DEFAULT)), + camera_name=str(value.get("camera_name", "camera_front_wide_120fov")), + debug_serve_hdmaps=bool(value.get("debug_serve_hdmaps", False)), + postprocess_preset=str(value.get("postprocess_preset", "")), + prefer_sw_encoder=bool(value.get("prefer_sw_encoder", False)), + ) + + +def _resolve_prompts( + value: Mapping[str, Any], + num_views: int, + *, + default_prompt: str, +) -> tuple[str, ...]: + prompts = _string_tuple(value.get("prompts", ())) + if prompts: + if len(prompts) != num_views: + raise ValueError( + f"OmniDreams replay prompts has {len(prompts)} entries but " + f"there are {num_views} views." + ) + return prompts + prompt = str(value.get("prompt", "")).strip() + if not prompt: + prompt = default_prompt.strip() + if not prompt: + raise ValueError("OmniDreams replay scenario requires prompt or prompts.") + return (prompt,) * num_views + + +def _resolve_example_data_default(value: Mapping[str, Any]) -> bool: + explicit = value.get("example_data") + if explicit is not None: + return _bool_value(explicit) + return not ( + _has_nonempty_value(value, "hdmap_video_paths") + or _has_nonempty_value(value, "first_frame_paths") + ) + + +def _bool_value(value: Any) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + lowered = value.strip().lower() + if lowered in {"1", "true", "yes", "on"}: + return True + if lowered in {"0", "false", "no", "off"}: + return False + return bool(value) + + +def _has_nonempty_value(value: Mapping[str, Any], key: str) -> bool: + if key not in value: + return False + raw = value[key] + if raw is None or raw == "": + return False + if isinstance(raw, Sequence) and not isinstance(raw, str): + return len(raw) > 0 + return True + + +def _path_tuple(value: Any) -> tuple[Path, ...]: + if value is None or value == "": + return () + if isinstance(value, (str, Path)): + return (Path(value),) + if isinstance(value, Sequence): + return tuple(Path(path) for path in value) + raise TypeError(f"Expected path or path sequence, got {type(value).__name__}.") + + +def _string_tuple(value: Any) -> tuple[str, ...]: + if value is None or value == "": + return () + if isinstance(value, str): + return (value,) + if isinstance(value, Sequence): + return tuple(str(item) for item in value) + raise TypeError(f"Expected string or string sequence, got {type(value).__name__}.") + + +def _require_existing_paths(paths: tuple[Path, ...], *, label: str) -> None: + if not paths: + raise ValueError(f"OmniDreams replay scenario requires {label}.") + missing = tuple(path for path in paths if not path.exists()) + if missing: + raise FileNotFoundError( + f"OmniDreams replay scenario missing {label}: " + + ", ".join(str(path) for path in missing) + ) + + +__all__ = [ + "DEFAULT_OMNIDREAMS_PRESET", + "OMNIDREAMS_MODEL_ID", + "OmnidreamsReplayScenario", + "OmnidreamsWebRTCScenario", + "resolve_replay_scenario", + "resolve_webrtc_scenario", +] diff --git a/integrations/omnidreams/omnidreams/demo/webrtc.py b/integrations/omnidreams/omnidreams/demo/webrtc.py new file mode 100644 index 000000000..699d47091 --- /dev/null +++ b/integrations/omnidreams/omnidreams/demo/webrtc.py @@ -0,0 +1,178 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""OmniDreams WebRTC hooks for the shared demo API.""" + +from __future__ import annotations + +from typing import Any, cast + +from aiohttp import web +from omnidreams.webrtc.session import ( + OmnidreamsRuntimeConfig, + OmnidreamsRuntimeError, + OmnidreamsSessionInput, + _validate_requested_postprocess_preset, +) + +from flashdreams.plugins.registry import resolve_postprocess_preset +from flashdreams.runtime.demo import DemoSpec +from flashdreams.runtime.demo.webrtc import SharedDemoWebRTCSessionManager +from flashdreams.serving.webrtc.controls import WSAD_SUPPORTED_KEYS +from flashdreams.serving.webrtc.manager import DEFAULT_CLIENT_LIVENESS_TIMEOUT_S +from flashdreams.serving.webrtc.server import ( + SESSION_MANAGER_KEY, + SessionBusyError, + create_packaged_webrtc_app, +) +from flashdreams.serving.webrtc.server import ( + close_package_resources as _close_package_resources, +) + + +class OmnidreamsDemoWebRTCSessionManager(SharedDemoWebRTCSessionManager): + """Shared WebRTC manager customized for OmniDreams session semantics.""" + + _busy_message = "An Omnidreams session is already active." + _warmup_label = "Omnidreams WebRTC" + _runtime_error_types = (OmnidreamsRuntimeError,) + _close_session_on_generation_error = True + _resampler_supported_keys = WSAD_SUPPORTED_KEYS + + runtime_config: OmnidreamsRuntimeConfig + _runtime: Any + + def __init__( + self, + *, + runtime: Any, + runtime_config: OmnidreamsRuntimeConfig, + fps: int, + client_liveness_timeout_s: float = DEFAULT_CLIENT_LIVENESS_TIMEOUT_S, + ) -> None: + super().__init__( + model_name=runtime_config.pipeline_config_name, + runtime=runtime, + runtime_config=runtime_config, + fps=fps, + client_liveness_timeout_s=client_liveness_timeout_s, + ) + self._pending_session_input: OmnidreamsSessionInput | None = None + + def _model_name(self) -> str: + return self.runtime_config.pipeline_config_name + + def _chunk_done_extra(self) -> dict[str, Any]: + return { + "stream": "hdmap" if self.runtime_config.debug_serve_hdmaps else "rgb", + "postprocess_preset": self._runtime.postprocess_preset, + } + + def _peek_pending_session_input(self) -> OmnidreamsSessionInput | None: + return self._pending_session_input + + def _clear_pending_session_input(self) -> None: + self._pending_session_input = None + + async def _reset_runtime_for_session( + self, session_input: OmnidreamsSessionInput | None + ) -> None: + await self._runtime.reset_for_new_session(session_input=session_input) + + def set_pending_session_input(self, session_input: OmnidreamsSessionInput) -> None: + if self.has_active_session(): + raise SessionBusyError(self._busy_message) + preset = session_input.postprocess_preset + if preset: + _validate_requested_postprocess_preset( + requested_preset=preset, + configured_preset=self.runtime_config.postprocess.preset, + ) + self._pending_session_input = session_input + + +async def postprocess_options(request: web.Request) -> web.StreamResponse: + """Return the postprocess preset selected at server launch.""" + manager = _get_omnidreams_manager(request.app) + configured_preset = manager.runtime_config.postprocess.preset + presets = [configured_preset] if configured_preset else [] + return web.json_response( + { + "default_preset": configured_preset, + "presets": presets, + } + ) + + +async def session_input(request: web.Request) -> web.StreamResponse: + """Apply browser-selected settings to the next WebRTC rollout.""" + try: + payload = await request.json() + except Exception as exc: + raise web.HTTPBadRequest(reason="Expected JSON session input.") from exc + if not isinstance(payload, dict): + raise web.HTTPBadRequest(reason="Session input must be a JSON object.") + preset = payload.get("postprocess_preset") + if not isinstance(preset, str): + raise web.HTTPBadRequest( + reason="Session input must include string 'postprocess_preset'." + ) + + manager = _get_omnidreams_manager(request.app) + try: + manager.set_pending_session_input( + OmnidreamsSessionInput(postprocess_preset=preset) + ) + except SessionBusyError as exc: + raise web.HTTPConflict(reason=str(exc)) from exc + except ValueError as exc: + raise web.HTTPBadRequest(reason=str(exc)) from exc + return web.json_response({"postprocess_preset": preset}) + + +def configure_omnidreams_webrtc_app(app: web.Application) -> None: + """Register OmniDreams browser support routes on a shared WebRTC app.""" + app.router.add_get("/api/postprocess/options", postprocess_options) + app.router.add_post("/api/session/input", session_input) + + +def create_omnidreams_webrtc_app( + *, + spec: DemoSpec, + session_manager: Any, + request_session_url: str, +) -> web.Application: + """Create the packaged OmniDreams browser app through shared serving glue.""" + from importlib.resources import as_file, files + + output_preload_name = getattr(spec.output, "preload_name", None) + preload_name = output_preload_name if isinstance(output_preload_name, str) else "" + return create_packaged_webrtc_app( + web_resource=files("omnidreams.webrtc").joinpath("web"), + session_manager=session_manager, + preload_name=preload_name or "Omnidreams", + request_session_url=request_session_url, + configure_app=configure_omnidreams_webrtc_app, + as_file_fn=as_file, + cleanup_callback=_close_package_resources, + ) + + +def validate_postprocess_preset(preset: str) -> None: + """Validate a configured preset without enabling the output system broadly.""" + if preset: + resolve_postprocess_preset(preset) + + +def _get_omnidreams_manager(app: web.Application) -> OmnidreamsDemoWebRTCSessionManager: + return cast(OmnidreamsDemoWebRTCSessionManager, app[SESSION_MANAGER_KEY]) + + +__all__ = [ + "OmnidreamsDemoWebRTCSessionManager", + "configure_omnidreams_webrtc_app", + "create_omnidreams_webrtc_app", + "postprocess_options", + "session_input", + "validate_postprocess_preset", +] diff --git a/integrations/omnidreams/pyproject.toml b/integrations/omnidreams/pyproject.toml index 99d35d73e..eafd80a1c 100644 --- a/integrations/omnidreams/pyproject.toml +++ b/integrations/omnidreams/pyproject.toml @@ -22,7 +22,8 @@ name = "flashdreams-omnidreams" version = "0.1.0" description = "Omnidreams inference with flashdreams (webrtc / gRPC servers + the interactive-drive desktop demo)" readme = "README.md" -requires-python = ">=3.10,<3.14" +# PyNvVideoCodec 2.1 currently publishes wheels through CPython 3.12. +requires-python = ">=3.10,<3.13" dependencies = [ # Core inference / serving deps (consumed by ``omnidreams.webrtc``, # ``omnidreams.grpc``, and the ``omnidreams.interactive_drive`` desktop @@ -104,6 +105,10 @@ omnidreams-prepare = "omnidreams.prepare:main" # FlashDreams generation, and DrivingGen adapter setup. omnidreams-eval = "omnidreams.eval.cli:main" +# Experimental shared demo API path. This coexists with the legacy +# WebRTC/gRPC/interactive-drive demos until the new adapter is proven. +omnidreams-demo = "omnidreams.demo.cli:main" + # Desktop interactive-drive demo entry point. Requires the # ``interactive-drive`` extra (it adds slangpy); without it the # presenter import fails fast with a clear message. diff --git a/integrations/omnidreams/tests/test_demo_api.py b/integrations/omnidreams/tests/test_demo_api.py new file mode 100644 index 000000000..d9411475a --- /dev/null +++ b/integrations/omnidreams/tests/test_demo_api.py @@ -0,0 +1,577 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from collections.abc import Sequence +from pathlib import Path +from typing import Any, cast + +import omnidreams.demo.spec as spec_module +import omnidreams.demo.webrtc as demo_webrtc_module +import pytest +import torch +from aiohttp import web +from omnidreams.config import OMNIDREAMS_RUNNERS +from omnidreams.demo import ( + DEFAULT_OMNIDREAMS_PRESET, + OMNIDREAMS_MODEL_ID, + OmnidreamsDemoAdapter, + OmnidreamsReplayScenario, + OmnidreamsWebRTCScenario, +) +from omnidreams.demo.cli import _replay_spec, _webrtc_spec, parse_args +from omnidreams.demo.replay import ( + OmnidreamsReplayRuntime, + OmnidreamsReplayRuntimeOptions, +) +from omnidreams.demo.webrtc import OmnidreamsDemoWebRTCSessionManager + +from flashdreams.infra.video_output import VideoStepResult +from flashdreams.runtime import ( + InferenceConfig, + InferenceInput, + OutputArtifact, + OutputTarget, + StepResult, +) +from flashdreams.runtime.demo import ( + DemoSpec, + Mp4OutputSpec, + WebRTCOutputSpec, + serve_flashdreams_demo, +) +from flashdreams.runtime.demo.replay import run_replay_demo +from flashdreams.runtime.demo.webrtc import WebRTCDemo, build_webrtc_demo +from flashdreams.serving.webrtc.server import SESSION_MANAGER_KEY + +pytestmark = pytest.mark.ci_cpu + + +def test_omnidreams_demo_defaults_to_stable_non_perf_preset() -> None: + args = parse_args(["replay", "--output", "demo.mp4"]) + + assert args.preset_id == "omnidreams-sv-2steps-chunk2-loc6-lightvae-lighttae" + assert not args.preset_id.endswith("-perf") + + +def test_omnidreams_demo_adapter_declares_mp4_and_webrtc_modes() -> None: + adapter = OmnidreamsDemoAdapter() + + assert adapter.model_id == OMNIDREAMS_MODEL_ID + assert adapter.supported_input_modes() == ("replay", "keyboard-driving") + assert adapter.supported_output_modes() == ("mp4", "webrtc") + + +def test_omnidreams_replay_demo_uses_shared_runner(tmp_path: Path) -> None: + hdmap = tmp_path / "hdmap.mp4" + first_frame = tmp_path / "first.png" + hdmap.write_bytes(b"fake") + first_frame.write_bytes(b"fake") + pipeline_config = object() + adapter = OmnidreamsDemoAdapter() + output = _RecordingOutputTarget() + calls: list[dict[str, Any]] = [] + + def fake_runner(**kwargs: Any) -> Sequence[OutputArtifact]: + calls.append(kwargs) + return (OutputArtifact(kind="video/mp4", uri="memory://omnidreams"),) + + spec = DemoSpec( + model_id=OMNIDREAMS_MODEL_ID, + preset_id=DEFAULT_OMNIDREAMS_PRESET, + input_mode="replay", + scenario={ + "prompt": "drive through a city", + "hdmap_video_paths": (hdmap,), + "first_frame_paths": (first_frame,), + "camera_names": ("camera_front_wide_120fov",), + "total_blocks": 1, + }, + output=Mp4OutputSpec(path=tmp_path / "demo.mp4", fps=30), + config=InferenceConfig( + model_id=OMNIDREAMS_MODEL_ID, + preset_id=DEFAULT_OMNIDREAMS_PRESET, + runtime_options={"pipeline_config": pipeline_config}, + ), + ) + + artifacts = run_replay_demo( + spec=spec, + adapter=adapter, + output_target_factory=lambda output_spec: output, + runner=fake_runner, + ) + + assert artifacts == (OutputArtifact(kind="video/mp4", uri="memory://omnidreams"),) + assert len(calls) == 1 + assert calls[0]["adapter"] is adapter + assert calls[0]["config"] == spec.config + scenario = calls[0]["initial_inputs"].global_conditioning["scenario"] + assert isinstance(scenario, OmnidreamsReplayScenario) + assert scenario.prompts == ("drive through a city",) + assert scenario.hdmap_video_paths == (hdmap,) + assert scenario.first_frame_paths == (first_frame,) + assert scenario.camera_names == ("camera_front_wide_120fov",) + + +def test_omnidreams_replay_invalid_scenario_fails_before_runtime_creation( + tmp_path: Path, +) -> None: + adapter = OmnidreamsDemoAdapter( + replay_runtime_factory=lambda **kwargs: pytest.fail( + f"runtime should not be created: {kwargs}" + ) + ) + output_factory_calls = 0 + + def output_factory(output_spec: object) -> OutputTarget: + nonlocal output_factory_calls + del output_spec + output_factory_calls += 1 + return _RecordingOutputTarget() + + spec = DemoSpec( + model_id=OMNIDREAMS_MODEL_ID, + input_mode="replay", + scenario={ + "prompt": "drive", + "hdmap_video_paths": (tmp_path / "missing-hdmap.mp4",), + "first_frame_paths": (tmp_path / "missing-first.png",), + }, + output=Mp4OutputSpec(path=tmp_path / "demo.mp4", fps=30), + config=InferenceConfig( + model_id=OMNIDREAMS_MODEL_ID, + runtime_options={"pipeline_config": object()}, + ), + ) + + with pytest.raises(FileNotFoundError, match="missing hdmap_video_paths"): + run_replay_demo( + spec=spec, + adapter=adapter, + output_target_factory=output_factory, + ) + + assert output_factory_calls == 0 + + +def test_omnidreams_replay_cli_defaults_to_hf_example_data( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + hdmap = tmp_path / "hf-hdmap.mp4" + first_frame = tmp_path / "hf-first.png" + hdmap.write_bytes(b"fake") + first_frame.write_bytes(b"fake") + synced_uuids: list[str] = [] + + def fake_sync(uuid: str) -> tuple[tuple[Path, ...], tuple[Path, ...]]: + synced_uuids.append(uuid) + return (hdmap,), (first_frame,) + + monkeypatch.setattr( + spec_module, + "_ensure_hf_single_view_example_data_synced", + fake_sync, + ) + args = parse_args(["replay", "--output", str(tmp_path / "demo.mp4")]) + spec = _replay_spec(args) + + prepared = OmnidreamsDemoAdapter().prepare_scenario(spec) + + scenario = prepared.initial_inputs.global_conditioning["scenario"] + assert isinstance(scenario, OmnidreamsReplayScenario) + assert synced_uuids == ["239560dc-33d1-11ef-9720-00044bcbccac"] + assert scenario.hdmap_video_paths == (hdmap,) + assert scenario.first_frame_paths == (first_frame,) + assert scenario.camera_names == ("camera_front_wide_120fov",) + assert scenario.prompts == ( + str(getattr(OMNIDREAMS_RUNNERS[DEFAULT_OMNIDREAMS_PRESET], "prompt")), + ) + + +def test_omnidreams_replay_cli_can_disable_example_data(tmp_path: Path) -> None: + args = parse_args( + ["replay", "--no-example-data", "--output", str(tmp_path / "demo.mp4")] + ) + spec = _replay_spec(args) + + with pytest.raises(ValueError, match="requires hdmap_video_paths"): + OmnidreamsDemoAdapter().prepare_scenario(spec) + + +def test_omnidreams_replay_runtime_generates_video_step_result( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + import omnidreams.demo.replay as replay_module + + hdmap = tmp_path / "hdmap.mp4" + first_frame = tmp_path / "first.png" + hdmap.write_bytes(b"fake") + first_frame.write_bytes(b"fake") + pipeline = _FakeOmnidreamsPipeline() + monkeypatch.setattr( + replay_module, + "load_first_frame_tensor", + lambda *args, **kwargs: torch.zeros(1, 3, 2, 2), + ) + monkeypatch.setattr( + replay_module, + "_load_video", + lambda *args, **kwargs: torch.zeros(2, 3, 2, 2), + ) + + runtime = OmnidreamsReplayRuntime( + config=InferenceConfig(model_id=OMNIDREAMS_MODEL_ID, device="cpu"), + options=OmnidreamsReplayRuntimeOptions( + pipeline_config=object(), + pipeline_factory=lambda pipeline_config, device: pipeline, + ), + ) + scenario = OmnidreamsReplayScenario( + prompts=("drive",), + hdmap_video_paths=(hdmap,), + first_frame_paths=(first_frame,), + camera_names=("camera_front_wide_120fov",), + total_blocks=1, + pixel_height=2, + pixel_width=2, + fps=30, + ) + session = runtime.start_session( + InferenceInput(global_conditioning={"scenario": scenario}) + ) + + request = session.next_step_request() + assert request is not None + assert request.step_index == 0 + result = session.step(InferenceInput()) + + assert result.step_index == 0 + assert result.frame_count == 1 + assert isinstance(result.output, VideoStepResult) + assert result.output.layout == "bvtchw" + assert result.output.video_chunk.shape == (1, 1, 1, 3, 2, 2) + assert result.metrics["denoise_s"] == 0.25 + assert session.next_step_request() is None + assert pipeline.initialize_cache_calls == [ + { + "text": [["drive"]], + "image_shape": (1, 1, 1, 3, 2, 2), + "view_names": ["camera_front_wide_120fov"], + } + ] + runtime.close() + + +def test_omnidreams_webrtc_cli_builds_keyboard_driving_spec(tmp_path: Path) -> None: + args = parse_args( + [ + "webrtc", + "--host", + "127.0.0.1", + "--port", + "9090", + "--device", + "cuda:2", + "--seed", + "123", + "--scene-dir", + str(tmp_path / "scene"), + "--scene-uuid", + "scene-1", + "--scene-variant", + "rain", + "--camera-name", + "camera_front_wide_120fov", + "--fps", + "24", + "--video-height", + "32", + "--video-width", + "64", + "--warmup-chunks", + "0", + "--warmup-timeout-s", + "1.5", + "--client-liveness-timeout-s", + "2.5", + "--debug-serve-hdmaps", + "--prefer-sw-encoder", + ] + ) + + spec = _webrtc_spec(args, device="cuda:3") + + assert spec.model_id == OMNIDREAMS_MODEL_ID + assert spec.preset_id == DEFAULT_OMNIDREAMS_PRESET + assert spec.input_mode == "keyboard-driving" + assert isinstance(spec.scenario, OmnidreamsWebRTCScenario) + assert spec.scenario.scene_dir == tmp_path / "scene" + assert spec.scenario.scene_uuid == "scene-1" + assert spec.scenario.scene_variant == "rain" + assert spec.scenario.camera_name == "camera_front_wide_120fov" + assert spec.scenario.debug_serve_hdmaps is True + assert spec.scenario.prefer_sw_encoder is True + assert isinstance(spec.output, WebRTCOutputSpec) + assert spec.output.host == "127.0.0.1" + assert spec.output.port == 9090 + assert spec.output.fps == 24 + assert spec.output.video_width == 64 + assert spec.output.video_height == 32 + assert spec.output.warmup_chunks == 0 + assert spec.output.warmup_timeout_s == 1.5 + assert spec.output.client_liveness_timeout_s == 2.5 + assert spec.config is not None + assert spec.config.device == "cuda:3" + assert spec.config.runtime_options["seed"] == 123 + + +def test_omnidreams_webrtc_demo_uses_shared_manager_with_model_config() -> None: + pipeline_config = object() + adapter = OmnidreamsDemoAdapter(webrtc_runtime_factory=_FakeWebRTCRuntime) + spec = DemoSpec( + model_id=OMNIDREAMS_MODEL_ID, + preset_id=DEFAULT_OMNIDREAMS_PRESET, + input_mode="keyboard-driving", + scenario=OmnidreamsWebRTCScenario( + scene_uuid="scene-1", + scene_variant="rain", + camera_name="camera_front_wide_120fov", + debug_serve_hdmaps=True, + prefer_sw_encoder=True, + ), + output=WebRTCOutputSpec( + host="0.0.0.0", + port=8082, + fps=24, + video_width=64, + video_height=32, + warmup_chunks=0, + warmup_timeout_s=1.0, + ), + config=InferenceConfig( + model_id=OMNIDREAMS_MODEL_ID, + preset_id=DEFAULT_OMNIDREAMS_PRESET, + device="cuda:7", + runtime_options={"pipeline_config": pipeline_config, "seed": 123}, + ), + ) + + demo = build_webrtc_demo(spec=spec, adapter=adapter) + + assert isinstance(demo.runtime, _FakeWebRTCRuntime) + assert isinstance(demo.session_manager, OmnidreamsDemoWebRTCSessionManager) + assert demo.session_manager._runtime is demo.runtime + assert demo.session_manager.runtime_config is demo.runtime.config + assert demo.runtime_config is demo.runtime.config + assert demo.runtime_config.pipeline_config is pipeline_config + assert demo.runtime_config.pipeline_config_name == DEFAULT_OMNIDREAMS_PRESET + assert demo.runtime_config.scene_uuid == "scene-1" + assert demo.runtime_config.scene_variant == "rain" + assert demo.runtime_config.seed == 123 + assert demo.runtime_config.device == "cuda:7" + assert demo.runtime_config.video_width == 64 + assert demo.runtime_config.video_height == 32 + assert demo.runtime_config.fps == 24 + assert demo.runtime_config.debug_serve_hdmaps is True + assert demo.runtime_config.encoder_backend == "default" + assert demo.session_manager._model_name() == DEFAULT_OMNIDREAMS_PRESET + assert demo.host == "0.0.0.0" + assert demo.port == 8082 + + +def test_omnidreams_webrtc_demo_installs_model_routes( + monkeypatch: pytest.MonkeyPatch, +) -> None: + app_calls: list[dict[str, Any]] = [] + + def fake_create_packaged_webrtc_app(**kwargs: Any) -> web.Application: + app_calls.append(kwargs) + app = web.Application() + app[SESSION_MANAGER_KEY] = kwargs["session_manager"] + kwargs["configure_app"](app) + return app + + monkeypatch.setattr( + demo_webrtc_module, + "create_packaged_webrtc_app", + fake_create_packaged_webrtc_app, + ) + adapter = OmnidreamsDemoAdapter(webrtc_runtime_factory=_FakeWebRTCRuntime) + spec = DemoSpec( + model_id=OMNIDREAMS_MODEL_ID, + preset_id=DEFAULT_OMNIDREAMS_PRESET, + input_mode="keyboard-driving", + scenario=OmnidreamsWebRTCScenario(), + output=WebRTCOutputSpec( + host="0.0.0.0", + port=8082, + warmup_timeout_s=1.0, + preload_name="Test Omnidreams", + ), + config=InferenceConfig( + model_id=OMNIDREAMS_MODEL_ID, + preset_id=DEFAULT_OMNIDREAMS_PRESET, + runtime_options={"pipeline_config": object()}, + ), + ) + + demo = build_webrtc_demo(spec=spec, adapter=adapter, create_app=True) + + assert demo.app is not None + assert app_calls[0]["session_manager"] is demo.session_manager + assert app_calls[0]["request_session_url"] == ( + "http://127.0.0.1:8082/request_session" + ) + assert app_calls[0]["preload_name"] == "Test Omnidreams" + route_paths = {resource.canonical for resource in demo.app.router.resources()} + assert "/api/postprocess/options" in route_paths + assert "/api/session/input" in route_paths + + +def test_omnidreams_webrtc_demo_serves_through_shared_runner( + monkeypatch: pytest.MonkeyPatch, +) -> None: + server_calls: list[dict[str, Any]] = [] + + def fake_create_packaged_webrtc_app(**kwargs: Any) -> web.Application: + app = web.Application() + app[SESSION_MANAGER_KEY] = kwargs["session_manager"] + kwargs["configure_app"](app) + return app + + def fake_server_runner(**kwargs: Any) -> None: + server_calls.append(kwargs) + + monkeypatch.setattr( + demo_webrtc_module, + "create_packaged_webrtc_app", + fake_create_packaged_webrtc_app, + ) + adapter = OmnidreamsDemoAdapter(webrtc_runtime_factory=_FakeWebRTCRuntime) + spec = DemoSpec( + model_id=OMNIDREAMS_MODEL_ID, + preset_id=DEFAULT_OMNIDREAMS_PRESET, + input_mode="keyboard-driving", + scenario={"scene_uuid": "scene-1"}, + output=WebRTCOutputSpec( + host="0.0.0.0", + port=8082, + warmup_timeout_s=1.0, + ), + config=InferenceConfig( + model_id=OMNIDREAMS_MODEL_ID, + preset_id=DEFAULT_OMNIDREAMS_PRESET, + runtime_options={"pipeline_config": object()}, + ), + ) + + demo = cast( + WebRTCDemo, + serve_flashdreams_demo( + spec=spec, + adapter=adapter, + world_rank=0, + server_runner=fake_server_runner, + ), + ) + + assert len(server_calls) == 1 + assert server_calls[0]["world_rank"] == 0 + assert server_calls[0]["session_manager"] is demo.session_manager + assert server_calls[0]["app"] is demo.app + assert server_calls[0]["host"] == "0.0.0.0" + assert server_calls[0]["port"] == 8082 + assert isinstance(demo.session_manager, OmnidreamsDemoWebRTCSessionManager) + + +class _RecordingOutputTarget: + def open(self) -> None: + return None + + def write(self, result: StepResult) -> None: + del result + + def close(self) -> Sequence[OutputArtifact]: + return () + + +class _FakeOmnidreamsPipeline: + def __init__(self) -> None: + self.initialize_cache_calls: list[dict[str, Any]] = [] + self.released_encoders = False + + def initialize_cache( + self, + *, + text: list[list[str]], + image: torch.Tensor, + view_names: list[str], + ) -> object: + self.initialize_cache_calls.append( + { + "text": text, + "image_shape": tuple(image.shape), + "view_names": view_names, + } + ) + return object() + + def release_oneshot_encoders(self) -> None: + self.released_encoders = True + + def get_num_frames(self, autoregressive_index: int) -> int: + del autoregressive_index + return 1 + + def generate( + self, + *, + autoregressive_index: int, + cache: object, + hdmap: torch.Tensor, + ) -> torch.Tensor: + del cache, hdmap + return torch.full((1, 1, 1, 3, 2, 2), float(autoregressive_index)) + + def finalize(self, *, autoregressive_index: int, cache: object) -> dict[str, float]: + del autoregressive_index, cache + return {"denoise_s": 0.25} + + +class _FakeWebRTCRuntime: + def __init__(self, config: Any) -> None: + self.config = config + + async def initialize(self) -> None: + return None + + async def reset_for_new_session(self, *args: Any, **kwargs: Any) -> None: + return None + + def peek_steady_chunk_num_frames(self) -> int: + return 1 + + def peek_next_chunk_num_frames(self) -> int: + return 1 + + async def generate_chunk( + self, + *, + segments: list[Any], + frame_times: list[float], + ) -> Any: + del segments, frame_times + return None + + async def close(self) -> None: + return None + + def send_exit_signal(self) -> None: + return None + + def wait_for_termination(self) -> None: + return None diff --git a/uv.lock b/uv.lock index a077bb380..4d0a32ba5 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,6 @@ version = 1 revision = 3 -requires-python = ">=3.10, <3.14" +requires-python = ">=3.10, <3.13" resolution-markers = [ "python_full_version >= '3.12' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform == 'win32'", @@ -131,7 +131,7 @@ dependencies = [ { name = "frozenlist" }, { name = "multidict" }, { name = "propcache" }, - { name = "typing-extensions", marker = "python_full_version < '3.13' or (extra == 'extra-11-flashdreams-dev' and extra == 'group-11-flashdreams-cuda12') or (extra == 'group-11-flashdreams-cuda12' and extra == 'group-11-flashdreams-cuda13')" }, + { name = "typing-extensions" }, { name = "yarl" }, ] sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" } @@ -190,29 +190,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3e/61/a0c0a8f327a9c52095cdd8e312391b00d3ed64ab6c72bb5c33d8ec251cf7/aiohttp-3.14.1-cp312-cp312-win32.whl", hash = "sha256:ec8dc383ee57ea3e883477dcca3f11b65d58199f1080acaf4cd6ad9a99698be4", size = 452771, upload-time = "2026-06-07T21:07:09.669Z" }, { url = "https://files.pythonhosted.org/packages/df/d9/ea367c75f16ac9c6cdc8febb25e8318fa21a2b1bc8d6514d4b2d890bface/aiohttp-3.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:2aa92c87868cd13674989f9ee83e5f9f7ea4237589b728048e1f0c8f6caa3271", size = 479873, upload-time = "2026-06-07T21:07:11.538Z" }, { url = "https://files.pythonhosted.org/packages/03/64/8d96784a7851156db8a4c6c3f6f91042fdf39fb15a4cc38c8b3c14833c45/aiohttp-3.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:2c840c90759922cb5e6dda94596e079a30fb5a5ba548e7e0dc00574703940847", size = 448073, upload-time = "2026-06-07T21:07:13.637Z" }, - { url = "https://files.pythonhosted.org/packages/bc/97/bd137012dd97e1649162b099135a80e1fd59aaa807b2430fc448d1029aff/aiohttp-3.14.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178", size = 506882, upload-time = "2026-06-07T21:07:15.501Z" }, - { url = "https://files.pythonhosted.org/packages/ef/79/e5cc690e9d922a66887ceeaca53a8ffd5a7b0be3816142b7abc433742d89/aiohttp-3.14.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf", size = 515270, upload-time = "2026-06-07T21:07:17.53Z" }, - { url = "https://files.pythonhosted.org/packages/fe/22/a73ccbf9dbd6e26dda0b24d5fd5db7da92ee3383a79f47677ffb834c5c5b/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd", size = 485841, upload-time = "2026-06-07T21:07:19.555Z" }, - { url = "https://files.pythonhosted.org/packages/3b/b9/57ed8eaf596321c2ad747bd480fb1700dbd7177c60dfc9e4c187f629662e/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe", size = 492088, upload-time = "2026-06-07T21:07:21.581Z" }, - { url = "https://files.pythonhosted.org/packages/78/c0/5ebe5270a7c140d7c6f79dcb018640225f14d406c149e4eec04a7d82fe71/aiohttp-3.14.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4", size = 501564, upload-time = "2026-06-07T21:07:23.388Z" }, - { url = "https://files.pythonhosted.org/packages/75/7f/8cdaa24fc7983865e0915153b96a9ac5bcdd3548d64c5a27d17cecccad2d/aiohttp-3.14.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876", size = 751998, upload-time = "2026-06-07T21:07:25.046Z" }, - { url = "https://files.pythonhosted.org/packages/b2/f4/c4227aacfacc5cb0cc2d119b65301d177912a6842cd64e120c47af76064f/aiohttp-3.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da", size = 510918, upload-time = "2026-06-07T21:07:27.28Z" }, - { url = "https://files.pythonhosted.org/packages/ab/01/a2d5f96cd4e74424864d30bc0a7e44d0a12dacdcfa91b5b2d1bd3dca6bf3/aiohttp-3.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6", size = 508657, upload-time = "2026-06-07T21:07:29.252Z" }, - { url = "https://files.pythonhosted.org/packages/e8/ed/3c0fb5c500fdd8e7ebc10d1889c04384fffa1a9163eac1356088ca9da1b1/aiohttp-3.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96", size = 1757907, upload-time = "2026-06-07T21:07:31.03Z" }, - { url = "https://files.pythonhosted.org/packages/0b/ab/d4c924d9bd5be3050c226612413ce68cb54c70d2c31b661bfc8d9a5b6a70/aiohttp-3.14.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f", size = 1737565, upload-time = "2026-06-07T21:07:33.031Z" }, - { url = "https://files.pythonhosted.org/packages/19/2a/37326821ff779084020cdc33224d20b19f42f4183a500ff92022a739eda7/aiohttp-3.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296", size = 1799018, upload-time = "2026-06-07T21:07:35.003Z" }, - { url = "https://files.pythonhosted.org/packages/b3/4f/6e947ba73e4ce09070761c05ed3a8ceb7c21f5e46798671d8b2aac0e4626/aiohttp-3.14.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9af6779bfb46abf124068327abcdf9ce95c9ef8287a3e8da76ccf2d0f16c28fa", size = 1894416, upload-time = "2026-06-07T21:07:36.956Z" }, - { url = "https://files.pythonhosted.org/packages/9d/6e/dbf1d0625dc711fb2851f4f3c3055c39ed58bae92082d8c627dbe6013736/aiohttp-3.14.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451", size = 1783881, upload-time = "2026-06-07T21:07:39.063Z" }, - { url = "https://files.pythonhosted.org/packages/44/c2/5e25098a67268ed369483ae7d1a58bd0a13d03aab860d2a0e4a6eb25b046/aiohttp-3.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c", size = 1587572, upload-time = "2026-06-07T21:07:41.058Z" }, - { url = "https://files.pythonhosted.org/packages/2a/bd/cf9cee17e140f942a3de73e658a543aa8fbf35a5fc67a9d2538d52d77f0b/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca", size = 1722137, upload-time = "2026-06-07T21:07:43.014Z" }, - { url = "https://files.pythonhosted.org/packages/89/6d/5684f8c59045c96f81a18cefbc1fbbd79d25b88f1c622f2a5c5c08fcb632/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09", size = 1755953, upload-time = "2026-06-07T21:07:45.933Z" }, - { url = "https://files.pythonhosted.org/packages/a8/40/35caf3170f8359760740a7d9aa0fff2e344bef98e1d1186f5a0f6dec17e6/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397", size = 1766479, upload-time = "2026-06-07T21:07:48.047Z" }, - { url = "https://files.pythonhosted.org/packages/6d/a1/b0c61e7a137f0d81de49a82023a6df73c3c16d6fefb0f8e4a93d21639002/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080", size = 1580077, upload-time = "2026-06-07T21:07:50.069Z" }, - { url = "https://files.pythonhosted.org/packages/0b/41/194ea4623693009fcefebef7aef63c141754f153e9cd0d39d3b9e36c175c/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345", size = 1791688, upload-time = "2026-06-07T21:07:52.106Z" }, - { url = "https://files.pythonhosted.org/packages/ba/45/4de841f005cfe1fd63e2a2fe011262c515e2a62aa6994b15947e7d717ac9/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588", size = 1761094, upload-time = "2026-06-07T21:07:54.113Z" }, - { url = "https://files.pythonhosted.org/packages/e4/ae/dbce10533d3896d544d5053939ed75b7dc31a1b0973d959b1b5ae21028d6/aiohttp-3.14.1-cp313-cp313-win32.whl", hash = "sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780", size = 452662, upload-time = "2026-06-07T21:07:56.06Z" }, - { url = "https://files.pythonhosted.org/packages/7b/d9/0bf1a19362c32f06229da5e7ddfcec91f93474d6307f7a2d3135e9c674dc/aiohttp-3.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a", size = 479748, upload-time = "2026-06-07T21:07:58.319Z" }, - { url = "https://files.pythonhosted.org/packages/22/0a/62e7232dc9484fbec112ceb32efb6a624cc7994ec6e2b019286f17c4e8f2/aiohttp-3.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8", size = 447723, upload-time = "2026-06-07T21:08:00.154Z" }, ] [[package]] @@ -252,7 +229,7 @@ version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "frozenlist" }, - { name = "typing-extensions", marker = "python_full_version < '3.13' or (extra == 'extra-11-flashdreams-dev' and extra == 'group-11-flashdreams-cuda12') or (extra == 'group-11-flashdreams-cuda12' and extra == 'group-11-flashdreams-cuda13')" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } wheels = [ @@ -293,7 +270,7 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "exceptiongroup", marker = "python_full_version < '3.11' or (extra == 'extra-11-flashdreams-dev' and extra == 'group-11-flashdreams-cuda12') or (extra == 'group-11-flashdreams-cuda12' and extra == 'group-11-flashdreams-cuda13')" }, { name = "idna" }, - { name = "typing-extensions", marker = "python_full_version < '3.13' or (extra == 'extra-11-flashdreams-dev' and extra == 'group-11-flashdreams-cuda12') or (extra == 'group-11-flashdreams-cuda12' and extra == 'group-11-flashdreams-cuda13')" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" } wheels = [ @@ -354,20 +331,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8e/ac/d90df7f1e3b97fc5554cf45076df5045f1e0a6adf13899e10121229b826c/av-16.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8cf065f9d438e1921dc31fc7aa045790b58aee71736897866420d80b5450f62a", size = 40817720, upload-time = "2026-01-11T09:57:39.039Z" }, { url = "https://files.pythonhosted.org/packages/80/6f/13c3a35f9dbcebafd03fe0c4cbd075d71ac8968ec849a3cfce406c35a9d2/av-16.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a345877a9d3cc0f08e2bc4ec163ee83176864b92587afb9d08dff50f37a9a829", size = 42267396, upload-time = "2026-01-11T09:57:42.115Z" }, { url = "https://files.pythonhosted.org/packages/c8/b9/275df9607f7fb44317ccb1d4be74827185c0d410f52b6e2cd770fe209118/av-16.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:f49243b1d27c91cd8c66fdba90a674e344eb8eb917264f36117bf2b6879118fd", size = 31752045, upload-time = "2026-01-11T09:57:45.106Z" }, - { url = "https://files.pythonhosted.org/packages/75/2a/63797a4dde34283dd8054219fcb29294ba1c25d68ba8c8c8a6ae53c62c45/av-16.1.0-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:ce2a1b3d8bf619f6c47a9f28cfa7518ff75ddd516c234a4ee351037b05e6a587", size = 26916715, upload-time = "2026-01-11T09:57:47.682Z" }, - { url = "https://files.pythonhosted.org/packages/d2/c4/0b49cf730d0ae8cda925402f18ae814aef351f5772d14da72dd87ff66448/av-16.1.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:408dbe6a2573ca58a855eb8cd854112b33ea598651902c36709f5f84c991ed8e", size = 21452167, upload-time = "2026-01-11T09:57:50.606Z" }, - { url = "https://files.pythonhosted.org/packages/51/23/408806503e8d5d840975aad5699b153aaa21eb6de41ade75248a79b7a37f/av-16.1.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:57f657f86652a160a8a01887aaab82282f9e629abf94c780bbdbb01595d6f0f7", size = 39215659, upload-time = "2026-01-11T09:57:53.757Z" }, - { url = "https://files.pythonhosted.org/packages/c4/19/a8528d5bba592b3903f44c28dab9cc653c95fcf7393f382d2751a1d1523e/av-16.1.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:adbad2b355c2ee4552cac59762809d791bda90586d134a33c6f13727fb86cb3a", size = 40874970, upload-time = "2026-01-11T09:57:56.802Z" }, - { url = "https://files.pythonhosted.org/packages/e8/24/2dbcdf0e929ad56b7df078e514e7bd4ca0d45cba798aff3c8caac097d2f7/av-16.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f42e1a68ec2aebd21f7eb6895be69efa6aa27eec1670536876399725bbda4b99", size = 40530345, upload-time = "2026-01-11T09:58:00.421Z" }, - { url = "https://files.pythonhosted.org/packages/54/27/ae91b41207f34e99602d1c72ab6ffd9c51d7c67e3fbcd4e3a6c0e54f882c/av-16.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:58fe47aeaef0f100c40ec8a5de9abbd37f118d3ca03829a1009cf288e9aef67c", size = 41972163, upload-time = "2026-01-11T09:58:03.756Z" }, - { url = "https://files.pythonhosted.org/packages/fc/7a/22158fb923b2a9a00dfab0e96ef2e8a1763a94dd89e666a5858412383d46/av-16.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:565093ebc93b2f4b76782589564869dadfa83af5b852edebedd8fee746457d06", size = 31729230, upload-time = "2026-01-11T09:58:07.254Z" }, - { url = "https://files.pythonhosted.org/packages/7f/f1/878f8687d801d6c4565d57ebec08449c46f75126ebca8e0fed6986599627/av-16.1.0-cp313-cp313t-macosx_11_0_x86_64.whl", hash = "sha256:574081a24edb98343fd9f473e21ae155bf61443d4ec9d7708987fa597d6b04b2", size = 27008769, upload-time = "2026-01-11T09:58:10.266Z" }, - { url = "https://files.pythonhosted.org/packages/30/f1/bd4ce8c8b5cbf1d43e27048e436cbc9de628d48ede088a1d0a993768eb86/av-16.1.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:9ab00ea29c25ebf2ea1d1e928d7babb3532d562481c5d96c0829212b70756ad0", size = 21590588, upload-time = "2026-01-11T09:58:12.629Z" }, - { url = "https://files.pythonhosted.org/packages/1d/dd/c81f6f9209201ff0b5d5bed6da6c6e641eef52d8fbc930d738c3f4f6f75d/av-16.1.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:a84a91188c1071f238a9523fd42dbe567fb2e2607b22b779851b2ce0eac1b560", size = 40638029, upload-time = "2026-01-11T09:58:15.399Z" }, - { url = "https://files.pythonhosted.org/packages/15/4d/07edff82b78d0459a6e807e01cd280d3180ce832efc1543de80d77676722/av-16.1.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:c2cd0de4dd022a7225ff224fde8e7971496d700be41c50adaaa26c07bb50bf97", size = 41970776, upload-time = "2026-01-11T09:58:19.075Z" }, - { url = "https://files.pythonhosted.org/packages/da/9d/1f48b354b82fa135d388477cd1b11b81bdd4384bd6a42a60808e2ec2d66b/av-16.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:0816143530624a5a93bc5494f8c6eeaf77549b9366709c2ac8566c1e9bff6df5", size = 41764751, upload-time = "2026-01-11T09:58:22.788Z" }, - { url = "https://files.pythonhosted.org/packages/2f/c7/a509801e98db35ec552dd79da7bdbcff7104044bfeb4c7d196c1ce121593/av-16.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e3a28053af29644696d0c007e897d19b1197585834660a54773e12a40b16974c", size = 43034355, upload-time = "2026-01-11T09:58:26.125Z" }, - { url = "https://files.pythonhosted.org/packages/36/8b/e5f530d9e8f640da5f5c5f681a424c65f9dd171c871cd255d8a861785a6e/av-16.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2e3e67144a202b95ed299d165232533989390a9ea3119d37eccec697dc6dbb0c", size = 31947047, upload-time = "2026-01-11T09:58:31.867Z" }, ] [[package]] @@ -484,18 +447,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, - { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, - { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, - { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, - { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, - { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, - { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, - { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, - { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, - { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, - { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, - { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, ] [[package]] @@ -561,22 +512,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, - { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, - { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, - { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, - { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, - { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, - { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, - { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, - { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, - { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, - { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, - { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, - { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, - { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, - { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, - { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, - { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, ] @@ -644,26 +579,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/8a/bebe5a3f68b484d3a2b8ffaf84704b3e343ef1addea528132ef148e22b3b/contourpy-1.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3d80b2c0300583228ac98d0a927a1ba6a2ba6b8a742463c564f1d419ee5b211e", size = 1380480, upload-time = "2025-04-15T17:38:06.7Z" }, { url = "https://files.pythonhosted.org/packages/34/db/fcd325f19b5978fb509a7d55e06d99f5f856294c1991097534360b307cf1/contourpy-1.3.2-cp312-cp312-win32.whl", hash = "sha256:90df94c89a91b7362e1142cbee7568f86514412ab8a2c0d0fca72d7e91b62912", size = 178489, upload-time = "2025-04-15T17:38:10.338Z" }, { url = "https://files.pythonhosted.org/packages/01/c8/fadd0b92ffa7b5eb5949bf340a63a4a496a6930a6c37a7ba0f12acb076d6/contourpy-1.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:8c942a01d9163e2e5cfb05cb66110121b8d07ad438a17f9e766317bcb62abf73", size = 223042, upload-time = "2025-04-15T17:38:14.239Z" }, - { url = "https://files.pythonhosted.org/packages/2e/61/5673f7e364b31e4e7ef6f61a4b5121c5f170f941895912f773d95270f3a2/contourpy-1.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:de39db2604ae755316cb5967728f4bea92685884b1e767b7c24e983ef5f771cb", size = 271630, upload-time = "2025-04-15T17:38:19.142Z" }, - { url = "https://files.pythonhosted.org/packages/ff/66/a40badddd1223822c95798c55292844b7e871e50f6bfd9f158cb25e0bd39/contourpy-1.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3f9e896f447c5c8618f1edb2bafa9a4030f22a575ec418ad70611450720b5b08", size = 255670, upload-time = "2025-04-15T17:38:23.688Z" }, - { url = "https://files.pythonhosted.org/packages/1e/c7/cf9fdee8200805c9bc3b148f49cb9482a4e3ea2719e772602a425c9b09f8/contourpy-1.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71e2bd4a1c4188f5c2b8d274da78faab884b59df20df63c34f74aa1813c4427c", size = 306694, upload-time = "2025-04-15T17:38:28.238Z" }, - { url = "https://files.pythonhosted.org/packages/dd/e7/ccb9bec80e1ba121efbffad7f38021021cda5be87532ec16fd96533bb2e0/contourpy-1.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de425af81b6cea33101ae95ece1f696af39446db9682a0b56daaa48cfc29f38f", size = 345986, upload-time = "2025-04-15T17:38:33.502Z" }, - { url = "https://files.pythonhosted.org/packages/dc/49/ca13bb2da90391fa4219fdb23b078d6065ada886658ac7818e5441448b78/contourpy-1.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:977e98a0e0480d3fe292246417239d2d45435904afd6d7332d8455981c408b85", size = 318060, upload-time = "2025-04-15T17:38:38.672Z" }, - { url = "https://files.pythonhosted.org/packages/c8/65/5245ce8c548a8422236c13ffcdcdada6a2a812c361e9e0c70548bb40b661/contourpy-1.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:434f0adf84911c924519d2b08fc10491dd282b20bdd3fa8f60fd816ea0b48841", size = 322747, upload-time = "2025-04-15T17:38:43.712Z" }, - { url = "https://files.pythonhosted.org/packages/72/30/669b8eb48e0a01c660ead3752a25b44fdb2e5ebc13a55782f639170772f9/contourpy-1.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c66c4906cdbc50e9cba65978823e6e00b45682eb09adbb78c9775b74eb222422", size = 1308895, upload-time = "2025-04-15T17:39:00.224Z" }, - { url = "https://files.pythonhosted.org/packages/05/5a/b569f4250decee6e8d54498be7bdf29021a4c256e77fe8138c8319ef8eb3/contourpy-1.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8b7fc0cd78ba2f4695fd0a6ad81a19e7e3ab825c31b577f384aa9d7817dc3bef", size = 1379098, upload-time = "2025-04-15T17:43:29.649Z" }, - { url = "https://files.pythonhosted.org/packages/19/ba/b227c3886d120e60e41b28740ac3617b2f2b971b9f601c835661194579f1/contourpy-1.3.2-cp313-cp313-win32.whl", hash = "sha256:15ce6ab60957ca74cff444fe66d9045c1fd3e92c8936894ebd1f3eef2fff075f", size = 178535, upload-time = "2025-04-15T17:44:44.532Z" }, - { url = "https://files.pythonhosted.org/packages/12/6e/2fed56cd47ca739b43e892707ae9a13790a486a3173be063681ca67d2262/contourpy-1.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e1578f7eafce927b168752ed7e22646dad6cd9bca673c60bff55889fa236ebf9", size = 223096, upload-time = "2025-04-15T17:44:48.194Z" }, - { url = "https://files.pythonhosted.org/packages/54/4c/e76fe2a03014a7c767d79ea35c86a747e9325537a8b7627e0e5b3ba266b4/contourpy-1.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0475b1f6604896bc7c53bb070e355e9321e1bc0d381735421a2d2068ec56531f", size = 285090, upload-time = "2025-04-15T17:43:34.084Z" }, - { url = "https://files.pythonhosted.org/packages/7b/e2/5aba47debd55d668e00baf9651b721e7733975dc9fc27264a62b0dd26eb8/contourpy-1.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c85bb486e9be652314bb5b9e2e3b0d1b2e643d5eec4992c0fbe8ac71775da739", size = 268643, upload-time = "2025-04-15T17:43:38.626Z" }, - { url = "https://files.pythonhosted.org/packages/a1/37/cd45f1f051fe6230f751cc5cdd2728bb3a203f5619510ef11e732109593c/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:745b57db7758f3ffc05a10254edd3182a2a83402a89c00957a8e8a22f5582823", size = 310443, upload-time = "2025-04-15T17:43:44.522Z" }, - { url = "https://files.pythonhosted.org/packages/8b/a2/36ea6140c306c9ff6dd38e3bcec80b3b018474ef4d17eb68ceecd26675f4/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:970e9173dbd7eba9b4e01aab19215a48ee5dd3f43cef736eebde064a171f89a5", size = 349865, upload-time = "2025-04-15T17:43:49.545Z" }, - { url = "https://files.pythonhosted.org/packages/95/b7/2fc76bc539693180488f7b6cc518da7acbbb9e3b931fd9280504128bf956/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6c4639a9c22230276b7bffb6a850dfc8258a2521305e1faefe804d006b2e532", size = 321162, upload-time = "2025-04-15T17:43:54.203Z" }, - { url = "https://files.pythonhosted.org/packages/f4/10/76d4f778458b0aa83f96e59d65ece72a060bacb20cfbee46cf6cd5ceba41/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc829960f34ba36aad4302e78eabf3ef16a3a100863f0d4eeddf30e8a485a03b", size = 327355, upload-time = "2025-04-15T17:44:01.025Z" }, - { url = "https://files.pythonhosted.org/packages/43/a3/10cf483ea683f9f8ab096c24bad3cce20e0d1dd9a4baa0e2093c1c962d9d/contourpy-1.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d32530b534e986374fc19eaa77fcb87e8a99e5431499949b828312bdcd20ac52", size = 1307935, upload-time = "2025-04-15T17:44:17.322Z" }, - { url = "https://files.pythonhosted.org/packages/78/73/69dd9a024444489e22d86108e7b913f3528f56cfc312b5c5727a44188471/contourpy-1.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e298e7e70cf4eb179cc1077be1c725b5fd131ebc81181bf0c03525c8abc297fd", size = 1372168, upload-time = "2025-04-15T17:44:33.43Z" }, - { url = "https://files.pythonhosted.org/packages/0f/1b/96d586ccf1b1a9d2004dd519b25fbf104a11589abfd05484ff12199cca21/contourpy-1.3.2-cp313-cp313t-win32.whl", hash = "sha256:d0e589ae0d55204991450bb5c23f571c64fe43adaa53f93fc902a84c96f52fe1", size = 189550, upload-time = "2025-04-15T17:44:37.092Z" }, - { url = "https://files.pythonhosted.org/packages/b0/e6/6000d0094e8a5e32ad62591c8609e269febb6e4db83a1c75ff8868b42731/contourpy-1.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:78e9253c3de756b3f6a5174d024c4835acd59eb3f8e2ca13e775dbffe1558f69", size = 238214, upload-time = "2025-04-15T17:44:40.827Z" }, { url = "https://files.pythonhosted.org/packages/33/05/b26e3c6ecc05f349ee0013f0bb850a761016d89cec528a98193a48c34033/contourpy-1.3.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:fd93cc7f3139b6dd7aab2f26a90dde0aa9fc264dbf70f6740d498a70b860b82c", size = 265681, upload-time = "2025-04-15T17:44:59.314Z" }, { url = "https://files.pythonhosted.org/packages/2b/25/ac07d6ad12affa7d1ffed11b77417d0a6308170f44ff20fa1d5aa6333f03/contourpy-1.3.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:107ba8a6a7eec58bb475329e6d3b95deba9440667c4d62b9b6063942b61d7f16", size = 315101, upload-time = "2025-04-15T17:45:04.165Z" }, { url = "https://files.pythonhosted.org/packages/8f/4d/5bb3192bbe9d3f27e3061a6a8e7733c9120e203cb8515767d30973f71030/contourpy-1.3.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ded1706ed0c1049224531b81128efbd5084598f18d8a2d9efae833edbd2b40ad", size = 220599, upload-time = "2025-04-15T17:45:08.456Z" }, @@ -709,28 +624,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018, upload-time = "2025-07-26T12:01:35.64Z" }, { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567, upload-time = "2025-07-26T12:01:36.804Z" }, { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655, upload-time = "2025-07-26T12:01:37.999Z" }, - { url = "https://files.pythonhosted.org/packages/68/35/0167aad910bbdb9599272bd96d01a9ec6852f36b9455cf2ca67bd4cc2d23/contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5", size = 293257, upload-time = "2025-07-26T12:01:39.367Z" }, - { url = "https://files.pythonhosted.org/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1", size = 274034, upload-time = "2025-07-26T12:01:40.645Z" }, - { url = "https://files.pythonhosted.org/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286", size = 334672, upload-time = "2025-07-26T12:01:41.942Z" }, - { url = "https://files.pythonhosted.org/packages/ed/93/b43d8acbe67392e659e1d984700e79eb67e2acb2bd7f62012b583a7f1b55/contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5", size = 381234, upload-time = "2025-07-26T12:01:43.499Z" }, - { url = "https://files.pythonhosted.org/packages/46/3b/bec82a3ea06f66711520f75a40c8fc0b113b2a75edb36aa633eb11c4f50f/contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67", size = 385169, upload-time = "2025-07-26T12:01:45.219Z" }, - { url = "https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9", size = 362859, upload-time = "2025-07-26T12:01:46.519Z" }, - { url = "https://files.pythonhosted.org/packages/33/71/e2a7945b7de4e58af42d708a219f3b2f4cff7386e6b6ab0a0fa0033c49a9/contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659", size = 1332062, upload-time = "2025-07-26T12:01:48.964Z" }, - { url = "https://files.pythonhosted.org/packages/12/fc/4e87ac754220ccc0e807284f88e943d6d43b43843614f0a8afa469801db0/contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7", size = 1403932, upload-time = "2025-07-26T12:01:51.979Z" }, - { url = "https://files.pythonhosted.org/packages/a6/2e/adc197a37443f934594112222ac1aa7dc9a98faf9c3842884df9a9d8751d/contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d", size = 185024, upload-time = "2025-07-26T12:01:53.245Z" }, - { url = "https://files.pythonhosted.org/packages/18/0b/0098c214843213759692cc638fce7de5c289200a830e5035d1791d7a2338/contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263", size = 226578, upload-time = "2025-07-26T12:01:54.422Z" }, - { url = "https://files.pythonhosted.org/packages/8a/9a/2f6024a0c5995243cd63afdeb3651c984f0d2bc727fd98066d40e141ad73/contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9", size = 193524, upload-time = "2025-07-26T12:01:55.73Z" }, - { url = "https://files.pythonhosted.org/packages/c0/b3/f8a1a86bd3298513f500e5b1f5fd92b69896449f6cab6a146a5d52715479/contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d", size = 306730, upload-time = "2025-07-26T12:01:57.051Z" }, - { url = "https://files.pythonhosted.org/packages/3f/11/4780db94ae62fc0c2053909b65dc3246bd7cecfc4f8a20d957ad43aa4ad8/contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216", size = 287897, upload-time = "2025-07-26T12:01:58.663Z" }, - { url = "https://files.pythonhosted.org/packages/ae/15/e59f5f3ffdd6f3d4daa3e47114c53daabcb18574a26c21f03dc9e4e42ff0/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae", size = 326751, upload-time = "2025-07-26T12:02:00.343Z" }, - { url = "https://files.pythonhosted.org/packages/0f/81/03b45cfad088e4770b1dcf72ea78d3802d04200009fb364d18a493857210/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20", size = 375486, upload-time = "2025-07-26T12:02:02.128Z" }, - { url = "https://files.pythonhosted.org/packages/0c/ba/49923366492ffbdd4486e970d421b289a670ae8cf539c1ea9a09822b371a/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99", size = 388106, upload-time = "2025-07-26T12:02:03.615Z" }, - { url = "https://files.pythonhosted.org/packages/9f/52/5b00ea89525f8f143651f9f03a0df371d3cbd2fccd21ca9b768c7a6500c2/contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b", size = 352548, upload-time = "2025-07-26T12:02:05.165Z" }, - { url = "https://files.pythonhosted.org/packages/32/1d/a209ec1a3a3452d490f6b14dd92e72280c99ae3d1e73da74f8277d4ee08f/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a", size = 1322297, upload-time = "2025-07-26T12:02:07.379Z" }, - { url = "https://files.pythonhosted.org/packages/bc/9e/46f0e8ebdd884ca0e8877e46a3f4e633f6c9c8c4f3f6e72be3fe075994aa/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e", size = 1391023, upload-time = "2025-07-26T12:02:10.171Z" }, - { url = "https://files.pythonhosted.org/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3", size = 196157, upload-time = "2025-07-26T12:02:11.488Z" }, - { url = "https://files.pythonhosted.org/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8", size = 240570, upload-time = "2025-07-26T12:02:12.754Z" }, - { url = "https://files.pythonhosted.org/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713, upload-time = "2025-07-26T12:02:14.4Z" }, { url = "https://files.pythonhosted.org/packages/a5/29/8dcfe16f0107943fa92388c23f6e05cff0ba58058c4c95b00280d4c75a14/contourpy-1.3.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cd5dfcaeb10f7b7f9dc8941717c6c2ade08f587be2226222c12b25f0483ed497", size = 278809, upload-time = "2025-07-26T12:02:52.74Z" }, { url = "https://files.pythonhosted.org/packages/85/a9/8b37ef4f7dafeb335daee3c8254645ef5725be4d9c6aa70b50ec46ef2f7e/contourpy-1.3.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0c1fc238306b35f246d61a1d416a627348b5cf0648648a031e14bb8705fcdfe8", size = 261593, upload-time = "2025-07-26T12:02:54.037Z" }, { url = "https://files.pythonhosted.org/packages/0a/59/ebfb8c677c75605cc27f7122c90313fd2f375ff3c8d19a1694bda74aaa63/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70f9aad7de812d6541d29d2bbf8feb22ff7e1c299523db288004e3157ff4674e", size = 302202, upload-time = "2025-07-26T12:02:55.947Z" }, @@ -804,12 +697,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/45/557d4ed1fa54f0c7db8aee083229f624990d69f7d00f55477eed5c7e169a/cuda_bindings-12.9.7-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0666d3c082ef8f4b2d670950589373550e9f3bf564d635dd883f24a0b40402ff", size = 7071026, upload-time = "2026-05-27T18:44:13.356Z" }, { url = "https://files.pythonhosted.org/packages/91/97/e3c6e58ece26a053419ba0a18444b5443cfc64451bbf37f84e8143b8bdca/cuda_bindings-12.9.7-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c7ef48c5e13ae90f3b2ecfb72f8e99ac43c8f4c43e67e1325b8aae331453687", size = 7611059, upload-time = "2026-05-27T18:44:15.252Z" }, { url = "https://files.pythonhosted.org/packages/6d/39/afaa3de4d491a55af8961081e0b69c08d51bfbe471c359a7bddb4a28ca41/cuda_bindings-12.9.7-cp312-cp312-win_amd64.whl", hash = "sha256:3c089aaf4f5f570ec50244c68f5a2b00a2c9a8e01e04219fd2e36e340be0d88b", size = 7400841, upload-time = "2026-05-27T18:44:17.164Z" }, - { url = "https://files.pythonhosted.org/packages/eb/7b/f1575e41e1a17dc2f2a408b2e8e864c9324e41e3e23f6401e5efc54c152a/cuda_bindings-12.9.7-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:266379e4942051f544a8e7ea1a30ead8d7e8199b6b30fcdc8917cae2bf614e61", size = 6978549, upload-time = "2026-05-27T18:44:18.839Z" }, - { url = "https://files.pythonhosted.org/packages/9d/dc/62d62eb4f91eb721bcf46da51b13e9872ccd8fa7e60eb8ba7b7baeac72c6/cuda_bindings-12.9.7-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:59cf4a37b0d662ba15037c9ceebe1a306ebf2c01a8235a09be13cd07094fdb74", size = 7457675, upload-time = "2026-05-27T18:44:20.637Z" }, - { url = "https://files.pythonhosted.org/packages/43/b2/753fe88151001d0dc23f56a8e119fe06b991b0d1a885fa02f9852b12f523/cuda_bindings-12.9.7-cp313-cp313-win_amd64.whl", hash = "sha256:5bd89dcb78475a6d8a4620ea94b74edf0cbbeacee6d1622d8f94452c1e8d3f15", size = 7360097, upload-time = "2026-05-27T18:44:22.405Z" }, - { url = "https://files.pythonhosted.org/packages/f9/77/94d9b85f26add6fe9c9cb7c4ec3b96bc598f7ea5cfbd7490cc0a36adf5be/cuda_bindings-12.9.7-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2dbcd4801954eb3508f4dc2fa0d0c8eb93eb3f45326fd61be2731418c371e7a0", size = 6870886, upload-time = "2026-05-27T18:44:24.164Z" }, - { url = "https://files.pythonhosted.org/packages/04/dd/3ec34b569e1b990b11276feba306bf8f446656cc38e8ed0f49b5facfeffa/cuda_bindings-12.9.7-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3747ea132642416786a8e31bf229032df3a7856911ae5426a7be53d032df183d", size = 7345663, upload-time = "2026-05-27T18:44:26.333Z" }, - { url = "https://files.pythonhosted.org/packages/b8/c8/d79a20ba396e7ab2dfdd4b72b62356972b25b88aee2ded49a70c797ddea1/cuda_bindings-12.9.7-cp313-cp313t-win_amd64.whl", hash = "sha256:64f7ade7a7a3b69001489753acc21706d9dbda32db8deb68a767a0a0aab30b68", size = 7780136, upload-time = "2026-05-27T18:44:28.121Z" }, ] [[package]] @@ -834,9 +721,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/67/5e7dba1ba576dd73da5dee894ca076ca5e959450dfff66d6d510a255d1f7/cuda_bindings-13.3.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7855c4868aabc0cfae28abbe83d56734bdfbd08f08fc234ac1912a12858bf49", size = 6025351, upload-time = "2026-05-29T23:11:49.685Z" }, { url = "https://files.pythonhosted.org/packages/39/2a/6d2e9047d1fb243dbaa364b01e0297534b9ed7fd27dba1c9f361519cf69b/cuda_bindings-13.3.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e32d08f71ebcdf00f0f41eab2eb37e8da94c8ed411cc9f7f7a019ce6b34abe3a", size = 6657965, upload-time = "2026-05-29T23:11:52.227Z" }, { url = "https://files.pythonhosted.org/packages/7c/95/872a0392122f1fb43fcb06869790ef3171f37beee9f7db8f441739113570/cuda_bindings-13.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:b134dd8c5c66ae4c4ad814f7aee88fd215353c077010cbc47e3b55ed35ec9eff", size = 5875099, upload-time = "2026-05-29T23:11:54.635Z" }, - { url = "https://files.pythonhosted.org/packages/cc/6e/2394f8163360f8391f8f1b7e72d300a82724edb81a7b7084c799fbd4c91f/cuda_bindings-13.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9efb21c1ee64981e184b9e0ba5eb3179e5ba3d4b51665a6cb52b8ef3d01a7cbf", size = 5920504, upload-time = "2026-05-29T23:11:56.883Z" }, - { url = "https://files.pythonhosted.org/packages/34/c2/ef9b6a63f7dc432712a462c816662e662e00d38caa9b861c8c2588195d03/cuda_bindings-13.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2732904099e0a4d4db774a5fc6d91ee95fae065b4d2ecabb4968c5fe2406c9d7", size = 6476660, upload-time = "2026-05-29T23:11:59.188Z" }, - { url = "https://files.pythonhosted.org/packages/0c/2f/6a0dd496550c6fafbf6aeb1bf40242eeabb2fd138a43892aabb4be8224c2/cuda_bindings-13.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:18c8c167c8907b8f02531ca810534315c458dabef31f7965095619bf647b9202", size = 5830027, upload-time = "2026-05-29T23:12:01.205Z" }, ] [[package]] @@ -1574,13 +1458,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/de/72/8f5d083ef3ea86263a49296a4247343b111077b479d172b66f1d2971cd28/flip_evaluator-1.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:db5bd82d93c5be24e10134138cb54942fdce93fe771412a8b389e29e378cb59e", size = 906634, upload-time = "2025-11-07T15:33:16.866Z" }, { url = "https://files.pythonhosted.org/packages/53/bb/9a85a283efca7f57c6ba2c6457d299e4021984315e2ecf5a0d5fb13cc106/flip_evaluator-1.7-cp312-cp312-win32.whl", hash = "sha256:8a734f77b2f820110e67c78ad103cf62888cf26d9602e5fc0469551d9b81f0b8", size = 188882, upload-time = "2025-11-07T15:33:18.406Z" }, { url = "https://files.pythonhosted.org/packages/95/a6/fe3e220fc50783682662cc5fd9d1f86a4e9ee47d229c3079915bc226d0c9/flip_evaluator-1.7-cp312-cp312-win_amd64.whl", hash = "sha256:dc685bfc5acaab99adeb878b261c819637b2a0638bb7cbcffe9eb6a3d988c52b", size = 214363, upload-time = "2025-11-07T15:33:19.424Z" }, - { url = "https://files.pythonhosted.org/packages/25/86/e328522798cd53908ee875b69a3272306cc6229377fb2f70517fcc6820d9/flip_evaluator-1.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:76e689402877598b5f4a42d57d7109a4339381cd104317f361cefb103d0851b3", size = 189925, upload-time = "2025-11-07T15:33:20.51Z" }, - { url = "https://files.pythonhosted.org/packages/b2/bf/6d62779ed195adfda082f3cd7a91ebbe3a1cc9c1e936f6fd26283a248bdb/flip_evaluator-1.7-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3f37f778e02f15607450f11f6d1b21a9b8c6817415cdc3441c3a7c55c33b5664", size = 444745, upload-time = "2025-11-07T15:33:21.498Z" }, - { url = "https://files.pythonhosted.org/packages/a5/ed/fc88540c25b08aba2458835c26c8db54c6ea9c1dee058734492efe96eb1e/flip_evaluator-1.7-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d63eff9eaf9a68a6f9c30a3714c8a1a83fb6182f18df2fbc9bc361634a6691f7", size = 415164, upload-time = "2025-11-07T15:33:22.531Z" }, - { url = "https://files.pythonhosted.org/packages/3b/4d/18923dc2e5262d2e34cc09aa51d3d2b977855922afc8ce333df2a10e9fc8/flip_evaluator-1.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1308484dfd89a90fef4d87db7ba26c18fbb4f7df36c7ed20c9369e1cb5b19689", size = 976486, upload-time = "2025-11-07T15:33:25.133Z" }, - { url = "https://files.pythonhosted.org/packages/9d/27/f552b286648c40bb5820b19c4a8200965abace4e17554ce098b33d7e4f28/flip_evaluator-1.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8fc68d919692ba147c6a3c6f8b2c41aa7d8aa578cad6ee86884019d90c99df66", size = 906636, upload-time = "2025-11-07T15:33:26.217Z" }, - { url = "https://files.pythonhosted.org/packages/da/e8/e8b84c26cabcfe939c9909b5414090506496015f3e5937fade4557467011/flip_evaluator-1.7-cp313-cp313-win32.whl", hash = "sha256:3735232d08f6128ff743e8405bf6c7666efbee6e0a6ee1b5e2f5aa0b64f9b090", size = 188882, upload-time = "2025-11-07T15:33:27.33Z" }, - { url = "https://files.pythonhosted.org/packages/b0/00/820a81e5d047298a622ca0538fede02b6ff09fbe85ad4bfa82649a92d919/flip_evaluator-1.7-cp313-cp313-win_amd64.whl", hash = "sha256:2081f715ea8190a5f58bc578b0cccd36a9a3f3cf78ad13fe6f86e5a75615bda7", size = 214374, upload-time = "2025-11-07T15:33:28.226Z" }, ] [[package]] @@ -1613,14 +1490,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f2/00/3bbab338c07c71fa56269953845e92c951a61457bbbb0f1022551ea266d9/fonttools-4.63.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:27fdc65af8da6f88b9c6121c47a464cbe359fcfff7ff6fc2d37a1f395d755b78", size = 5133598, upload-time = "2026-05-14T12:03:25.168Z" }, { url = "https://files.pythonhosted.org/packages/62/f2/aa27c7f98db5b064883dadcc5283947e81e034de42e22a33675878d98b54/fonttools-4.63.0-cp312-cp312-win32.whl", hash = "sha256:af2fd1664d00a397d75f806985ddb36282091c2131a73a6485c23b4a34722263", size = 2292575, upload-time = "2026-05-14T12:03:27.496Z" }, { url = "https://files.pythonhosted.org/packages/87/36/cccb9bc2a6ab63d1b2980374f0dca72ce95ae267c9b4cfe77455bb70d0d4/fonttools-4.63.0-cp312-cp312-win_amd64.whl", hash = "sha256:59ac449f8cca9b4ffa08d2e7bbadad87ce710d69d1eda5c3c1ce579baa987272", size = 2343211, upload-time = "2026-05-14T12:03:30.057Z" }, - { url = "https://files.pythonhosted.org/packages/0f/8d/d8fec3dcde2963f8c908fb315e5ff2cd0ac34f82394bbbf73a2aa5145ce3/fonttools-4.63.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:cd7e9857e5e63738b9d9fd707bc1f59c8b09e5177726d23664db393c59bb08bd", size = 2876062, upload-time = "2026-05-14T12:03:32.554Z" }, - { url = "https://files.pythonhosted.org/packages/ef/71/d935dc54e4ff121bfdd11e08702db63a7e6f25af21d8a3d7b7212df53641/fonttools-4.63.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c2a2a42198b696a6f48fad91709afb55176e66a5e566131219dba372fb7f8c59", size = 2424594, upload-time = "2026-05-14T12:03:34.86Z" }, - { url = "https://files.pythonhosted.org/packages/8e/40/e76320afa1df918e146155ef239b1719ee266092e96f5423bfd075affba1/fonttools-4.63.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e874792a8212b44583ea02189d9e693906b2f78b261f372f95d6c563210ac1d", size = 5024840, upload-time = "2026-05-14T12:03:36.745Z" }, - { url = "https://files.pythonhosted.org/packages/ce/36/0b805d8c485f872f65a509cbe3b58a5d0d17bee855333b54a150c79d3061/fonttools-4.63.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22135da48a348785c5e2d5d2d9d6bec5ed44adacbaeb9db12d9493bf6c6bfa68", size = 4975801, upload-time = "2026-05-14T12:03:38.833Z" }, - { url = "https://files.pythonhosted.org/packages/c8/26/2cee03d0aa083ab022da5c07aff9ed3f689da1defb81ad6917c9627896da/fonttools-4.63.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ccf41f2efdf56994d22d73bef4ced1052161958169428d06ba9724ea9e9a64be", size = 4965009, upload-time = "2026-05-14T12:03:41.494Z" }, - { url = "https://files.pythonhosted.org/packages/7e/48/cc4b66d9058c0d0982c833fad10127c4b0e9324606aafa41382295ca4102/fonttools-4.63.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9ced0bd02ac751dd6319b0da88aaef24414e3b0dbc32bb4f24944821a3741a27", size = 5105892, upload-time = "2026-05-14T12:03:43.525Z" }, - { url = "https://files.pythonhosted.org/packages/d8/1f/a98a30a814b9ddef3a2e706025f90b9e0bc94890e6cb15254bc86547d11a/fonttools-4.63.0-cp313-cp313-win32.whl", hash = "sha256:85be818f5506e8a7753153def2c9550178f0ecae6a47b5e0e8dbb23f7cc90380", size = 2291313, upload-time = "2026-05-14T12:03:45.594Z" }, - { url = "https://files.pythonhosted.org/packages/92/46/5177b01f3b4abfdd4409f31cca4ab279c9343a26efbe9ec78c97fc612e02/fonttools-4.63.0-cp313-cp313-win_amd64.whl", hash = "sha256:ba04cb5891d4c0c21b6da95eda8d7b090021508a294fff33464fc7d241e0856b", size = 2342299, upload-time = "2026-05-14T12:03:47.414Z" }, { url = "https://files.pythonhosted.org/packages/2c/47/c99d5268f354002ce80f8d029cd9d7d872969da1de8b93d32de4dc56d6f4/fonttools-4.63.0-py3-none-any.whl", hash = "sha256:445af2eab030a16b9171ea8bdda7ebf7d96bda2df88ee182a464252f6e05e20d", size = 1164562, upload-time = "2026-05-14T12:04:29.092Z" }, ] @@ -1678,38 +1547,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, - { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, - { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, - { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, - { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, - { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, - { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, - { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, - { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, - { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, - { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, - { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, - { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, - { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, - { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, - { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, - { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, - { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, - { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, - { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, - { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, - { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, - { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, - { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, - { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, - { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, - { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, - { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, - { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, - { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, - { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, - { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, - { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, ] @@ -1755,11 +1592,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/a9/a780cc66f86335a6019f557a8aaca8fbb970728f0efd2430d15ff1beae0e/google_crc32c-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14f87e04d613dfa218d6135e81b78272c3b904e2a7053b841481b38a7d901411", size = 33364, upload-time = "2025-12-16T00:40:22.96Z" }, { url = "https://files.pythonhosted.org/packages/21/3f/3457ea803db0198c9aaca2dd373750972ce28a26f00544b6b85088811939/google_crc32c-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb5c869c2923d56cb0c8e6bcdd73c009c36ae39b652dbe46a05eb4ef0ad01454", size = 33740, upload-time = "2025-12-16T00:40:23.96Z" }, { url = "https://files.pythonhosted.org/packages/df/c0/87c2073e0c72515bb8733d4eef7b21548e8d189f094b5dad20b0ecaf64f6/google_crc32c-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:3cc0c8912038065eafa603b238abf252e204accab2a704c63b9e14837a854962", size = 34437, upload-time = "2025-12-16T00:35:21.395Z" }, - { url = "https://files.pythonhosted.org/packages/d1/db/000f15b41724589b0e7bc24bc7a8967898d8d3bc8caf64c513d91ef1f6c0/google_crc32c-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:3ebb04528e83b2634857f43f9bb8ef5b2bbe7f10f140daeb01b58f972d04736b", size = 31297, upload-time = "2025-12-16T00:23:20.709Z" }, - { url = "https://files.pythonhosted.org/packages/d7/0d/8ebed0c39c53a7e838e2a486da8abb0e52de135f1b376ae2f0b160eb4c1a/google_crc32c-1.8.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:450dc98429d3e33ed2926fc99ee81001928d63460f8538f21a5d6060912a8e27", size = 30867, upload-time = "2025-12-16T00:43:14.628Z" }, - { url = "https://files.pythonhosted.org/packages/ce/42/b468aec74a0354b34c8cbf748db20d6e350a68a2b0912e128cabee49806c/google_crc32c-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3b9776774b24ba76831609ffbabce8cdf6fa2bd5e9df37b594221c7e333a81fa", size = 33344, upload-time = "2025-12-16T00:40:24.742Z" }, - { url = "https://files.pythonhosted.org/packages/1c/e8/b33784d6fc77fb5062a8a7854e43e1e618b87d5ddf610a88025e4de6226e/google_crc32c-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:89c17d53d75562edfff86679244830599ee0a48efc216200691de8b02ab6b2b8", size = 33694, upload-time = "2025-12-16T00:40:25.505Z" }, - { url = "https://files.pythonhosted.org/packages/92/b1/d3cbd4d988afb3d8e4db94ca953df429ed6db7282ed0e700d25e6c7bfc8d/google_crc32c-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:57a50a9035b75643996fbf224d6661e386c7162d1dfdab9bc4ca790947d1007f", size = 34435, upload-time = "2025-12-16T00:35:22.107Z" }, { url = "https://files.pythonhosted.org/packages/52/c5/c171e4d8c44fec1422d801a6d2e5d7ddabd733eeda505c79730ee9607f07/google_crc32c-1.8.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:87fa445064e7db928226b2e6f0d5304ab4cd0339e664a4e9a25029f384d9bb93", size = 28615, upload-time = "2025-12-16T00:40:29.298Z" }, { url = "https://files.pythonhosted.org/packages/9c/97/7d75fe37a7a6ed171a2cf17117177e7aab7e6e0d115858741b41e9dd4254/google_crc32c-1.8.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f639065ea2042d5c034bf258a9f085eaa7af0cd250667c0635a3118e8f92c69c", size = 28800, upload-time = "2025-12-16T00:40:30.322Z" }, ] @@ -1803,16 +1635,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/25/7a/71437c7f3596e5246155c515852795a85a1a8d228190212432b13b97a95d/grpcio-1.81.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8ea1936c26b99999b27479853039a7f34713f56c49375ad52b38535ec93a796c", size = 7849660, upload-time = "2026-06-11T12:45:40.627Z" }, { url = "https://files.pythonhosted.org/packages/65/40/7debc0da45d2efebafb82da75644be347497fe4ee250514b8cd3b86ae8bf/grpcio-1.81.1-cp312-cp312-win32.whl", hash = "sha256:a185a04039df6cae8648bc8ab6d6fde7bf94f7188ecf7828e76ac52eef1e41d6", size = 4185819, upload-time = "2026-06-11T12:45:43.027Z" }, { url = "https://files.pythonhosted.org/packages/2e/b9/8fe3ba5ed462067774ebc1f9c7f26aa7ebcc280ddd476be107153de1339e/grpcio-1.81.1-cp312-cp312-win_amd64.whl", hash = "sha256:3ad74f8bb1a18963914c5452d289422830b39459e8776ebbcd207be1fbfb1d94", size = 4930461, upload-time = "2026-06-11T12:45:45.775Z" }, - { url = "https://files.pythonhosted.org/packages/7a/42/dcc2e4b600538ef18327c0839d56b7d3c3812337c5d710df5877dbb39b1e/grpcio-1.81.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:b10e1ff4756ed27d5a29d7fc79cfce7ef1ff56ad20025b89bac7cf79e09abbbe", size = 6054466, upload-time = "2026-06-11T12:45:48.43Z" }, - { url = "https://files.pythonhosted.org/packages/7b/4a/a36e03210183a8a7d4c80c3936acee679f4bd77d5861f369db47b2cc5f05/grpcio-1.81.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:819edbdcb42ab8598b494bcf0222684bbb7a3c772bd1b1f0be7e029a6063c28e", size = 12048795, upload-time = "2026-06-11T12:45:54.011Z" }, - { url = "https://files.pythonhosted.org/packages/b0/d5/d68e30b29098f63beab6fe501100fe82674ff142b32c672532da86a99b3a/grpcio-1.81.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c5bf2dc311127d91230cc79b92188c082634a06cf66c5234db49a43b910183b0", size = 6599094, upload-time = "2026-06-11T12:45:57.799Z" }, - { url = "https://files.pythonhosted.org/packages/3d/b3/e837954d279754f638a11cca5dcf6b24a005efb398984cefaf7735945a54/grpcio-1.81.1-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:e8ca6a1fcdb2943c9cbc1804a1baf3acb6071d72a471591678ded84218006e14", size = 7307182, upload-time = "2026-06-11T12:46:00.568Z" }, - { url = "https://files.pythonhosted.org/packages/0d/1e/b47957057e729adc6cdf519a47f8be2562b7140e280f1418443eb4022192/grpcio-1.81.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e64dd101d380a115cc5a0c7856788adb535f1a4e21fc543775602f8be95180ae", size = 6810962, upload-time = "2026-06-11T12:46:03.312Z" }, - { url = "https://files.pythonhosted.org/packages/40/26/569868e364e05b19ec8f969da53d230bcd89c962cd198f7c29943155c4d3/grpcio-1.81.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:98a07f9bf591e3a8919797bee1c53f026ba4acd587e5a4404c8e57c9ec36b2a5", size = 7415698, upload-time = "2026-06-11T12:46:06.005Z" }, - { url = "https://files.pythonhosted.org/packages/36/0c/5440a0582cb5653fc42a6e262eeb22700943313f8076f9dc927491b20a59/grpcio-1.81.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c261d74b1a945cf895a9d6eccd1685a8e837531beaab782da4d630a8d12deffb", size = 8407779, upload-time = "2026-06-11T12:46:08.84Z" }, - { url = "https://files.pythonhosted.org/packages/ff/aa/66fe9f39871d766987d869a03ee0842a026f499c7b1e62decb9e78a8088e/grpcio-1.81.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:58ad1131c300d3c9b933802b3cc4dc69d380822935ba50b28703156ea826fbf7", size = 7844521, upload-time = "2026-06-11T12:46:12.171Z" }, - { url = "https://files.pythonhosted.org/packages/f0/9e/69bb7194861bcd28fb3193261d4f9c3831b4446993f002cf59068943e7ab/grpcio-1.81.1-cp313-cp313-win32.whl", hash = "sha256:78e29211f26da2fdd0e9c6d2b79f489476140cf7029b6a64808ade7ca4156a42", size = 4182786, upload-time = "2026-06-11T12:46:15.192Z" }, - { url = "https://files.pythonhosted.org/packages/0d/20/3da8bb0d637feccdc3e1e419bb511ce93651ce7d54164f95de22cc0b8b34/grpcio-1.81.1-cp313-cp313-win_amd64.whl", hash = "sha256:edb59506291b647a30884b1d51a599d605f40b20af4a7dc3d33786a47a31de60", size = 4928648, upload-time = "2026-06-11T12:46:17.823Z" }, ] [[package]] @@ -1856,16 +1678,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/b5/67baeba7366162652cdc1dbd962289accde07241bc8f42f6f02b305efcc6/grpcio_tools-1.81.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:724ecb69af63d2f6d4ccea3e6fa0ca110ed9c5824d48c2f887c631bbb03c1c3c", size = 3370797, upload-time = "2026-06-11T12:50:21.501Z" }, { url = "https://files.pythonhosted.org/packages/f2/5d/34f2dce2125ccb107e32b57f5a9c1257edcc0793b0d2fef1e8b13a6bac3c/grpcio_tools-1.81.1-cp312-cp312-win32.whl", hash = "sha256:895a6782cec86beac71ccebb4b9848259c6f04a3028b8e42fa8d40cfe5146593", size = 1008453, upload-time = "2026-06-11T12:50:23.358Z" }, { url = "https://files.pythonhosted.org/packages/8a/be/09da8256ec8d2a5ce8a1acc51cbbc4ca52a462d78ed3412778440a56502e/grpcio_tools-1.81.1-cp312-cp312-win_amd64.whl", hash = "sha256:0265fd1386b7458302f79542558345880d484f8fa92ae196c0c0268242c5f23a", size = 1174857, upload-time = "2026-06-11T12:50:25.685Z" }, - { url = "https://files.pythonhosted.org/packages/76/90/5faa8b26e03495e5117f93bef8293cbada4af136362745dad7d1813ef0b0/grpcio_tools-1.81.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:3d604b4fd114b79ebb9f865bf3e04fd3ae93c704e1fad96f7fd03b0865c263b7", size = 2586071, upload-time = "2026-06-11T12:50:28.4Z" }, - { url = "https://files.pythonhosted.org/packages/e8/9a/85dc589fa6ae2439451eaa81a1578de31e29c676980d38bef7549b8a1f45/grpcio_tools-1.81.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:3389e705460efa3f3758141ba5520e6743b131c9576197c944fb9cbe49048126", size = 5813299, upload-time = "2026-06-11T12:50:31.295Z" }, - { url = "https://files.pythonhosted.org/packages/77/fd/c53994e58a837e6eefe48f53eb3492afc04f2b8af255df4adb37d14378f8/grpcio_tools-1.81.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8a17d8ceeb6a855fadf39f5171c80a382d97c4db98d5943eca553497fdebf84b", size = 2634668, upload-time = "2026-06-11T12:50:33.938Z" }, - { url = "https://files.pythonhosted.org/packages/34/32/de988e86688686a2117e7ce6ce9eff4f638c929bb55b0afe60d6fbd2e45c/grpcio_tools-1.81.1-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:43baf71dc60fd653062da2e95e95c73b35dd130be8f9fa3d544c3af3f808a290", size = 2957930, upload-time = "2026-06-11T12:50:36.726Z" }, - { url = "https://files.pythonhosted.org/packages/72/97/3f18a0ea32b5f809d21961dbd0bc382b589a4c3d501e3d67c345d5456ed3/grpcio_tools-1.81.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:136e90906af0df51ad929713244ba812d0dbb1844b4f467d5d86bdb054698f90", size = 2697760, upload-time = "2026-06-11T12:50:39.108Z" }, - { url = "https://files.pythonhosted.org/packages/49/c0/dbf5cbc877290ff7504a59959a8af4fdcfdaa1e84237948405ccf1aa82a6/grpcio_tools-1.81.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd6c3bf3ea6a61eb58c54368d72ada591f2a270f3a31a32e8536e773337e76d9", size = 3151456, upload-time = "2026-06-11T12:50:41.983Z" }, - { url = "https://files.pythonhosted.org/packages/de/ea/16fe2dc83140a59e5c0a0b9dc2693dd36bfaa6bd835724b4ec66a68eab7b/grpcio_tools-1.81.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c306c307f8f74cddc4056fdbb6f1da55de087a21120efbd02bd915daa5a52fd", size = 3710469, upload-time = "2026-06-11T12:50:44.596Z" }, - { url = "https://files.pythonhosted.org/packages/22/7d/df987d7d81e7ad2f7516d9e9d56ff29c54dbc6d8587e425688dca9a28e49/grpcio_tools-1.81.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bdbdc927be2e0ea13c32564a72ee31d712a716fb6f8c0d53d37a77d8277c272c", size = 3370488, upload-time = "2026-06-11T12:50:47.199Z" }, - { url = "https://files.pythonhosted.org/packages/ba/c5/5a63444d694ea47bf670138208f71830cc1759c402c8818092b28ab2dc5f/grpcio_tools-1.81.1-cp313-cp313-win32.whl", hash = "sha256:9d383724bcd67244b6def9e9164c640ee9380c0b7534ee7545a6fb0022a59afe", size = 1008229, upload-time = "2026-06-11T12:50:49.527Z" }, - { url = "https://files.pythonhosted.org/packages/00/75/3945e26d5c94ae6ed9be5caef73d4d66c47dc8cfdd7b4995efaf942754e0/grpcio_tools-1.81.1-cp313-cp313-win_amd64.whl", hash = "sha256:f3eb15849979ca7bb864ce81a74d68b0f225a7f111ed3fe212bfc08cf9812b10", size = 1174523, upload-time = "2026-06-11T12:50:51.755Z" }, ] [[package]] @@ -1883,14 +1695,6 @@ version = "1.5.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/4b/2d/57fd21d84d93efb4bd0b962383790e19dd1bc053501b4264c97903b4e83e/hf_xet-1.5.1.tar.gz", hash = "sha256:51ef4500dab3764b41135ee1381a4b62ce56fc54d4c92b719b59e597d6df5bf6", size = 876636, upload-time = "2026-06-08T23:02:53.897Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/64/ee/dd9ba7beae1005e54131b7d45263cc74c8a066d47d354e6d58ae9445a388/hf_xet-1.5.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:dbf48c0d02cf0b2e568944330c60d9120c272dabe013bd892d48e25bc6797577", size = 4069485, upload-time = "2026-06-08T23:02:13.193Z" }, - { url = "https://files.pythonhosted.org/packages/b6/bc/9cae6cfeb4e03070874e73e5c97c66eb90369d3206b6a2b1ef5f96520888/hf_xet-1.5.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78e4e5192ad2b674c2e1160b651cb9134db974f8ae1835bdfbfb0166b894a43", size = 3838493, upload-time = "2026-06-08T23:02:15.282Z" }, - { url = "https://files.pythonhosted.org/packages/ba/b4/d5c01e0eb6d9f2ca2dacd84d0d1b71e6cfbb2ef3208c968528e010e9b3d7/hf_xet-1.5.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6f7a04a8ad962422e225bc49fbbac99dc1806764b1f3e54dbd154bffa7593947", size = 4505658, upload-time = "2026-06-08T23:02:17.196Z" }, - { url = "https://files.pythonhosted.org/packages/76/c5/29a7598c0c6383c523dc22186d577f4e04267a626cd95ae60f67c00bfe66/hf_xet-1.5.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:d48199c2bf4f8df0adc55d31d1368b6ec0e4d4f45bc86b08038089c23db0bed8", size = 4292822, upload-time = "2026-06-08T23:02:18.608Z" }, - { url = "https://files.pythonhosted.org/packages/04/9a/dceaf6ca69390126b86ea825fb354b93d01163199070b7bd849225de9468/hf_xet-1.5.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:97f212a88d14bbf573619a74b7fecb238de77d08fc702e54dec6f78276ca3283", size = 4491255, upload-time = "2026-06-08T23:02:20.124Z" }, - { url = "https://files.pythonhosted.org/packages/48/a7/e5a7afaacf6c1791fdbeeac42951fb81c3d2bc482992b115dedcc86d963e/hf_xet-1.5.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f61e3665892a6c8c5e765395838b8ddf36185da835253d4bc4509a81e49fb342", size = 4711062, upload-time = "2026-06-08T23:02:21.863Z" }, - { url = "https://files.pythonhosted.org/packages/53/49/2802f8433c9742ce281bddc1e65c02c32268ca3098d66828b05e12e45ee2/hf_xet-1.5.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f4ad3ebd4c32dd2b27099d69dc7b2df821e30767e46fb6ee6a0713778243b8ff", size = 4017205, upload-time = "2026-06-08T23:02:23.495Z" }, - { url = "https://files.pythonhosted.org/packages/9e/5a/50c71195b9fb883659f596e7252faf4c18c58e753a9013bdbf9bac5d2250/hf_xet-1.5.1-cp313-cp313t-win_arm64.whl", hash = "sha256:8298485c1e36e7e67cbd01eeb1376619b7af43d4f1ec245caae306f890a8a32d", size = 3845426, upload-time = "2026-06-08T23:02:25.124Z" }, { url = "https://files.pythonhosted.org/packages/7a/d8/5e54cf37434759d1f4f2ba9b66077ff9d4c4e1f37b6bd7975da5c40d94ab/hf_xet-1.5.1-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:6abd35c3221eff63836618ddfb954dcf84798603f71d8e33e3ed7b04acfdbe6e", size = 4077794, upload-time = "2026-06-08T23:02:40.656Z" }, { url = "https://files.pythonhosted.org/packages/35/94/4b2ecfbad8f8b04701a23aefb62f540b9137d058b7e1dbef16a32676f0e9/hf_xet-1.5.1-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:94e761bbd266bf4c03cee73753916062665ce8365aa40ed321f45afcb934b41e", size = 3845354, upload-time = "2026-06-08T23:02:42.702Z" }, { url = "https://files.pythonhosted.org/packages/de/cc/f99f4bc7295023d7bd9ebbfd51f75cc530ca262c1227666268b8208f4b77/hf_xet-1.5.1-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:892e3a3a3aecc12aded8b93cf4f9cd059282c7de0732f7d55026f3abdf474350", size = 4514864, upload-time = "2026-06-08T23:02:44.497Z" }, @@ -2189,35 +1993,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e4/34/8aefdd0be9cfd00a44509251ba864f5caf2991e36772e61c408007e7f417/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d9daea4ea6b9be74fe2f01f7fbade8d6ffab263e781274cffca0dba9be9eec9", size = 2294947, upload-time = "2026-03-09T13:13:43.343Z" }, { url = "https://files.pythonhosted.org/packages/ad/cf/0348374369ca588f8fe9c338fae49fa4e16eeb10ffb3d012f23a54578a9e/kiwisolver-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:f18c2d9782259a6dc132fdc7a63c168cbc74b35284b6d75c673958982a378384", size = 73569, upload-time = "2026-03-09T13:13:45.792Z" }, { url = "https://files.pythonhosted.org/packages/28/26/192b26196e2316e2bd29deef67e37cdf9870d9af8e085e521afff0fed526/kiwisolver-1.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7c7553b13f69c1b29a5bde08ddc6d9d0c8bfb84f9ed01c30db25944aeb852a7", size = 64997, upload-time = "2026-03-09T13:13:46.878Z" }, - { url = "https://files.pythonhosted.org/packages/9d/69/024d6711d5ba575aa65d5538042e99964104e97fa153a9f10bc369182bc2/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:fd40bb9cd0891c4c3cb1ddf83f8bbfa15731a248fdc8162669405451e2724b09", size = 123166, upload-time = "2026-03-09T13:13:48.032Z" }, - { url = "https://files.pythonhosted.org/packages/ce/48/adbb40df306f587054a348831220812b9b1d787aff714cfbc8556e38fccd/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0e1403fd7c26d77c1f03e096dc58a5c726503fa0db0456678b8668f76f521e3", size = 66395, upload-time = "2026-03-09T13:13:49.365Z" }, - { url = "https://files.pythonhosted.org/packages/a8/3a/d0a972b34e1c63e2409413104216cd1caa02c5a37cb668d1687d466c1c45/kiwisolver-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dda366d548e89a90d88a86c692377d18d8bd64b39c1fb2b92cb31370e2896bbd", size = 64065, upload-time = "2026-03-09T13:13:50.562Z" }, - { url = "https://files.pythonhosted.org/packages/2b/0a/7b98e1e119878a27ba8618ca1e18b14f992ff1eda40f47bccccf4de44121/kiwisolver-1.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:332b4f0145c30b5f5ad9374881133e5aa64320428a57c2c2b61e9d891a51c2f3", size = 1477903, upload-time = "2026-03-09T13:13:52.084Z" }, - { url = "https://files.pythonhosted.org/packages/18/d8/55638d89ffd27799d5cc3d8aa28e12f4ce7a64d67b285114dbedc8ea4136/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c50b89ffd3e1a911c69a1dd3de7173c0cd10b130f56222e57898683841e4f96", size = 1278751, upload-time = "2026-03-09T13:13:54.673Z" }, - { url = "https://files.pythonhosted.org/packages/b8/97/b4c8d0d18421ecceba20ad8701358453b88e32414e6f6950b5a4bad54e65/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4db576bb8c3ef9365f8b40fe0f671644de6736ae2c27a2c62d7d8a1b4329f099", size = 1296793, upload-time = "2026-03-09T13:13:56.287Z" }, - { url = "https://files.pythonhosted.org/packages/c4/10/f862f94b6389d8957448ec9df59450b81bec4abb318805375c401a1e6892/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0b85aad90cea8ac6797a53b5d5f2e967334fa4d1149f031c4537569972596cb8", size = 1346041, upload-time = "2026-03-09T13:13:58.269Z" }, - { url = "https://files.pythonhosted.org/packages/a3/6a/f1650af35821eaf09de398ec0bc2aefc8f211f0cda50204c9f1673741ba9/kiwisolver-1.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:d36ca54cb4c6c4686f7cbb7b817f66f5911c12ddb519450bbe86707155028f87", size = 987292, upload-time = "2026-03-09T13:13:59.871Z" }, - { url = "https://files.pythonhosted.org/packages/de/19/d7fb82984b9238115fe629c915007be608ebd23dc8629703d917dbfaffd4/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:38f4a703656f493b0ad185211ccfca7f0386120f022066b018eb5296d8613e23", size = 2227865, upload-time = "2026-03-09T13:14:01.401Z" }, - { url = "https://files.pythonhosted.org/packages/7f/b9/46b7f386589fd222dac9e9de9c956ce5bcefe2ee73b4e79891381dda8654/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3ac2360e93cb41be81121755c6462cff3beaa9967188c866e5fce5cf13170859", size = 2324369, upload-time = "2026-03-09T13:14:02.972Z" }, - { url = "https://files.pythonhosted.org/packages/92/8b/95e237cf3d9c642960153c769ddcbe278f182c8affb20cecc1cc983e7cc5/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c95cab08d1965db3d84a121f1c7ce7479bdd4072c9b3dafd8fecce48a2e6b902", size = 1977989, upload-time = "2026-03-09T13:14:04.503Z" }, - { url = "https://files.pythonhosted.org/packages/1b/95/980c9df53501892784997820136c01f62bc1865e31b82b9560f980c0e649/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fc20894c3d21194d8041a28b65622d5b86db786da6e3cfe73f0c762951a61167", size = 2491645, upload-time = "2026-03-09T13:14:06.106Z" }, - { url = "https://files.pythonhosted.org/packages/cb/32/900647fd0840abebe1561792c6b31e6a7c0e278fc3973d30572a965ca14c/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a32f72973f0f950c1920475d5c5ea3d971b81b6f0ec53b8d0a956cc965f22e0", size = 2295237, upload-time = "2026-03-09T13:14:08.891Z" }, - { url = "https://files.pythonhosted.org/packages/be/8a/be60e3bbcf513cc5a50f4a3e88e1dcecebb79c1ad607a7222877becaa101/kiwisolver-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bf3acf1419fa93064a4c2189ac0b58e3be7872bf6ee6177b0d4c63dc4cea276", size = 73573, upload-time = "2026-03-09T13:14:12.327Z" }, - { url = "https://files.pythonhosted.org/packages/4d/d2/64be2e429eb4fca7f7e1c52a91b12663aeaf25de3895e5cca0f47ef2a8d0/kiwisolver-1.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:fa8eb9ecdb7efb0b226acec134e0d709e87a909fa4971a54c0c4f6e88635484c", size = 64998, upload-time = "2026-03-09T13:14:13.469Z" }, - { url = "https://files.pythonhosted.org/packages/b0/69/ce68dd0c85755ae2de490bf015b62f2cea5f6b14ff00a463f9d0774449ff/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db485b3847d182b908b483b2ed133c66d88d49cacf98fd278fadafe11b4478d1", size = 125700, upload-time = "2026-03-09T13:14:14.636Z" }, - { url = "https://files.pythonhosted.org/packages/74/aa/937aac021cf9d4349990d47eb319309a51355ed1dbdc9c077cdc9224cb11/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:be12f931839a3bdfe28b584db0e640a65a8bcbc24560ae3fdb025a449b3d754e", size = 67537, upload-time = "2026-03-09T13:14:15.808Z" }, - { url = "https://files.pythonhosted.org/packages/ee/20/3a87fbece2c40ad0f6f0aefa93542559159c5f99831d596050e8afae7a9f/kiwisolver-1.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:16b85d37c2cbb3253226d26e64663f755d88a03439a9c47df6246b35defbdfb7", size = 65514, upload-time = "2026-03-09T13:14:18.035Z" }, - { url = "https://files.pythonhosted.org/packages/f0/7f/f943879cda9007c45e1f7dba216d705c3a18d6b35830e488b6c6a4e7cdf0/kiwisolver-1.5.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4432b835675f0ea7414aab3d37d119f7226d24869b7a829caeab49ebda407b0c", size = 1584848, upload-time = "2026-03-09T13:14:19.745Z" }, - { url = "https://files.pythonhosted.org/packages/37/f8/4d4f85cc1870c127c88d950913370dd76138482161cd07eabbc450deff01/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b0feb50971481a2cc44d94e88bdb02cdd497618252ae226b8eb1201b957e368", size = 1391542, upload-time = "2026-03-09T13:14:21.54Z" }, - { url = "https://files.pythonhosted.org/packages/04/0b/65dd2916c84d252b244bd405303220f729e7c17c9d7d33dca6feeff9ffc4/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56fa888f10d0f367155e76ce849fa1166fc9730d13bd2d65a2aa13b6f5424489", size = 1404447, upload-time = "2026-03-09T13:14:23.205Z" }, - { url = "https://files.pythonhosted.org/packages/39/5c/2606a373247babce9b1d056c03a04b65f3cf5290a8eac5d7bdead0a17e21/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:940dda65d5e764406b9fb92761cbf462e4e63f712ab60ed98f70552e496f3bf1", size = 1455918, upload-time = "2026-03-09T13:14:24.74Z" }, - { url = "https://files.pythonhosted.org/packages/d5/d1/c6078b5756670658e9192a2ef11e939c92918833d2745f85cd14a6004bdf/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:89fc958c702ee9a745e4700378f5d23fddbc46ff89e8fdbf5395c24d5c1452a3", size = 1072856, upload-time = "2026-03-09T13:14:26.597Z" }, - { url = "https://files.pythonhosted.org/packages/cb/c8/7def6ddf16eb2b3741d8b172bdaa9af882b03c78e9b0772975408801fa63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9027d773c4ff81487181a925945743413f6069634d0b122d0b37684ccf4f1e18", size = 2333580, upload-time = "2026-03-09T13:14:28.237Z" }, - { url = "https://files.pythonhosted.org/packages/9e/87/2ac1fce0eb1e616fcd3c35caa23e665e9b1948bb984f4764790924594128/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5b233ea3e165e43e35dba1d2b8ecc21cf070b45b65ae17dd2747d2713d942021", size = 2423018, upload-time = "2026-03-09T13:14:30.018Z" }, - { url = "https://files.pythonhosted.org/packages/67/13/c6700ccc6cc218716bfcda4935e4b2997039869b4ad8a94f364c5a3b8e63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ce9bf03dad3b46408c08649c6fbd6ca28a9fce0eb32fdfffa6775a13103b5310", size = 2062804, upload-time = "2026-03-09T13:14:32.888Z" }, - { url = "https://files.pythonhosted.org/packages/1b/bd/877056304626943ff0f1f44c08f584300c199b887cb3176cd7e34f1515f1/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:fc4d3f1fb9ca0ae9f97b095963bc6326f1dbfd3779d6679a1e016b9baaa153d3", size = 2597482, upload-time = "2026-03-09T13:14:34.971Z" }, - { url = "https://files.pythonhosted.org/packages/75/19/c60626c47bf0f8ac5dcf72c6c98e266d714f2fbbfd50cf6dab5ede3aaa50/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f443b4825c50a51ee68585522ab4a1d1257fac65896f282b4c6763337ac9f5d2", size = 2394328, upload-time = "2026-03-09T13:14:36.816Z" }, - { url = "https://files.pythonhosted.org/packages/47/84/6a6d5e5bb8273756c27b7d810d47f7ef2f1f9b9fd23c9ee9a3f8c75c9cef/kiwisolver-1.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:893ff3a711d1b515ba9da14ee090519bad4610ed1962fbe298a434e8c5f8db53", size = 68410, upload-time = "2026-03-09T13:14:38.695Z" }, { url = "https://files.pythonhosted.org/packages/1c/fa/2910df836372d8761bb6eff7d8bdcb1613b5c2e03f260efe7abe34d388a7/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:5ae8e62c147495b01a0f4765c878e9bfdf843412446a247e28df59936e99e797", size = 130262, upload-time = "2026-03-09T13:15:35.629Z" }, { url = "https://files.pythonhosted.org/packages/0f/41/c5f71f9f00aabcc71fee8b7475e3f64747282580c2fe748961ba29b18385/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f6764a4ccab3078db14a632420930f6186058750df066b8ea2a7106df91d3203", size = 138036, upload-time = "2026-03-09T13:15:36.894Z" }, { url = "https://files.pythonhosted.org/packages/fa/06/7399a607f434119c6e1fdc8ec89a8d51ccccadf3341dee4ead6bd14caaf5/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c31c13da98624f957b0fb1b5bae5383b2333c2c3f6793d9825dd5ce79b525cb7", size = 194295, upload-time = "2026-03-09T13:15:38.22Z" }, @@ -2276,7 +2051,7 @@ dev = [ { name = "pytest" }, ] video-codec = [ - { name = "pynvvideocodec", marker = "python_full_version < '3.13' or (extra == 'extra-11-flashdreams-dev' and extra == 'group-11-flashdreams-cuda12') or (extra == 'group-11-flashdreams-cuda12' and extra == 'group-11-flashdreams-cuda13')" }, + { name = "pynvvideocodec" }, ] [package.metadata] @@ -2370,28 +2145,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, - { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, - { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, - { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, - { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, - { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, - { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, - { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, - { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, - { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, - { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, - { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, - { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, - { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, - { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, - { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, - { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, - { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, - { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, - { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, - { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, - { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, - { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, ] [[package]] @@ -2435,20 +2188,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fc/c2/541e4d09d87bb6b5830fc28b4c887a9a8cf4e1c6cee698a8c05552ae2003/matplotlib-3.10.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d75d11c949914165976c621b2324f9ef162af7ebf4b057ddf95dd1dba7e5edcf", size = 9670966, upload-time = "2026-04-24T00:12:32.131Z" }, { url = "https://files.pythonhosted.org/packages/04/a1/4571fc46e7702de8d0c2dc54ad1b2f8e29328dea3ee90831181f7353d93c/matplotlib-3.10.9-cp312-cp312-win_amd64.whl", hash = "sha256:d091f9d758b34aaaaa6331d13574bf01891d903b3dec59bfff458ef7551de5d6", size = 8217462, upload-time = "2026-04-24T00:12:35.226Z" }, { url = "https://files.pythonhosted.org/packages/4b/d0/2269edb12aa30c13c8bcc9382892e39943ce1d28aab4ec296e0381798e81/matplotlib-3.10.9-cp312-cp312-win_arm64.whl", hash = "sha256:10cc5ce06d10231c36f40e875f3c7e8050362a4ee8f0ee5d29a6b3277d57bb42", size = 8136688, upload-time = "2026-04-24T00:12:37.442Z" }, - { url = "https://files.pythonhosted.org/packages/aa/d3/8d4f6afbecb49fc04e060a57c0fce39ea51cc163a6bd87303ccd698e4fa6/matplotlib-3.10.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b580440f1ff81a0e34122051a3dfabb7e4b7f9e380629929bde0eff9af72165f", size = 8320331, upload-time = "2026-04-24T00:12:39.688Z" }, - { url = "https://files.pythonhosted.org/packages/63/d9/9e14bc7564bf92d5ffa801ae5fac819ce74b925dfb55e3ebde61a3bbad3e/matplotlib-3.10.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b1b745c489cd1a77a0dc1120a05dc87af9798faebc913601feb8c73d89bf2d1e", size = 8216461, upload-time = "2026-04-24T00:12:42.494Z" }, - { url = "https://files.pythonhosted.org/packages/8a/17/4402d0d14ccf1dfc70932600b68097fbbf9c898a4871d2cbbe79c7801a32/matplotlib-3.10.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8f3bcac1ca5ed000a6f4337d47ba67dfddf37ed6a46c15fd7f014997f7bf865f", size = 8790091, upload-time = "2026-04-24T00:12:44.789Z" }, - { url = "https://files.pythonhosted.org/packages/3e/0b/322aeec06dd9b91411f92028b37d447342770a24392aa4813e317064dad5/matplotlib-3.10.9-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a8d66a55def891c33147ba3ba9bfcabf0b526a43764c818acbb4525e5ed0838", size = 9605027, upload-time = "2026-04-24T00:12:47.583Z" }, - { url = "https://files.pythonhosted.org/packages/74/88/5f13482f55e7b00bcfc09838b093c2456e1379978d2a146844aae05350ad/matplotlib-3.10.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d843374407c4017a6403b59c6c81606773d136f3259d5b6da3131bc814542cc2", size = 9671269, upload-time = "2026-04-24T00:12:50.878Z" }, - { url = "https://files.pythonhosted.org/packages/c5/e0/0840fd2f93da988ec660b8ad1984abe9f25d2aed22a5e394ff1c68c88307/matplotlib-3.10.9-cp313-cp313-win_amd64.whl", hash = "sha256:f4399f64b3e94cd500195490972ae1ee81170df1636fa15364d157d5bdd7b921", size = 8217588, upload-time = "2026-04-24T00:12:53.784Z" }, - { url = "https://files.pythonhosted.org/packages/47/b9/d706d06dd605c49b9f83a2aed8c13e3e5db70697d7a80b7e3d7915de6b17/matplotlib-3.10.9-cp313-cp313-win_arm64.whl", hash = "sha256:ba7b3b8ef09eab7df0e86e9ae086faa433efbfbdb46afcb3aa16aabf779469a8", size = 8136913, upload-time = "2026-04-24T00:12:56.501Z" }, - { url = "https://files.pythonhosted.org/packages/9b/45/6e32d96978264c8ca8c4b1010adb955a1a49cfaf314e212bbc8908f04a61/matplotlib-3.10.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:09218df8a93712bd6ea133e83a153c755448cf7868316c531cffcc43f69d1cc9", size = 8368019, upload-time = "2026-04-24T00:12:58.896Z" }, - { url = "https://files.pythonhosted.org/packages/86/0a/c8e3d3bba245f0f7fc424937f8ff7ef77291a36af3edb97ccd78aa93d84f/matplotlib-3.10.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:82368699727bfb7b0182e1aa13082e3c08e092fa1a25d3e1fd92405bff96f6d4", size = 8264645, upload-time = "2026-04-24T00:13:01.406Z" }, - { url = "https://files.pythonhosted.org/packages/3d/aa/5bf5a14fe4fed73a4209a155606f8096ff797aad89c6c35179026571133e/matplotlib-3.10.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3225f4e1edcb8c86c884ddf79ebe20ecd0a67d30188f279897554ccd8fded4dc", size = 8802194, upload-time = "2026-04-24T00:13:03.702Z" }, - { url = "https://files.pythonhosted.org/packages/dd/5e/b4be852d6bba6fd15893fadf91ff26ae49cb91aac789e95dde9d342e664f/matplotlib-3.10.9-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de2445a0c6690d21b7eb6ce071cebad6d40a2e9bdf10d039074a96ba19797b99", size = 9622684, upload-time = "2026-04-24T00:13:06.647Z" }, - { url = "https://files.pythonhosted.org/packages/4c/3d/ed428c971139112ef730f62770654d609467346d09d4b62617e1afd68a5a/matplotlib-3.10.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:b2b9516251cb89ff618d757daec0e2ed1bf21248013844a853d87ef85ab3081d", size = 9680790, upload-time = "2026-04-24T00:13:10.009Z" }, - { url = "https://files.pythonhosted.org/packages/e7/09/052e884aaf2b985c63cb79f715f1d5b6a3eaa7de78f6a52b9dbc077d5b53/matplotlib-3.10.9-cp313-cp313t-win_amd64.whl", hash = "sha256:e9fae004b941b23ff2edcf1567a857ed77bafc8086ffa258190462328434faf8", size = 8287571, upload-time = "2026-04-24T00:13:13.087Z" }, - { url = "https://files.pythonhosted.org/packages/f4/38/ae27288e788c35a4250491422f3db7750366fc8c97d6f36fbdecfc1f5518/matplotlib-3.10.9-cp313-cp313t-win_arm64.whl", hash = "sha256:6b63d9c7c769b88ab81e10dc86e4e0607cf56817b9f9e6cf24b2a5f1693b8e38", size = 8188292, upload-time = "2026-04-24T00:13:15.546Z" }, { url = "https://files.pythonhosted.org/packages/2c/2b/0e92ad0ac446633f928a1563db4aa8add407e1924faf0ded5b95b35afb27/matplotlib-3.10.9-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1872fb212a05b729e649754a72d5da61d03e0554d76e80303b6f83d1d2c0552b", size = 8293058, upload-time = "2026-04-24T00:13:56.339Z" }, { url = "https://files.pythonhosted.org/packages/4b/23/74682fd369f5299ceda438fea2a0662e6383b85c9383fb9cdfcf04713e07/matplotlib-3.10.9-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:985f2238880e2e69093f588f5fe2e46771747febf0649f3cf7f7b7480875317f", size = 8186627, upload-time = "2026-04-24T00:13:58.623Z" }, { url = "https://files.pythonhosted.org/packages/ca/e8/368aab88f3c4cd8992800f31abfe0670c3e47540ba20a97e9fdbcde594b3/matplotlib-3.10.9-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6640f75af2c6148293caa0a2b39dd806a492dd66c8a8b04035813e33d0fd2585", size = 8764117, upload-time = "2026-04-24T00:14:01.684Z" }, @@ -2494,20 +2233,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/85/c2/db15da2bbdf9e3ca66df7db8e2c33a1dfed67be24a24d2c878efaaff01d6/matplotlib-3.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:94f5000f67ca9faa300863ea17f8bce9175cb67b88bec4bc7780502d53dd7c9e", size = 10923899, upload-time = "2026-06-12T02:28:00.223Z" }, { url = "https://files.pythonhosted.org/packages/e5/2f/a58a4443a4d052a4ea77557478336aefc26c7981f6408d37adba763aa758/matplotlib-3.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:ac6f1ef39f3d0f9e2463303013094992cdbe0f85f43bc54155bc472b2042768e", size = 9329528, upload-time = "2026-06-12T02:28:02.27Z" }, { url = "https://files.pythonhosted.org/packages/61/0f/4b669589d47733b97ab9df4b58d6fc1e68acb5ea42a928dc7cbdd6bf5871/matplotlib-3.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:9dd11fb612ce7bc60b1de5b4fc87ff959d22317b5de42aabf392f66f97af22eb", size = 9003413, upload-time = "2026-06-12T02:28:04.49Z" }, - { url = "https://files.pythonhosted.org/packages/55/41/aa47f156b061d14c98b906f76c428507397708ec63ff94f410ae1752b426/matplotlib-3.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6ce3b839b34ae1f430b4616893a2945a2999debaa7e94e7e29a2a8bbf286f7b5", size = 9450532, upload-time = "2026-06-12T02:28:06.769Z" }, - { url = "https://files.pythonhosted.org/packages/8c/4f/5a9eb0375e81413953febf8af7b012a6b6357f53438a15c4f5ad86c6bbb5/matplotlib-3.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:373db8f91214e8ccaf35ac833cc1dd59dd961e148bbd55dd027141591dde1313", size = 9279760, upload-time = "2026-06-12T02:28:09.152Z" }, - { url = "https://files.pythonhosted.org/packages/a4/c0/1117d53077e3ac3152503a84e9cf7a5c239576805ee71276e80c2aaa7471/matplotlib-3.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be152b7570324dc8d01574cc9474dd2d803237acf528bcbb5b211fa347461a09", size = 10031623, upload-time = "2026-06-12T02:28:11.26Z" }, - { url = "https://files.pythonhosted.org/packages/92/7e/e937138daffad65b71bf831a377809dcbc830fb4f31a31e067dc1faa2575/matplotlib-3.11.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:126f256df600652d7e4b394cf3164ff75210a00038f287c95a012a6f58d0e83f", size = 10839372, upload-time = "2026-06-12T02:28:14.102Z" }, - { url = "https://files.pythonhosted.org/packages/1d/c2/438ecc197ffb8023b6b9922915542f2172f5fd45b76703b0b4fc47322243/matplotlib-3.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:03acfeddf87b0dddb11b081ef7740ad445a3ca8bcb6b8e3011b08f2cf802b75c", size = 10924099, upload-time = "2026-06-12T02:28:16.383Z" }, - { url = "https://files.pythonhosted.org/packages/40/2e/395883da416f378b3ed2c9f3e843ac477eae1ce731b671b79adaa6f0bacd/matplotlib-3.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:ab3722f04f3ff34c23b5012c5873d2894174e06c3822fcdac3610965a5ac7d06", size = 9329727, upload-time = "2026-06-12T02:28:18.581Z" }, - { url = "https://files.pythonhosted.org/packages/61/82/2c388956abf8bf392dfb5b8917c502f1082df6a941b781ab8c8e5ba2474b/matplotlib-3.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:c945824670fb8915b4ac879e5e61f3c58e0913022f70a0de4c082b17372f8771", size = 9003506, upload-time = "2026-06-12T02:28:20.474Z" }, - { url = "https://files.pythonhosted.org/packages/c8/c1/34454baa44da7975ada82e9aea37105ec47059514dc967d3be14426ba8dc/matplotlib-3.11.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3489c3dc487669b4a980bc3068f87856de7a1564248d3f6c629efb2a58b03f24", size = 9499838, upload-time = "2026-06-12T02:28:22.713Z" }, - { url = "https://files.pythonhosted.org/packages/b1/c3/98fe79a398cf232219f090163a7fa7e6766e9f2e0ad26df54d6f8934d8ee/matplotlib-3.11.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:6a98f5476ce784a50ce09998f4ae1e6a9f25043cef8a480c98949902eda74620", size = 9332298, upload-time = "2026-06-12T02:28:24.796Z" }, - { url = "https://files.pythonhosted.org/packages/95/e4/b4b7c33151e74e5c802f3cde1ba807ebfc38401e329b44e215a5888dd76d/matplotlib-3.11.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:565af866fd63e4bd3f987d580afe27c44c2552a3b3305f4ecbb85133601ea6f3", size = 10045491, upload-time = "2026-06-12T02:28:27.141Z" }, - { url = "https://files.pythonhosted.org/packages/71/28/394548efd68354110c1a1be11fe6b6e559e06d1a23da35908a0e316c55a9/matplotlib-3.11.0-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e6b3e64dea5062c570f04358e2711859f3531b459f29516274fbad889079e4f3", size = 10857059, upload-time = "2026-06-12T02:28:29.222Z" }, - { url = "https://files.pythonhosted.org/packages/c8/44/e7922e6e2a4d63bdfbc9dc4a53e3850ab438d46cf42e6779bb15ec92c948/matplotlib-3.11.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:942b37c5db1899610bd1543ce8e13e4ecff9a4633e7f63bb6aa9205d2644ebd1", size = 10939576, upload-time = "2026-06-12T02:28:31.66Z" }, - { url = "https://files.pythonhosted.org/packages/3d/be/b1ca96003a441d619b727fee21d671fdff7a5ce2f1bb797b2521aa2f679a/matplotlib-3.11.0-cp313-cp313t-win_amd64.whl", hash = "sha256:c08e649a6313e1291e713623b97a38e5bb4aa580b2a100a94a3309bc6b9c8eb3", size = 9379519, upload-time = "2026-06-12T02:28:33.888Z" }, - { url = "https://files.pythonhosted.org/packages/e3/72/4bf3b91821c34596dd6a7bdac5836d94f744144c8208939ef49d8ec43f7e/matplotlib-3.11.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2746cd2c113742ff6ce37a864c5ac5fd7aa644568f445e66166e457ac78e40e0", size = 9055456, upload-time = "2026-06-12T02:28:35.878Z" }, { url = "https://files.pythonhosted.org/packages/0f/c2/f5da6cd37ed6871f5c9b3c0507ddb69f14d6c36fac4541e4e0c60cb8cdfc/matplotlib-3.11.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:81ae77077a1e16d37a5b61096ccb07c8d90a99b518fa8256b8f21578932f2f62", size = 9434094, upload-time = "2026-06-12T02:29:09.135Z" }, { url = "https://files.pythonhosted.org/packages/f8/07/56f66906e0f87a0c6d0d0acbd34dbc9432b1931d8f26ef618bd6f92932a9/matplotlib-3.11.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ddef37840695f5eef65f9f070fe2d2f510f584c2156203f9f622a5b0584efffd", size = 9262183, upload-time = "2026-06-12T02:29:11.283Z" }, { url = "https://files.pythonhosted.org/packages/0c/d8/c4ecab06b7ea36a570c4f3bd2d48d1799fd5d9174470e45c2194199431e7/matplotlib-3.11.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf662e5ac5707658cb931e19972c4bd99f7b4f8b7bf79d3c821d239fa6b71e64", size = 10015653, upload-time = "2026-06-12T02:29:13.251Z" }, @@ -2589,16 +2314,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3a/cb/28ce52eb94390dda42599c98ea0204d74799e4d8047a0eb559b6fd648056/ml_dtypes-0.5.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ad459e99793fa6e13bd5b7e6792c8f9190b4e5a1b45c63aba14a4d0a7f1d5ff", size = 5009002, upload-time = "2025-11-17T22:31:52.001Z" }, { url = "https://files.pythonhosted.org/packages/f5/f0/0cfadd537c5470378b1b32bd859cf2824972174b51b873c9d95cfd7475a5/ml_dtypes-0.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:c1a953995cccb9e25a4ae19e34316671e4e2edaebe4cf538229b1fc7109087b7", size = 212222, upload-time = "2025-11-17T22:31:53.742Z" }, { url = "https://files.pythonhosted.org/packages/16/2e/9acc86985bfad8f2c2d30291b27cd2bb4c74cea08695bd540906ed744249/ml_dtypes-0.5.4-cp312-cp312-win_arm64.whl", hash = "sha256:9bad06436568442575beb2d03389aa7456c690a5b05892c471215bfd8cf39460", size = 160793, upload-time = "2025-11-17T22:31:55.358Z" }, - { url = "https://files.pythonhosted.org/packages/d9/a1/4008f14bbc616cfb1ac5b39ea485f9c63031c4634ab3f4cf72e7541f816a/ml_dtypes-0.5.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8c760d85a2f82e2bed75867079188c9d18dae2ee77c25a54d60e9cc79be1bc48", size = 676888, upload-time = "2025-11-17T22:31:56.907Z" }, - { url = "https://files.pythonhosted.org/packages/d3/b7/dff378afc2b0d5a7d6cd9d3209b60474d9819d1189d347521e1688a60a53/ml_dtypes-0.5.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce756d3a10d0c4067172804c9cc276ba9cc0ff47af9078ad439b075d1abdc29b", size = 5036993, upload-time = "2025-11-17T22:31:58.497Z" }, - { url = "https://files.pythonhosted.org/packages/eb/33/40cd74219417e78b97c47802037cf2d87b91973e18bb968a7da48a96ea44/ml_dtypes-0.5.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:533ce891ba774eabf607172254f2e7260ba5f57bdd64030c9a4fcfbd99815d0d", size = 5010956, upload-time = "2025-11-17T22:31:59.931Z" }, - { url = "https://files.pythonhosted.org/packages/e1/8b/200088c6859d8221454825959df35b5244fa9bdf263fd0249ac5fb75e281/ml_dtypes-0.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:f21c9219ef48ca5ee78402d5cc831bd58ea27ce89beda894428bc67a52da5328", size = 212224, upload-time = "2025-11-17T22:32:01.349Z" }, - { url = "https://files.pythonhosted.org/packages/8f/75/dfc3775cb36367816e678f69a7843f6f03bd4e2bcd79941e01ea960a068e/ml_dtypes-0.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:35f29491a3e478407f7047b8a4834e4640a77d2737e0b294d049746507af5175", size = 160798, upload-time = "2025-11-17T22:32:02.864Z" }, - { url = "https://files.pythonhosted.org/packages/4f/74/e9ddb35fd1dd43b1106c20ced3f53c2e8e7fc7598c15638e9f80677f81d4/ml_dtypes-0.5.4-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:304ad47faa395415b9ccbcc06a0350800bc50eda70f0e45326796e27c62f18b6", size = 702083, upload-time = "2025-11-17T22:32:04.08Z" }, - { url = "https://files.pythonhosted.org/packages/74/f5/667060b0aed1aa63166b22897fdf16dca9eb704e6b4bbf86848d5a181aa7/ml_dtypes-0.5.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6a0df4223b514d799b8a1629c65ddc351b3efa833ccf7f8ea0cf654a61d1e35d", size = 5354111, upload-time = "2025-11-17T22:32:05.546Z" }, - { url = "https://files.pythonhosted.org/packages/40/49/0f8c498a28c0efa5f5c95a9e374c83ec1385ca41d0e85e7cf40e5d519a21/ml_dtypes-0.5.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:531eff30e4d368cb6255bc2328d070e35836aa4f282a0fb5f3a0cd7260257298", size = 5366453, upload-time = "2025-11-17T22:32:07.115Z" }, - { url = "https://files.pythonhosted.org/packages/8c/27/12607423d0a9c6bbbcc780ad19f1f6baa2b68b18ce4bddcdc122c4c68dc9/ml_dtypes-0.5.4-cp313-cp313t-win_amd64.whl", hash = "sha256:cb73dccfc991691c444acc8c0012bee8f2470da826a92e3a20bb333b1a7894e6", size = 225612, upload-time = "2025-11-17T22:32:08.615Z" }, - { url = "https://files.pythonhosted.org/packages/e5/80/5a5929e92c72936d5b19872c5fb8fc09327c1da67b3b68c6a13139e77e20/ml_dtypes-0.5.4-cp313-cp313t-win_arm64.whl", hash = "sha256:3bbbe120b915090d9dd1375e4684dd17a20a2491ef25d640a908281da85e73f1", size = 164145, upload-time = "2025-11-17T22:32:09.782Z" }, ] [[package]] @@ -2689,42 +2404,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, - { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, - { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, - { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, - { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, - { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, - { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, - { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, - { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, - { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, - { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, - { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, - { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, - { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, - { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, - { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, - { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, - { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, - { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, - { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, - { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, - { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, - { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, - { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, - { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, - { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, - { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, - { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, - { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, - { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, - { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, - { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, - { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, - { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, - { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, - { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, - { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, ] @@ -2876,26 +2555,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload-time = "2025-05-17T21:36:56.883Z" }, { url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506, upload-time = "2025-05-17T21:37:07.368Z" }, { url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190, upload-time = "2025-05-17T21:37:26.213Z" }, - { url = "https://files.pythonhosted.org/packages/f9/5c/6657823f4f594f72b5471f1db1ab12e26e890bb2e41897522d134d2a3e81/numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84", size = 20867828, upload-time = "2025-05-17T21:37:56.699Z" }, - { url = "https://files.pythonhosted.org/packages/dc/9e/14520dc3dadf3c803473bd07e9b2bd1b69bc583cb2497b47000fed2fa92f/numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b", size = 14143006, upload-time = "2025-05-17T21:38:18.291Z" }, - { url = "https://files.pythonhosted.org/packages/4f/06/7e96c57d90bebdce9918412087fc22ca9851cceaf5567a45c1f404480e9e/numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d", size = 5076765, upload-time = "2025-05-17T21:38:27.319Z" }, - { url = "https://files.pythonhosted.org/packages/73/ed/63d920c23b4289fdac96ddbdd6132e9427790977d5457cd132f18e76eae0/numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566", size = 6617736, upload-time = "2025-05-17T21:38:38.141Z" }, - { url = "https://files.pythonhosted.org/packages/85/c5/e19c8f99d83fd377ec8c7e0cf627a8049746da54afc24ef0a0cb73d5dfb5/numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f", size = 14010719, upload-time = "2025-05-17T21:38:58.433Z" }, - { url = "https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f", size = 16526072, upload-time = "2025-05-17T21:39:22.638Z" }, - { url = "https://files.pythonhosted.org/packages/b2/6c/04b5f47f4f32f7c2b0e7260442a8cbcf8168b0e1a41ff1495da42f42a14f/numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868", size = 15503213, upload-time = "2025-05-17T21:39:45.865Z" }, - { url = "https://files.pythonhosted.org/packages/17/0a/5cd92e352c1307640d5b6fec1b2ffb06cd0dabe7d7b8227f97933d378422/numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d", size = 18316632, upload-time = "2025-05-17T21:40:13.331Z" }, - { url = "https://files.pythonhosted.org/packages/f0/3b/5cba2b1d88760ef86596ad0f3d484b1cbff7c115ae2429678465057c5155/numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd", size = 6244532, upload-time = "2025-05-17T21:43:46.099Z" }, - { url = "https://files.pythonhosted.org/packages/cb/3b/d58c12eafcb298d4e6d0d40216866ab15f59e55d148a5658bb3132311fcf/numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c", size = 12610885, upload-time = "2025-05-17T21:44:05.145Z" }, - { url = "https://files.pythonhosted.org/packages/6b/9e/4bf918b818e516322db999ac25d00c75788ddfd2d2ade4fa66f1f38097e1/numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6", size = 20963467, upload-time = "2025-05-17T21:40:44Z" }, - { url = "https://files.pythonhosted.org/packages/61/66/d2de6b291507517ff2e438e13ff7b1e2cdbdb7cb40b3ed475377aece69f9/numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda", size = 14225144, upload-time = "2025-05-17T21:41:05.695Z" }, - { url = "https://files.pythonhosted.org/packages/e4/25/480387655407ead912e28ba3a820bc69af9adf13bcbe40b299d454ec011f/numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40", size = 5200217, upload-time = "2025-05-17T21:41:15.903Z" }, - { url = "https://files.pythonhosted.org/packages/aa/4a/6e313b5108f53dcbf3aca0c0f3e9c92f4c10ce57a0a721851f9785872895/numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8", size = 6712014, upload-time = "2025-05-17T21:41:27.321Z" }, - { url = "https://files.pythonhosted.org/packages/b7/30/172c2d5c4be71fdf476e9de553443cf8e25feddbe185e0bd88b096915bcc/numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f", size = 14077935, upload-time = "2025-05-17T21:41:49.738Z" }, - { url = "https://files.pythonhosted.org/packages/12/fb/9e743f8d4e4d3c710902cf87af3512082ae3d43b945d5d16563f26ec251d/numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa", size = 16600122, upload-time = "2025-05-17T21:42:14.046Z" }, - { url = "https://files.pythonhosted.org/packages/12/75/ee20da0e58d3a66f204f38916757e01e33a9737d0b22373b3eb5a27358f9/numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571", size = 15586143, upload-time = "2025-05-17T21:42:37.464Z" }, - { url = "https://files.pythonhosted.org/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1", size = 18385260, upload-time = "2025-05-17T21:43:05.189Z" }, - { url = "https://files.pythonhosted.org/packages/09/04/f2f83279d287407cf36a7a8053a5abe7be3622a4363337338f2585e4afda/numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff", size = 6377225, upload-time = "2025-05-17T21:43:16.254Z" }, - { url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374, upload-time = "2025-05-17T21:43:35.479Z" }, { url = "https://files.pythonhosted.org/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391, upload-time = "2025-05-17T21:44:35.948Z" }, { url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754, upload-time = "2025-05-17T21:44:47.446Z" }, { url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476, upload-time = "2025-05-17T21:45:11.871Z" }, @@ -2936,27 +2595,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, - { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, - { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, - { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, - { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, - { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, - { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, - { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, - { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, - { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, - { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, - { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, - { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, - { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, - { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, - { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, - { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, - { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, - { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, - { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, - { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, - { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" }, { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" }, { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" }, @@ -3145,9 +2783,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1a/3f/523fb08d9b7be15242ade6e2a641900d05c0e9cfffab8260de37a04ac0d2/nvidia_cudnn_frontend-1.22.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f64fb4e0a45b7a8bb126f91a71d8afc03facf14b82dade51744ca48cf20d2974", size = 2722597, upload-time = "2026-04-10T17:33:54.366Z" }, { url = "https://files.pythonhosted.org/packages/34/b7/35c87c334d553bd45809ec957b53f3d7dd13c5a407e853c9eea29fcc5b3c/nvidia_cudnn_frontend-1.22.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:933275df405053001888875ee75d2138b20dc4e8bf4057461b1c74ca68b0e270", size = 2863367, upload-time = "2026-04-10T17:29:22.838Z" }, { url = "https://files.pythonhosted.org/packages/4f/42/af975c8937a4c331b1215a0b2bdd2a742d792c6f777f919fd70480d63762/nvidia_cudnn_frontend-1.22.1-cp312-cp312-win_amd64.whl", hash = "sha256:2da1c277f008ee64273a48a5cb8d07efbb6d6774fdc08bd889476cce93b2f69a", size = 2310595, upload-time = "2026-04-10T17:37:24.776Z" }, - { url = "https://files.pythonhosted.org/packages/29/d3/d698b020ced27b75f1e29862f0bc26759da96fc743570a094632c0dd14a9/nvidia_cudnn_frontend-1.22.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bc0a0ec8004998a56f222cef618243bbee779930cdf3fe1f4a7604b2b412388", size = 2722225, upload-time = "2026-04-10T17:34:42.315Z" }, - { url = "https://files.pythonhosted.org/packages/2b/04/b7b66e3a0a7b036aca0f9704b335e663609359d0e3bdd7097f6d5ccdb40a/nvidia_cudnn_frontend-1.22.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b5295f8018cd92119968d948d25b0d2d834afd552627b47450759880dfe32110", size = 2863434, upload-time = "2026-04-10T17:29:55.721Z" }, - { url = "https://files.pythonhosted.org/packages/54/8c/e9da7bbdf197397d13bb418027951e6181d0bb74c70c648fd97376bc2ed7/nvidia_cudnn_frontend-1.22.1-cp313-cp313-win_amd64.whl", hash = "sha256:7ea7887facf23d5363159073b0080cc09185e73be16ae797831d89f09b96b0f4", size = 2310490, upload-time = "2026-04-10T17:37:47.625Z" }, ] [[package]] @@ -3537,19 +3172,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a6/de/8b1895b107277d52f2b42d3a6806e69cfef0d5cf1d0ba343470b9d8e0a04/pandas-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a68e15f780eddf2b07d242e17a04aa187a7ee12b40b930bfdd78070556550e98", size = 12771002, upload-time = "2025-09-29T23:20:26.76Z" }, { url = "https://files.pythonhosted.org/packages/87/21/84072af3187a677c5893b170ba2c8fbe450a6ff911234916da889b698220/pandas-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:371a4ab48e950033bcf52b6527eccb564f52dc826c02afd9a1bc0ab731bba084", size = 13450971, upload-time = "2025-09-29T23:20:41.344Z" }, { url = "https://files.pythonhosted.org/packages/86/41/585a168330ff063014880a80d744219dbf1dd7a1c706e75ab3425a987384/pandas-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:a16dcec078a01eeef8ee61bf64074b4e524a2a3f4b3be9326420cabe59c4778b", size = 10992722, upload-time = "2025-09-29T23:20:54.139Z" }, - { url = "https://files.pythonhosted.org/packages/cd/4b/18b035ee18f97c1040d94debd8f2e737000ad70ccc8f5513f4eefad75f4b/pandas-2.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:56851a737e3470de7fa88e6131f41281ed440d29a9268dcbf0002da5ac366713", size = 11544671, upload-time = "2025-09-29T23:21:05.024Z" }, - { url = "https://files.pythonhosted.org/packages/31/94/72fac03573102779920099bcac1c3b05975c2cb5f01eac609faf34bed1ca/pandas-2.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdcd9d1167f4885211e401b3036c0c8d9e274eee67ea8d0758a256d60704cfe8", size = 10680807, upload-time = "2025-09-29T23:21:15.979Z" }, - { url = "https://files.pythonhosted.org/packages/16/87/9472cf4a487d848476865321de18cc8c920b8cab98453ab79dbbc98db63a/pandas-2.3.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e32e7cc9af0f1cc15548288a51a3b681cc2a219faa838e995f7dc53dbab1062d", size = 11709872, upload-time = "2025-09-29T23:21:27.165Z" }, - { url = "https://files.pythonhosted.org/packages/15/07/284f757f63f8a8d69ed4472bfd85122bd086e637bf4ed09de572d575a693/pandas-2.3.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318d77e0e42a628c04dc56bcef4b40de67918f7041c2b061af1da41dcff670ac", size = 12306371, upload-time = "2025-09-29T23:21:40.532Z" }, - { url = "https://files.pythonhosted.org/packages/33/81/a3afc88fca4aa925804a27d2676d22dcd2031c2ebe08aabd0ae55b9ff282/pandas-2.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e0a175408804d566144e170d0476b15d78458795bb18f1304fb94160cabf40c", size = 12765333, upload-time = "2025-09-29T23:21:55.77Z" }, - { url = "https://files.pythonhosted.org/packages/8d/0f/b4d4ae743a83742f1153464cf1a8ecfafc3ac59722a0b5c8602310cb7158/pandas-2.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2d9ab0fc11822b5eece72ec9587e172f63cff87c00b062f6e37448ced4493", size = 13418120, upload-time = "2025-09-29T23:22:10.109Z" }, - { url = "https://files.pythonhosted.org/packages/4f/c7/e54682c96a895d0c808453269e0b5928a07a127a15704fedb643e9b0a4c8/pandas-2.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:f8bfc0e12dc78f777f323f55c58649591b2cd0c43534e8355c51d3fede5f4dee", size = 10993991, upload-time = "2025-09-29T23:25:04.889Z" }, - { url = "https://files.pythonhosted.org/packages/f9/ca/3f8d4f49740799189e1395812f3bf23b5e8fc7c190827d55a610da72ce55/pandas-2.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:75ea25f9529fdec2d2e93a42c523962261e567d250b0013b16210e1d40d7c2e5", size = 12048227, upload-time = "2025-09-29T23:22:24.343Z" }, - { url = "https://files.pythonhosted.org/packages/0e/5a/f43efec3e8c0cc92c4663ccad372dbdff72b60bdb56b2749f04aa1d07d7e/pandas-2.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74ecdf1d301e812db96a465a525952f4dde225fdb6d8e5a521d47e1f42041e21", size = 11411056, upload-time = "2025-09-29T23:22:37.762Z" }, - { url = "https://files.pythonhosted.org/packages/46/b1/85331edfc591208c9d1a63a06baa67b21d332e63b7a591a5ba42a10bb507/pandas-2.3.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6435cb949cb34ec11cc9860246ccb2fdc9ecd742c12d3304989017d53f039a78", size = 11645189, upload-time = "2025-09-29T23:22:51.688Z" }, - { url = "https://files.pythonhosted.org/packages/44/23/78d645adc35d94d1ac4f2a3c4112ab6f5b8999f4898b8cdf01252f8df4a9/pandas-2.3.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:900f47d8f20860de523a1ac881c4c36d65efcb2eb850e6948140fa781736e110", size = 12121912, upload-time = "2025-09-29T23:23:05.042Z" }, - { url = "https://files.pythonhosted.org/packages/53/da/d10013df5e6aaef6b425aa0c32e1fc1f3e431e4bcabd420517dceadce354/pandas-2.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a45c765238e2ed7d7c608fc5bc4a6f88b642f2f01e70c0c23d2224dd21829d86", size = 12712160, upload-time = "2025-09-29T23:23:28.57Z" }, - { url = "https://files.pythonhosted.org/packages/bd/17/e756653095a083d8a37cbd816cb87148debcfcd920129b25f99dd8d04271/pandas-2.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c4fc4c21971a1a9f4bdb4c73978c7f7256caa3e62b323f70d6cb80db583350bc", size = 13199233, upload-time = "2025-09-29T23:24:24.876Z" }, ] [[package]] @@ -3585,21 +3207,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f8/45/830bb57f533a4604b355e07edcb8ea18cf88b5f94e5fca92f27052d7c597/pandas-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8894dc474d648fe7b6ff0ca9b0bd73950d19952bc1a6534540762c5d79d305c", size = 11950889, upload-time = "2026-05-11T18:52:50.905Z" }, { url = "https://files.pythonhosted.org/packages/b9/c5/fc1b368f303087d20e8c9bf3d6ceb186263cfac0ade735cd938538bea839/pandas-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:c7be265b62cef88e253a941e4698604973736dcfe242fdb5198f0f7bc473cdcc", size = 9755463, upload-time = "2026-05-11T18:52:53.386Z" }, { url = "https://files.pythonhosted.org/packages/86/bd/fda8f9705b1b09c6ebe14bfc0fa0e4ec8584d54ea673628f157ff55131af/pandas-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:557409bc4178e70ee8d9ddb494798e51ebf6ea59330f6be22c51bab2a7db6c49", size = 9066158, upload-time = "2026-05-11T18:52:56.038Z" }, - { url = "https://files.pythonhosted.org/packages/c5/90/62d8302883c44308c477e222c3daf7c813a34c8e96985882fbd53d964352/pandas-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:67b3b64c11910cfa29f4e94a14d3bff9ee693b6fc76055e7cad549cee0aec5fa", size = 10331071, upload-time = "2026-05-11T18:52:58.838Z" }, - { url = "https://files.pythonhosted.org/packages/7f/ae/6a6493c783a101f165e4356953ba3c74d6f77f0042fa7d753da9dfbb640c/pandas-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:39436b377d56d2a2e52d0395bdbee171f01068e99af5250509aceeb929f765c7", size = 9875690, upload-time = "2026-05-11T18:53:01.431Z" }, - { url = "https://files.pythonhosted.org/packages/62/7c/5df8e9f56c69a2769fbe9382a5ef8f2658c007e376434e1e2cbb57ad895f/pandas-3.0.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4be06d68f9ddcfc645b87534911da79a8fbffc7573c80e0edcf42a5020624d8", size = 10381634, upload-time = "2026-05-11T18:53:04.393Z" }, - { url = "https://files.pythonhosted.org/packages/99/68/1237369725aa617bb358263d535803e3053fdbc593513ec5ed9c9896b5b6/pandas-3.0.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a4eeb6830daf35a71cc09649bd823e2b542dac246cdee9614c6e4bd65028cd6a", size = 10891243, upload-time = "2026-05-11T18:53:07.643Z" }, - { url = "https://files.pythonhosted.org/packages/25/93/77d108e8af7222b4a503ebde0e30215b1c2e4f8e53a526431890f22d5586/pandas-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1928e07221f82db493cd4af1e23c1bfca524a19a4699887975bff68f49a72bfb", size = 11388659, upload-time = "2026-05-11T18:53:10.634Z" }, - { url = "https://files.pythonhosted.org/packages/d0/bd/eff5b4399f332ac386c853f6cd2bd3fa2ca0061b9f36ecd9c4d7c4265649/pandas-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51b1fe551acb77dac643c6fda86084d8d446c10fe64b06a9cc29c4cc8540e7f2", size = 11942880, upload-time = "2026-05-11T18:53:13.536Z" }, - { url = "https://files.pythonhosted.org/packages/2c/20/559ace4200982c3887d0b86bfd0d856a2143ef8ddab63cc07934951a964c/pandas-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:a82d532a3351d435432cd913edbccaf8b8e01d4dd0e5ced5a8d2e8ecd94c7e44", size = 9757091, upload-time = "2026-05-11T18:53:16.306Z" }, - { url = "https://files.pythonhosted.org/packages/3a/66/69055a09fe200f29f922a3eeec4804611900b95f52d932ece3393c3c0c19/pandas-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:275c14e0fce14a2ec20eee474aecd305478ea3c1e6f6a9d8fe219a165542717e", size = 9057282, upload-time = "2026-05-11T18:53:18.768Z" }, - { url = "https://files.pythonhosted.org/packages/57/0e/efe801b0e6811e8e650cd21b7f2608e30f08a7067e2bf6e8752b0d56ee3c/pandas-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:46997386d528eb40376ecd6b033cf4a8a1e5282580f68f43de875b78cba2199d", size = 10767016, upload-time = "2026-05-11T18:53:21.227Z" }, - { url = "https://files.pythonhosted.org/packages/ea/dc/eb55135a1d5f0f0519f28da1f609a206d2cad1f9c35c32d51e38dd7261ae/pandas-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:261e308dfb22448384b7580cf719d2f998fe2966c92893c3e77d14008af1f066", size = 10420210, upload-time = "2026-05-11T18:53:23.982Z" }, - { url = "https://files.pythonhosted.org/packages/c6/3e/b1d5d955ce33ffecb407465a60bc32769d74fcf68224b7ae67ae11d4dea4/pandas-3.0.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd1a5d1def6a46002e964510bdc67c368aa0951df5d1d9f8365336f5a1f490cd", size = 10336126, upload-time = "2026-05-11T18:53:26.731Z" }, - { url = "https://files.pythonhosted.org/packages/f5/76/a01261711ab60a22d71b862f0de20e4c504bf80457270ad8cb42110f6abc/pandas-3.0.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d72828c20c6d6e83e1e22a6a3b47b326b71664112fa9705dcbccfd7a39b62085", size = 10728051, upload-time = "2026-05-11T18:53:29.125Z" }, - { url = "https://files.pythonhosted.org/packages/e9/21/ea191195e587b18cf682e97f433f81b2d0fbe341380e80a3e0d6e4403c8e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d26cbe1fcfc12e8fd900e2454163e466b2d3af84f7c75481df7683ffc073d870", size = 11350796, upload-time = "2026-05-11T18:53:32.056Z" }, - { url = "https://files.pythonhosted.org/packages/64/69/f0eaaf54939f0e8c6768fd06be9af2cef9b36048b96dfb9e1b2c685a807e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3e91cec1879ada0624fc3dc9953c5cbd60208e59c0db28f540c5d6d47502422f", size = 11799741, upload-time = "2026-05-11T18:53:34.985Z" }, - { url = "https://files.pythonhosted.org/packages/45/a4/865e0e510cae5fc2194de4db28be638952de942571ba9125934fd9c01d47/pandas-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:08d789b41f87e0905880e293cedf6197ce71fe67cc081358b1e148a491b9bd13", size = 10499958, upload-time = "2026-05-11T18:53:37.857Z" }, ] [[package]] @@ -3662,31 +3269,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, - { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, - { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, - { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, - { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" }, - { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" }, - { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" }, - { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" }, - { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" }, - { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" }, - { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" }, - { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" }, - { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" }, - { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" }, - { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" }, - { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" }, - { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" }, - { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" }, - { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" }, - { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" }, - { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" }, - { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" }, - { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" }, - { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, - { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, - { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, { url = "https://files.pythonhosted.org/packages/4e/b7/2437044fb910f499610356d1352e3423753c98e34f915252aafecc64889f/pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f", size = 5273969, upload-time = "2026-04-01T14:45:55.538Z" }, { url = "https://files.pythonhosted.org/packages/f6/f4/8316e31de11b780f4ac08ef3654a75555e624a98db1056ecb2122d008d5a/pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d", size = 4659674, upload-time = "2026-04-01T14:45:58.093Z" }, { url = "https://files.pythonhosted.org/packages/d4/37/664fca7201f8bb2aa1d20e2c3d5564a62e6ae5111741966c8319ca802361/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f", size = 5288479, upload-time = "2026-04-01T14:46:01.141Z" }, @@ -3811,40 +3393,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", size = 39018, upload-time = "2026-05-08T21:00:31.622Z" }, { url = "https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", size = 42322, upload-time = "2026-05-08T21:00:32.918Z" }, { url = "https://files.pythonhosted.org/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", size = 38172, upload-time = "2026-05-08T21:00:35.124Z" }, - { url = "https://files.pythonhosted.org/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a", size = 94457, upload-time = "2026-05-08T21:00:36.355Z" }, - { url = "https://files.pythonhosted.org/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117", size = 53835, upload-time = "2026-05-08T21:00:38.072Z" }, - { url = "https://files.pythonhosted.org/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098", size = 54545, upload-time = "2026-05-08T21:00:39.319Z" }, - { url = "https://files.pythonhosted.org/packages/a9/19/7fa086f5764c59ec8a8e157cd93aa8497acc00aba9dcdec56bfffb32602d/propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4", size = 59886, upload-time = "2026-05-08T21:00:40.621Z" }, - { url = "https://files.pythonhosted.org/packages/a1/e4/5d7663dc8235956c8f5281698a3af1d351d8820341ddd890f59d9a9127f2/propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e", size = 63261, upload-time = "2026-05-08T21:00:41.775Z" }, - { url = "https://files.pythonhosted.org/packages/4a/4a/15a03adee24d6350da4292caeac44c34c033d2afe5e87eb370f38854560f/propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7", size = 64184, upload-time = "2026-05-08T21:00:43.018Z" }, - { url = "https://files.pythonhosted.org/packages/8b/c6/979176efdaa3d239e36d503d5af63a0a773b36662ed8f52e5b6a6d9fd40e/propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d", size = 61534, upload-time = "2026-05-08T21:00:44.507Z" }, - { url = "https://files.pythonhosted.org/packages/c8/22/63e8cd1bae4c2d2be6493b6b7d10566ddafad88137cfbc99964a1119853c/propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a", size = 61500, upload-time = "2026-05-08T21:00:45.796Z" }, - { url = "https://files.pythonhosted.org/packages/60/5a/28e5d9acbac1cc9ccb67045e8c1b943aa8d79fdf39c93bd73cacd68008ea/propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2", size = 59994, upload-time = "2026-05-08T21:00:47.093Z" }, - { url = "https://files.pythonhosted.org/packages/f3/40/db650677f554a95b9c01a7c9d93d629e93a15562f5deb4573c9ee136fed2/propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa", size = 56884, upload-time = "2026-05-08T21:00:48.376Z" }, - { url = "https://files.pythonhosted.org/packages/80/45/70b39b89516ff8b96bf732fa6fded8cef20f293cb1508690101c3c07ec51/propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853", size = 63464, upload-time = "2026-05-08T21:00:49.954Z" }, - { url = "https://files.pythonhosted.org/packages/f9/e2/fa59d3a89eac5534293124af4f1d0d0ada091ce4a0ab4610ce03fd2bdd8d/propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a", size = 61588, upload-time = "2026-05-08T21:00:51.281Z" }, - { url = "https://files.pythonhosted.org/packages/0b/97/efb547a55c4bc7381cfb202d6a2239ac621045277bc1ea5dfd3a7f0516c0/propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704", size = 64667, upload-time = "2026-05-08T21:00:52.602Z" }, - { url = "https://files.pythonhosted.org/packages/92/56/f5c7d9b4b7595d5127da38974d791b2153f3d1eae6c674af3583ace92ad3/propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4", size = 62463, upload-time = "2026-05-08T21:00:54.303Z" }, - { url = "https://files.pythonhosted.org/packages/bd/3b/484a3a65fc9f9f60c41dcd17b428bace5389544e2c680994534a20755066/propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d", size = 38621, upload-time = "2026-05-08T21:00:55.808Z" }, - { url = "https://files.pythonhosted.org/packages/1c/fd/3f0f10dba4dabad3bf53102be007abf55481067952bde0fdddff439e7c61/propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757", size = 41649, upload-time = "2026-05-08T21:00:57.061Z" }, - { url = "https://files.pythonhosted.org/packages/90/ec/6ce619cc32bb500a482f811f9cd509368b4e58e638d13f2c68f370d6b475/propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f", size = 37636, upload-time = "2026-05-08T21:00:58.646Z" }, - { url = "https://files.pythonhosted.org/packages/1b/82/c1d268bbbf2ef981c5bf0fbbe746db617c66e3bcefe431a1aa8943fbe23a/propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d", size = 98872, upload-time = "2026-05-08T21:00:59.889Z" }, - { url = "https://files.pythonhosted.org/packages/f4/d4/52c871e73e864e6b34c0e2d58ac1ec5ccd149497ddc7ad2137ae98323a35/propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa", size = 56257, upload-time = "2026-05-08T21:01:01.195Z" }, - { url = "https://files.pythonhosted.org/packages/67/f0/9b90ca2a210b3d09bcfcd96ecd0f55545c091535abce2a45de2775cfd357/propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94", size = 56696, upload-time = "2026-05-08T21:01:02.941Z" }, - { url = "https://files.pythonhosted.org/packages/9d/0e/6e9d4ba07c8e56e21ddec1e75f12148142b21ca83a51871babce095334f4/propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164", size = 62378, upload-time = "2026-05-08T21:01:04.475Z" }, - { url = "https://files.pythonhosted.org/packages/65/19/c10badaa463dde8a27ce884f8ee2ec37e6035b7c9f5ff0c8f74f06f08dac/propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f", size = 65283, upload-time = "2026-05-08T21:01:05.959Z" }, - { url = "https://files.pythonhosted.org/packages/b0/b6/93bea99ca80e19cef6512a8580e5b7857bbe09422d9daa7fd4ef5723306c/propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c", size = 66616, upload-time = "2026-05-08T21:01:07.228Z" }, - { url = "https://files.pythonhosted.org/packages/83/e4/5c7462e50625f051f37fb38b8224f7639f667184bbd34424ec83819bb1b7/propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc", size = 63773, upload-time = "2026-05-08T21:01:08.514Z" }, - { url = "https://files.pythonhosted.org/packages/ca/b6/99238894047b13c823be25027e736626cd414a52a5e30d2c3347c2733529/propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f", size = 63664, upload-time = "2026-05-08T21:01:09.874Z" }, - { url = "https://files.pythonhosted.org/packages/85/1e/a3a1a63116a2b8edb415a8bb9a6f0c34bd03830b1e18e8ce2904e1dc1cf4/propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb", size = 62643, upload-time = "2026-05-08T21:01:11.132Z" }, - { url = "https://files.pythonhosted.org/packages/e4/03/893cf147de2fc6543c5eaa07ad833170e7e2a2385725bbebe8c0503723bb/propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751", size = 59595, upload-time = "2026-05-08T21:01:12.387Z" }, - { url = "https://files.pythonhosted.org/packages/86/3b/04c1a2e12c57766568ba75ba72b3bf2042818d4c1425fab6fc07155c7cff/propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836", size = 65711, upload-time = "2026-05-08T21:01:13.676Z" }, - { url = "https://files.pythonhosted.org/packages/1c/34/80f8d0099f8d6bacc4de1624c85672681c8cd1149ca2da0e38fd120b817f/propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f", size = 64247, upload-time = "2026-05-08T21:01:14.936Z" }, - { url = "https://files.pythonhosted.org/packages/f3/1a/8b08f3a5f1037e9e370c55883ceeeee0f6dd0416fb2d2d67b8bfc91f2a79/propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55", size = 67102, upload-time = "2026-05-08T21:01:16.281Z" }, - { url = "https://files.pythonhosted.org/packages/34/68/8bdb7bb7756d76e005490649d10e4a8369e610c74d619f71e1aedf889e9c/propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568", size = 64964, upload-time = "2026-05-08T21:01:17.57Z" }, - { url = "https://files.pythonhosted.org/packages/0a/aa/50fb0b5d3968b61a510926ff8b8465f1d6e976b3ab74496d7a4b9fc42515/propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191", size = 42546, upload-time = "2026-05-08T21:01:18.946Z" }, - { url = "https://files.pythonhosted.org/packages/ae/4c/0ddbae64321bd4a95bcbfc19307238016b5b1fee645c84626c8d539e5b74/propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7", size = 46330, upload-time = "2026-05-08T21:01:20.162Z" }, - { url = "https://files.pythonhosted.org/packages/00/d9/9cddc8efb78d8af264c5ec9f6d10b62f57c515feda8d321595f56010fb23/propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96", size = 40521, upload-time = "2026-05-08T21:01:21.399Z" }, { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, ] @@ -3869,12 +3417,6 @@ version = "7.2.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, - { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, - { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, - { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, - { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, - { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, @@ -3930,20 +3472,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5f/e8/f88ce625fe8babaae64e8db2d417c7653adb3019b08aae85c5ed787dc816/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3b13dedfe76a0ad2d1d859b0811b53827a4e9d93a0bcb05cf59333ab4980cc7e", size = 49376032, upload-time = "2026-04-21T10:47:48.967Z" }, { url = "https://files.pythonhosted.org/packages/36/7a/82c363caa145fff88fb475da50d3bf52bb024f61917be5424c3392eaf878/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:25ea65d868eb04015cd18e6df2fbe98f07e5bda2abefabcb88fce39a947716f6", size = 51929490, upload-time = "2026-04-21T10:47:55.981Z" }, { url = "https://files.pythonhosted.org/packages/66/1c/e3e72c8014ad2743ca64a701652c733cc5cbcee15c0463a32a8c55518d9e/pyarrow-24.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:295f0a7f2e242dabd513737cf076007dc5b2d59237e3eca37b05c0c6446f3826", size = 27355660, upload-time = "2026-04-21T10:48:01.718Z" }, - { url = "https://files.pythonhosted.org/packages/6f/d3/a1abf004482026ddc17f4503db227787fa3cfe41ec5091ff20e4fea55e57/pyarrow-24.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:02b001b3ed4723caa44f6cd1af2d5c86aa2cf9971dacc2ffa55b21237713dfba", size = 34976759, upload-time = "2026-04-21T10:48:07.258Z" }, - { url = "https://files.pythonhosted.org/packages/4f/4a/34f0a36d28a2dd32225301b79daad44e243dc1a2bb77d43b60749be255c4/pyarrow-24.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:04920d6a71aabd08a0417709efce97d45ea8e6fb733d9ca9ecffb13c67839f68", size = 36658471, upload-time = "2026-04-21T10:48:13.347Z" }, - { url = "https://files.pythonhosted.org/packages/1f/78/543b94712ae8bb1a6023bcc1acf1a740fbff8286747c289cd9468fced2a5/pyarrow-24.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:a964266397740257f16f7bb2e4f08a0c81454004beab8ff59dd531b73610e9f2", size = 45675981, upload-time = "2026-04-21T10:48:20.201Z" }, - { url = "https://files.pythonhosted.org/packages/84/9f/8fb7c222b100d314137fa40ec050de56cd8c6d957d1cfff685ce72f15b17/pyarrow-24.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6f066b179d68c413374294bc1735f68475457c933258df594443bb9d88ddc2a0", size = 48859172, upload-time = "2026-04-21T10:48:27.541Z" }, - { url = "https://files.pythonhosted.org/packages/a7/d3/1ea72538e6c8b3b475ed78d1049a2c518e655761ea50fe1171fc855fcab7/pyarrow-24.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1183baeb14c5f587b1ec52831e665718ce632caab84b7cd6b85fd44f96114495", size = 49385733, upload-time = "2026-04-21T10:48:34.7Z" }, - { url = "https://files.pythonhosted.org/packages/c3/be/c3d8b06a1ba35f2260f8e1f771abbee7d5e345c0937aab90675706b1690a/pyarrow-24.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:806f24b4085453c197a5078218d1ee08783ebbba271badd153d1ae22a3ee804f", size = 51934335, upload-time = "2026-04-21T10:48:42.099Z" }, - { url = "https://files.pythonhosted.org/packages/9c/62/89e07a1e7329d2cde3e3c6994ba0839a24977a2beda8be6005ea3d860b99/pyarrow-24.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:e4505fc6583f7b05ab854934896bcac8253b04ac1171a77dfb73efef92076d91", size = 27271748, upload-time = "2026-04-21T10:49:42.532Z" }, - { url = "https://files.pythonhosted.org/packages/17/1a/cff3a59f80b5b1658549d46611b67163f65e0664431c076ad728bf9d5af4/pyarrow-24.0.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:1a4e45017efbf115032e4475ee876d525e0e36c742214fbe405332480ecd6275", size = 35238554, upload-time = "2026-04-21T10:48:48.526Z" }, - { url = "https://files.pythonhosted.org/packages/a8/99/cce0f42a327bfef2c420fb6078a3eb834826e5d6697bf3009fe11d2ad051/pyarrow-24.0.0-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:7986f1fa71cee060ad00758bcc79d3a93bab8559bf978fab9e53472a2e25a17b", size = 36782301, upload-time = "2026-04-21T10:48:55.181Z" }, - { url = "https://files.pythonhosted.org/packages/2a/66/8e560d5ff6793ca29aca213c53eec0dd482dd46cb93b2819e5aab52e4252/pyarrow-24.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:d3e0b61e8efb24ed38898e5cdc5fffa9124be480008d401a1f8071500494ae42", size = 45721929, upload-time = "2026-04-21T10:49:03.676Z" }, - { url = "https://files.pythonhosted.org/packages/27/0c/a26e25505d030716e078d9f16eb74973cbf0b33b672884e9f9da1c83b871/pyarrow-24.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:55a3bc1e3df3b5567b7d27ef551b2283f0c68a5e86f1cd56abc569da4f31335b", size = 48825365, upload-time = "2026-04-21T10:49:11.714Z" }, - { url = "https://files.pythonhosted.org/packages/5f/eb/771f9ecb0c65e73fe9dccdd1717901b9594f08c4515d000c7c62df573811/pyarrow-24.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:641f795b361874ac9da5294f8f443dfdbee355cf2bd9e3b8d97aaac2306b9b37", size = 49451819, upload-time = "2026-04-21T10:49:21.474Z" }, - { url = "https://files.pythonhosted.org/packages/48/da/61ae89a88732f5a785646f3ec6125dbb640fa98a540eb2b9889caa561403/pyarrow-24.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8adc8e6ce5fccf5dc707046ae4914fd537def529709cc0d285d37a7f9cd442ca", size = 51909252, upload-time = "2026-04-21T10:49:31.164Z" }, - { url = "https://files.pythonhosted.org/packages/cb/1a/8dd5cafab7b66573fa91c03d06d213356ad4edd71813aa75e08ce2b3a844/pyarrow-24.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:9b18371ad2f44044b81a8d23bc2d8a9b6a6226dca775e8e16cfee640473d6c5d", size = 27388127, upload-time = "2026-04-21T10:49:37.334Z" }, ] [[package]] @@ -4023,21 +3551,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, - { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, - { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, - { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, - { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, - { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, - { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, - { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, - { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, - { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, - { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, - { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, - { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, - { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, - { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, @@ -4142,7 +3655,7 @@ version = "26.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, - { name = "typing-extensions", marker = "python_full_version < '3.13' or (extra == 'extra-11-flashdreams-dev' and extra == 'group-11-flashdreams-cuda12') or (extra == 'group-11-flashdreams-cuda12' and extra == 'group-11-flashdreams-cuda13')" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/74/b7/da07bae88f5a9506b4def6f2f4903cf4c3b8831e560dba8fa18ca08f758f/pyopenssl-26.3.0.tar.gz", hash = "sha256:589de7fae1c9ea670d18422ed00fc04da787bbde8e1454aea872aa57b49ad341", size = 182024, upload-time = "2026-06-12T20:28:07.458Z" } wheels = [ @@ -4183,7 +3696,7 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "backports-asyncio-runner", marker = "python_full_version < '3.11' or (extra == 'extra-11-flashdreams-dev' and extra == 'group-11-flashdreams-cuda12') or (extra == 'group-11-flashdreams-cuda12' and extra == 'group-11-flashdreams-cuda13')" }, { name = "pytest" }, - { name = "typing-extensions", marker = "python_full_version < '3.13' or (extra == 'extra-11-flashdreams-dev' and extra == 'group-11-flashdreams-cuda12') or (extra == 'group-11-flashdreams-cuda12' and extra == 'group-11-flashdreams-cuda13')" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } wheels = [ @@ -4280,16 +3793,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, - { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, - { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, - { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, - { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, - { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, - { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, - { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, - { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, - { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, - { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, ] [[package]] @@ -4360,38 +3863,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c3/1c/bdcc98f9a4af4fdd166c74941174619ccff4726d3ce32faa8e9a2ecd38dd/regex-2026.5.9-cp312-cp312-win32.whl", hash = "sha256:164eba9b755ea6f244b0d881196fbc1fac09714e9782c9e2732b813142033c8e", size = 266699, upload-time = "2026-05-09T23:12:59.14Z" }, { url = "https://files.pythonhosted.org/packages/78/87/240d36864f9e48ace85f72e79ced97ceb7f27ce87739a947dcb834b4e6bc/regex-2026.5.9-cp312-cp312-win_amd64.whl", hash = "sha256:86f40a5d6444db30a125c9c9177e6b25dad981cbc37451fd838f145e6edac92e", size = 277783, upload-time = "2026-05-09T23:13:00.789Z" }, { url = "https://files.pythonhosted.org/packages/4f/b5/7b30f312b0669dff5beebe5b0989dc2d1a312b1a44fab852199c387a5b96/regex-2026.5.9-cp312-cp312-win_arm64.whl", hash = "sha256:96f5f58b54a063d7ea9dca08e1cf57bfe10499c4d579ee672da284f57f5f0070", size = 270513, upload-time = "2026-05-09T23:13:02.426Z" }, - { url = "https://files.pythonhosted.org/packages/aa/da/797e91ecec6f84135da778ddce78c20e0af5d2a15c26f87a81bc3eadb6db/regex-2026.5.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d626b84406444b165fc0ba981604edea39f0588ff1f92baa23fe50799ea9afdb", size = 490303, upload-time = "2026-05-09T23:13:04.382Z" }, - { url = "https://files.pythonhosted.org/packages/44/da/bf30abaaa737b58f4a4b8c4a03659e02fd92092c822e0197ed9e0daab917/regex-2026.5.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d7bdc0ab8f3dd7e1b4f9ab88634e13374669db86bb3c72e8292f07ae313f539f", size = 292019, upload-time = "2026-05-09T23:13:06.022Z" }, - { url = "https://files.pythonhosted.org/packages/2d/e7/d0eaf5713828417b9e5648cf81fa9bacd4961f6ab98c380c2034f8716e35/regex-2026.5.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a8820737949116ffff55fe18f9fc644530063ba6ebfcb8314239416e78f1347c", size = 289468, upload-time = "2026-05-09T23:13:08.214Z" }, - { url = "https://files.pythonhosted.org/packages/d3/9b/b3fdd62b003baa1a9b593cd8c8699c9651c2e80cc21a5c715707983c42d7/regex-2026.5.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0fbdbac82cb3e4450d0ccde7d7a35607f4cb2dd9fba4b8b69bfaf8c9fa6aed", size = 796749, upload-time = "2026-05-09T23:13:10.573Z" }, - { url = "https://files.pythonhosted.org/packages/d4/30/66ab84588765f5b4b271a9ca09ef7ce2b87caa95176ec3d2ad65d7bc4902/regex-2026.5.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:57e8915c7986aa33d25e4d3629cef711cd2863f2961b10409f0c04cb8b7d9020", size = 865445, upload-time = "2026-05-09T23:13:12.523Z" }, - { url = "https://files.pythonhosted.org/packages/1a/89/f05169e8588aac365f35ffc7f3bc3184f095ef4cfded7cfaa3c7fd5dbd89/regex-2026.5.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:508f56a89ba9cb26e4168cbc37dbd60a28d82430a9e18ad1d25fe0883c314ca2", size = 912322, upload-time = "2026-05-09T23:13:14.281Z" }, - { url = "https://files.pythonhosted.org/packages/30/e1/c93444052cf41581f3c884ab3fb5823daf0992f11cd4388d4275ca610558/regex-2026.5.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6d189041f15691cfa2b6c4290448ec221244d225b3f5fe9e7771b34ffcdf6e2", size = 801269, upload-time = "2026-05-09T23:13:16.569Z" }, - { url = "https://files.pythonhosted.org/packages/50/fe/0cf96b882f540e62e8b9956599798203d599c44cf4c77917ca27400ff69b/regex-2026.5.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e82db382b44d0111b22601c509c89f64434816c9e0eef9d1989cda8cc6ff1c04", size = 777085, upload-time = "2026-05-09T23:13:18.675Z" }, - { url = "https://files.pythonhosted.org/packages/23/5c/d78d4924e7fc875557b9e9b768423925fdfaac5549d06da7810019a9bd26/regex-2026.5.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2acfb48634f64996b57f90f39afa692ff362162722581921fe92239a59960f3c", size = 785153, upload-time = "2026-05-09T23:13:20.525Z" }, - { url = "https://files.pythonhosted.org/packages/bf/e0/5214774090e7b4524dcea3e3c4aa74141d43043f8beb49c1599db1c8b53a/regex-2026.5.9-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d29eebfc9525db68cad3c97eedd7f754fa265aa5cd0cf4f863b2421e1b48fc9f", size = 860164, upload-time = "2026-05-09T23:13:22.263Z" }, - { url = "https://files.pythonhosted.org/packages/6e/e1/4a57a83350319b1271f0d7a249b8672513ed928b237a741631270de6caea/regex-2026.5.9-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:debb893095e944091c16e641a6e33c1b0f4cb61ab945ec5afbf53ce7068834d8", size = 765731, upload-time = "2026-05-09T23:13:24.277Z" }, - { url = "https://files.pythonhosted.org/packages/12/f4/499e74a20c156fc75836ee04a72a38d1a063978f600937f9760467beb1b0/regex-2026.5.9-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d659eee77986549c9ea45b861c7567e44d6287c3dc9a4565478853f7b9fe2ff6", size = 852062, upload-time = "2026-05-09T23:13:26.125Z" }, - { url = "https://files.pythonhosted.org/packages/5b/92/7eebc0d0a01e78629695f342ba17e0deaff8fb45e79cc0d7b98287da6e3e/regex-2026.5.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2efa205e6d98b24d1f3ab395c11aa15cdf10935bca283d0285e0499c284fba21", size = 789577, upload-time = "2026-05-09T23:13:27.814Z" }, - { url = "https://files.pythonhosted.org/packages/05/a4/018e71f7d2ad48c1ebe6d3ae0026f9b7cb4802fd15c7cc02fdf724355102/regex-2026.5.9-cp313-cp313-win32.whl", hash = "sha256:f3844f134e834076677dd369976e9f5068679fcb8e50102fdf6b7ac96a3ec127", size = 266691, upload-time = "2026-05-09T23:13:29.549Z" }, - { url = "https://files.pythonhosted.org/packages/e6/1d/861a93719fb9ee7dbfc3761b3797b7a3e112a5d42c6129459d2d741be9b5/regex-2026.5.9-cp313-cp313-win_amd64.whl", hash = "sha256:3527bb4942d2c14552155406cdedd906567456821848aed1cb4933a391bf5eca", size = 277747, upload-time = "2026-05-09T23:13:31.859Z" }, - { url = "https://files.pythonhosted.org/packages/d9/c6/0a2436ae4da1ba76e51cb98943c6838a9a721faa40ebe2dce07694ae34e3/regex-2026.5.9-cp313-cp313-win_arm64.whl", hash = "sha256:56a33f191f17d8c417f99945ebdc1e691d3af9605d86ec68c7e54a57e3e17af6", size = 270500, upload-time = "2026-05-09T23:13:33.525Z" }, - { url = "https://files.pythonhosted.org/packages/e8/e9/d21346f7b60ed58789371358ed66b09d00f832e1bd7c06e55d9da5679882/regex-2026.5.9-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:01f28d868834624c934b8d2e0aa1c8341337e37831f4a012f18a5afcba4cbaf3", size = 494172, upload-time = "2026-05-09T23:13:35.935Z" }, - { url = "https://files.pythonhosted.org/packages/c4/43/fd1177a2032037c681baecdb3422ee4e1424aec4e4f470ef47793d325274/regex-2026.5.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:48036f6374aaa79eb3b754ec29c61d1c6b1606749d705a13f8854fa2539671f6", size = 293952, upload-time = "2026-05-09T23:13:38.307Z" }, - { url = "https://files.pythonhosted.org/packages/f2/7d/9fbf919768368d3f8a4f6c692cf2aa61e482b2b81ec6a298ace4cbf02480/regex-2026.5.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b96350aa424e79d4fd6b567b344dcbe2b2d6bfc48dfe7717587e1fa6d43da6ff", size = 292314, upload-time = "2026-05-09T23:13:40.353Z" }, - { url = "https://files.pythonhosted.org/packages/e2/6c/e41bfeecb589716843e7c4df09ba46ff2a42961457afece19059d85caeef/regex-2026.5.9-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f3af7a4903c5c04a11a196a5aa75cdd7dd3f8508132f9fb3259d9f5908e3b88", size = 811681, upload-time = "2026-05-09T23:13:42.543Z" }, - { url = "https://files.pythonhosted.org/packages/87/83/a5c1c525fba0aa656e88ad0face0b1829788ef4c2fb6b26df58aa1151b84/regex-2026.5.9-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7e87577720152d2caae19fe2baaf1f8d5ca12091e9e229f03915c37d1e4b9178", size = 871135, upload-time = "2026-05-09T23:13:44.326Z" }, - { url = "https://files.pythonhosted.org/packages/18/d4/80882e799e440dd878b0979cbebf8fa4d54624a332c83037c7a701649e3f/regex-2026.5.9-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c8b9b9d294cfea3cd19c718ade7cc93492b2c4991abd9a68d0b3477ae6d8e100", size = 917265, upload-time = "2026-05-09T23:13:47.295Z" }, - { url = "https://files.pythonhosted.org/packages/ae/ff/8db60211e2286e396aad7dc7725356c502bff0901ea05bd6cdc2e1a042b9/regex-2026.5.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:728d8bfd28a8845c8b6bc5dc7ce010453d206396786c0765c2740cb65f37791e", size = 816311, upload-time = "2026-05-09T23:13:49.885Z" }, - { url = "https://files.pythonhosted.org/packages/4c/47/742ef579c61730f8d268e5cf1f9ce0e37e2ea041ad0f5644724f2378e463/regex-2026.5.9-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7e30b874d341fac767d7df5a0870540541c2c054b80cfaac116e8d367a8a7ff2", size = 785498, upload-time = "2026-05-09T23:13:52.25Z" }, - { url = "https://files.pythonhosted.org/packages/7f/ab/cb0999802dcb0fb95b1ab005e8d4163d8afdd67efc2cb6b6630ac13f8cb1/regex-2026.5.9-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fd190e88a895a8901325fad284a3f74ea52b1da8525b76cc811fa9b1edf0ce2b", size = 801348, upload-time = "2026-05-09T23:13:54.127Z" }, - { url = "https://files.pythonhosted.org/packages/7d/62/8ca59a24c55bc34d166eefaf3717bd77772f329fdbf984d86581e0a3571c/regex-2026.5.9-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:8e76e8161ad00694cfce6767d5dea860c6391ac5b83e5c3a39661e696f11fc7e", size = 866493, upload-time = "2026-05-09T23:13:56.067Z" }, - { url = "https://files.pythonhosted.org/packages/8d/3d/30f2ae62cef3278bb5bb821f467277a55fb73f01032cf85997e15e8289a8/regex-2026.5.9-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ddda5340e6c01a293027dd46232fa79eaff1b48058ce7a98f572b6445b088041", size = 772811, upload-time = "2026-05-09T23:13:57.867Z" }, - { url = "https://files.pythonhosted.org/packages/d8/ae/7d2089bcd78ad0c0161bc684339df50032acb438a7bd3305e7ddb1193cec/regex-2026.5.9-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:205109e96b3cf5adf8f4cd62bedde9487feb282b9497a3535451e5a24cd706a0", size = 856584, upload-time = "2026-05-09T23:13:59.679Z" }, - { url = "https://files.pythonhosted.org/packages/a9/29/92ff47f75990131ea4f24ba17819e5a9d141e10819807e09addd73409af6/regex-2026.5.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dfbe4579b9f08036aa7d101d1835437a20783574ac66327e6b29b4018a138081", size = 803453, upload-time = "2026-05-09T23:14:01.978Z" }, - { url = "https://files.pythonhosted.org/packages/04/99/eff29f1037dcab36702c9ee5d6858cf1ce2336ea8ea2987f64245b99ea5e/regex-2026.5.9-cp313-cp313t-win32.whl", hash = "sha256:ed2c9e8068b614c574d8d30e543d617cf5379b0535d46f97ef00e904745a08b5", size = 269951, upload-time = "2026-05-09T23:14:03.661Z" }, - { url = "https://files.pythonhosted.org/packages/0e/9d/8870b8981d27b22cda77bb26a5ac7ebfa9c7d9e0dea195a834a82380e748/regex-2026.5.9-cp313-cp313t-win_amd64.whl", hash = "sha256:b46b0f094dc1d3b90356c85a0bd2c9bafc4a6a190b9d6f8ddd5a033b6e088ed4", size = 281240, upload-time = "2026-05-09T23:14:05.56Z" }, - { url = "https://files.pythonhosted.org/packages/72/b1/3379415e8f135c13ac551353397cc4fe97b4978f3cac73c5fcbcded548b8/regex-2026.5.9-cp313-cp313t-win_arm64.whl", hash = "sha256:872acc074bd29ffc9913ecdfedf6ea77502312ca44a4aa0d3779089c6069d8de", size = 272383, upload-time = "2026-05-09T23:14:07.843Z" }, ] [[package]] @@ -4508,24 +3979,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/89/b1/fbb53137f42c4bf630b1ffdfc2151a62d1d1b903b249f030d2b1c0280af8/scipy-1.15.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6cfd56fc1a8e53f6e89ba3a7a7251f7396412d655bca2aa5611c8ec9a6784a1e", size = 36885140, upload-time = "2025-05-08T16:06:39.249Z" }, { url = "https://files.pythonhosted.org/packages/2e/2e/025e39e339f5090df1ff266d021892694dbb7e63568edcfe43f892fa381d/scipy-1.15.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ff17c0bb1cb32952c09217d8d1eed9b53d1463e5f1dd6052c7857f83127d539", size = 39710549, upload-time = "2025-05-08T16:06:45.729Z" }, { url = "https://files.pythonhosted.org/packages/e6/eb/3bf6ea8ab7f1503dca3a10df2e4b9c3f6b3316df07f6c0ded94b281c7101/scipy-1.15.3-cp312-cp312-win_amd64.whl", hash = "sha256:52092bc0472cfd17df49ff17e70624345efece4e1a12b23783a1ac59a1b728ed", size = 40966184, upload-time = "2025-05-08T16:06:52.623Z" }, - { url = "https://files.pythonhosted.org/packages/73/18/ec27848c9baae6e0d6573eda6e01a602e5649ee72c27c3a8aad673ebecfd/scipy-1.15.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2c620736bcc334782e24d173c0fdbb7590a0a436d2fdf39310a8902505008759", size = 38728256, upload-time = "2025-05-08T16:06:58.696Z" }, - { url = "https://files.pythonhosted.org/packages/74/cd/1aef2184948728b4b6e21267d53b3339762c285a46a274ebb7863c9e4742/scipy-1.15.3-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:7e11270a000969409d37ed399585ee530b9ef6aa99d50c019de4cb01e8e54e62", size = 30109540, upload-time = "2025-05-08T16:07:04.209Z" }, - { url = "https://files.pythonhosted.org/packages/5b/d8/59e452c0a255ec352bd0a833537a3bc1bfb679944c4938ab375b0a6b3a3e/scipy-1.15.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8c9ed3ba2c8a2ce098163a9bdb26f891746d02136995df25227a20e71c396ebb", size = 22383115, upload-time = "2025-05-08T16:07:08.998Z" }, - { url = "https://files.pythonhosted.org/packages/08/f5/456f56bbbfccf696263b47095291040655e3cbaf05d063bdc7c7517f32ac/scipy-1.15.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:0bdd905264c0c9cfa74a4772cdb2070171790381a5c4d312c973382fc6eaf730", size = 25163884, upload-time = "2025-05-08T16:07:14.091Z" }, - { url = "https://files.pythonhosted.org/packages/a2/66/a9618b6a435a0f0c0b8a6d0a2efb32d4ec5a85f023c2b79d39512040355b/scipy-1.15.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79167bba085c31f38603e11a267d862957cbb3ce018d8b38f79ac043bc92d825", size = 35174018, upload-time = "2025-05-08T16:07:19.427Z" }, - { url = "https://files.pythonhosted.org/packages/b5/09/c5b6734a50ad4882432b6bb7c02baf757f5b2f256041da5df242e2d7e6b6/scipy-1.15.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9deabd6d547aee2c9a81dee6cc96c6d7e9a9b1953f74850c179f91fdc729cb7", size = 37269716, upload-time = "2025-05-08T16:07:25.712Z" }, - { url = "https://files.pythonhosted.org/packages/77/0a/eac00ff741f23bcabd352731ed9b8995a0a60ef57f5fd788d611d43d69a1/scipy-1.15.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dde4fc32993071ac0c7dd2d82569e544f0bdaff66269cb475e0f369adad13f11", size = 36872342, upload-time = "2025-05-08T16:07:31.468Z" }, - { url = "https://files.pythonhosted.org/packages/fe/54/4379be86dd74b6ad81551689107360d9a3e18f24d20767a2d5b9253a3f0a/scipy-1.15.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f77f853d584e72e874d87357ad70f44b437331507d1c311457bed8ed2b956126", size = 39670869, upload-time = "2025-05-08T16:07:38.002Z" }, - { url = "https://files.pythonhosted.org/packages/87/2e/892ad2862ba54f084ffe8cc4a22667eaf9c2bcec6d2bff1d15713c6c0703/scipy-1.15.3-cp313-cp313-win_amd64.whl", hash = "sha256:b90ab29d0c37ec9bf55424c064312930ca5f4bde15ee8619ee44e69319aab163", size = 40988851, upload-time = "2025-05-08T16:08:33.671Z" }, - { url = "https://files.pythonhosted.org/packages/1b/e9/7a879c137f7e55b30d75d90ce3eb468197646bc7b443ac036ae3fe109055/scipy-1.15.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3ac07623267feb3ae308487c260ac684b32ea35fd81e12845039952f558047b8", size = 38863011, upload-time = "2025-05-08T16:07:44.039Z" }, - { url = "https://files.pythonhosted.org/packages/51/d1/226a806bbd69f62ce5ef5f3ffadc35286e9fbc802f606a07eb83bf2359de/scipy-1.15.3-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:6487aa99c2a3d509a5227d9a5e889ff05830a06b2ce08ec30df6d79db5fcd5c5", size = 30266407, upload-time = "2025-05-08T16:07:49.891Z" }, - { url = "https://files.pythonhosted.org/packages/e5/9b/f32d1d6093ab9eeabbd839b0f7619c62e46cc4b7b6dbf05b6e615bbd4400/scipy-1.15.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:50f9e62461c95d933d5c5ef4a1f2ebf9a2b4e83b0db374cb3f1de104d935922e", size = 22540030, upload-time = "2025-05-08T16:07:54.121Z" }, - { url = "https://files.pythonhosted.org/packages/e7/29/c278f699b095c1a884f29fda126340fcc201461ee8bfea5c8bdb1c7c958b/scipy-1.15.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:14ed70039d182f411ffc74789a16df3835e05dc469b898233a245cdfd7f162cb", size = 25218709, upload-time = "2025-05-08T16:07:58.506Z" }, - { url = "https://files.pythonhosted.org/packages/24/18/9e5374b617aba742a990581373cd6b68a2945d65cc588482749ef2e64467/scipy-1.15.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a769105537aa07a69468a0eefcd121be52006db61cdd8cac8a0e68980bbb723", size = 34809045, upload-time = "2025-05-08T16:08:03.929Z" }, - { url = "https://files.pythonhosted.org/packages/e1/fe/9c4361e7ba2927074360856db6135ef4904d505e9b3afbbcb073c4008328/scipy-1.15.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9db984639887e3dffb3928d118145ffe40eff2fa40cb241a306ec57c219ebbbb", size = 36703062, upload-time = "2025-05-08T16:08:09.558Z" }, - { url = "https://files.pythonhosted.org/packages/b7/8e/038ccfe29d272b30086b25a4960f757f97122cb2ec42e62b460d02fe98e9/scipy-1.15.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:40e54d5c7e7ebf1aa596c374c49fa3135f04648a0caabcb66c52884b943f02b4", size = 36393132, upload-time = "2025-05-08T16:08:15.34Z" }, - { url = "https://files.pythonhosted.org/packages/10/7e/5c12285452970be5bdbe8352c619250b97ebf7917d7a9a9e96b8a8140f17/scipy-1.15.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5e721fed53187e71d0ccf382b6bf977644c533e506c4d33c3fb24de89f5c3ed5", size = 38979503, upload-time = "2025-05-08T16:08:21.513Z" }, - { url = "https://files.pythonhosted.org/packages/81/06/0a5e5349474e1cbc5757975b21bd4fad0e72ebf138c5592f191646154e06/scipy-1.15.3-cp313-cp313t-win_amd64.whl", hash = "sha256:76ad1fb5f8752eabf0fa02e4cc0336b4e8f021e2d5f061ed37d6d264db35e3ca", size = 40308097, upload-time = "2025-05-08T16:08:27.627Z" }, ] [[package]] @@ -4561,26 +4014,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, - { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, - { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, - { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, - { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, - { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, - { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, - { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, - { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, - { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, - { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, - { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, - { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, - { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, - { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, - { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, - { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, - { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, - { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, - { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, - { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, ] [[package]] @@ -4606,16 +4039,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/ca/210d4759c7210bb7d269437421959b39a33434e2776b60c5cb8a763bb30a/scipy-1.18.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4a55985d54c769c872e64b7f4c8a81cc30ef700cc04296abbbf3705439c126de", size = 37421717, upload-time = "2026-06-19T15:00:05.102Z" }, { url = "https://files.pythonhosted.org/packages/2b/54/9a9edb45345bd6744da5ddfb6628e5d5185920494c6a67ec45b6381004cb/scipy-1.18.0-cp312-cp312-win_amd64.whl", hash = "sha256:71ccc8faa2dd16ac310233203474a8b5cb67f10dedd54a3116d34943f4b19132", size = 36597428, upload-time = "2026-06-19T15:00:08.112Z" }, { url = "https://files.pythonhosted.org/packages/99/0e/33f32a2a58987e26aec0f7df252cbbad1e90ae77bdbc76f40dd4ed0cf0ea/scipy-1.18.0-cp312-cp312-win_arm64.whl", hash = "sha256:d88363fd9d8fbd3511bd273f1a49efb2a540773ddf92a91d57498ce7dd7f3e76", size = 24351481, upload-time = "2026-06-19T15:00:11.103Z" }, - { url = "https://files.pythonhosted.org/packages/05/52/9c0136c2de7ae0779b7b366447766cec6d9f0702c56bb8ffeb04c8fd3af4/scipy-1.18.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:09143f676d157d9f546d663504ef9c1becb819824f1afc018814176411942446", size = 31036107, upload-time = "2026-06-19T15:00:14.03Z" }, - { url = "https://files.pythonhosted.org/packages/02/73/0291a64843270f4efb86cdcf2ee0f2048631b65ec6b405398b2b4dbf11bf/scipy-1.18.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5efe260f69417b97ddae455bfb5a95e8359f7f66ad7fa9522a60feb66f169520", size = 28663303, upload-time = "2026-06-19T15:00:16.819Z" }, - { url = "https://files.pythonhosted.org/packages/d3/0f/10ffa0b697a572f4e0d48b92a88895d366422f019f723e7e14a84c050dac/scipy-1.18.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:68363b7eaacd8b5dd426df56d782cc156468ac79a127a1b87ca597d6e2e82197", size = 20404960, upload-time = "2026-06-19T15:00:19.635Z" }, - { url = "https://files.pythonhosted.org/packages/7e/d2/e896cea21ba8edd6c81d4c55b1ffcc717e79698dcbebf9641b4cfb4c6622/scipy-1.18.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:c5557d8be5da8e41353fcd4d21491fdbab83b062fc579e94dc09a7c8ab4f669b", size = 23034074, upload-time = "2026-06-19T15:00:22.107Z" }, - { url = "https://files.pythonhosted.org/packages/ea/b2/e83ea34279a52c03374477c74006256ec78df65fc877baa4617d6de1d202/scipy-1.18.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0d13bca67c096d89fb95ced0d8921807300fce0275643aef9533cc63a0773468", size = 33942038, upload-time = "2026-06-19T15:00:24.964Z" }, - { url = "https://files.pythonhosted.org/packages/f6/af/e8fe5fb136f51e2b01678b92cb4106d10d8cd68ec147ead2e7cb0ac75398/scipy-1.18.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a46f9273dbd0eb1cefba61c9b8648b4dfe3cbc14a080176f9a73e44b8336dc7f", size = 35266390, upload-time = "2026-06-19T15:00:28.059Z" }, - { url = "https://files.pythonhosted.org/packages/3a/49/2c5cbb907b56695fc67517811d1db234dfd83381a84814ec220aded2794d/scipy-1.18.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5aba46108853ddfc77906b6557aac839d2b52e900c1d72a1180adaaab58d265f", size = 35551324, upload-time = "2026-06-19T15:00:31.014Z" }, - { url = "https://files.pythonhosted.org/packages/bb/73/eda39f7a2d306ff0ffc574afd13c0bbb6d10a603d9a413998ee269487a80/scipy-1.18.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b6f758e35f12757b5d95c00bc6de2438e229c2664b7a92e96f205959d9f2dfa4", size = 37404785, upload-time = "2026-06-19T15:00:34.072Z" }, - { url = "https://files.pythonhosted.org/packages/b7/d2/ae881ee28d014f38e0ccbfd974a06a919ba9af34f1f74bf42b5301891d63/scipy-1.18.0-cp313-cp313-win_amd64.whl", hash = "sha256:1afac4a847207c7ff8efd321734a50b06d0280b3b2a2c0fc2f413101747ad7c7", size = 36554943, upload-time = "2026-06-19T15:00:36.903Z" }, - { url = "https://files.pythonhosted.org/packages/70/3a/21154e2d54eb3639c6bf4dbae2e531c68356bfe95990daa30df33b30d556/scipy-1.18.0-cp313-cp313-win_arm64.whl", hash = "sha256:c5dbddf60e58c2312316d097271a8e73d40eaf2eabfa4d95ed7d3695bbf2ce7b", size = 24350911, upload-time = "2026-06-19T15:00:40.062Z" }, ] [[package]] @@ -4645,20 +4068,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b6/2d/37e3da037318a70066ded0d51bc2a7f35491ae6338dd993d5eb1503fc3b5/sentencepiece-0.2.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c8a168b040bc61681293f79a949b5d911c8e25086f4260285b8d97ab5f1195da", size = 1397736, upload-time = "2026-07-12T08:38:35.771Z" }, { url = "https://files.pythonhosted.org/packages/8d/11/753fca2e6b109be3ab7867abf357dfe48677fe726ae5a5363d0b54ca9450/sentencepiece-0.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:7c6e7bf684dc12145bfa685d3060beaea55139134ba848289bee514ed42e7383", size = 1248030, upload-time = "2026-07-12T08:38:37.604Z" }, { url = "https://files.pythonhosted.org/packages/e2/0a/70efbe861ca182d7d4b6e1a20f58e043400848fa9f2915229f082e221648/sentencepiece-0.2.2-cp312-cp312-win_arm64.whl", hash = "sha256:76ff5814db72e7462dece042d7593cdf102b8ec82c2b1cc201a2add34ee3050d", size = 1187325, upload-time = "2026-07-12T08:38:39.348Z" }, - { url = "https://files.pythonhosted.org/packages/b9/a3/b3b05095c174d6e80d37d5ddc2f57c2c56237333e7bbd6079cf3243c2a8a/sentencepiece-0.2.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:77c3ce990b23441e5ecfa5bce181fd6f408b564aeb6d7e1d1e7de9c5612501c8", size = 2188346, upload-time = "2026-07-12T08:38:41.089Z" }, - { url = "https://files.pythonhosted.org/packages/ca/f3/72ebc4acb10a06bcf7503fbc6091c8f5db68300f6aac4356c09e6c76e0e1/sentencepiece-0.2.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fd523c4992041faa5c2b3cde62253d11a96c30d73a34afe48a486e8e2254cd1c", size = 1441434, upload-time = "2026-07-12T08:38:42.56Z" }, - { url = "https://files.pythonhosted.org/packages/34/db/f9ea1a6844b4fa5dfe2312095cd866a1f724cd0905054ab9d5991778ba50/sentencepiece-0.2.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:201a8e0f55501a76e08dbf2c54bc45f4642b379271e89c667d517bfbc2191f2a", size = 1347267, upload-time = "2026-07-12T08:38:44.389Z" }, - { url = "https://files.pythonhosted.org/packages/32/4f/31c1073314ad94466bca37d29581761d70110237ee3d46b0efece59a8c1e/sentencepiece-0.2.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8eed98514bffe5ecac37f493f91869c351fbb05629328bfdbc08502c6c094dc0", size = 1324980, upload-time = "2026-07-12T08:38:46.304Z" }, - { url = "https://files.pythonhosted.org/packages/59/b4/a0356fa04d6a14337a6e0e443556785a0422c53ec58baae6b9568120eb0f/sentencepiece-0.2.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:64b656f025355cf8c51abe9fbe3848540756c6d7ca5e6791b1afa664bc24c7cb", size = 1397593, upload-time = "2026-07-12T08:38:48.302Z" }, - { url = "https://files.pythonhosted.org/packages/09/fa/d2d6369257fd2f0de616b1c7110b73fab409ef61b14f1b9e0010ed325914/sentencepiece-0.2.2-cp313-cp313-win_amd64.whl", hash = "sha256:74f0ee601047c0c12a783088b51be4e6214a62ecd9e02278c477433cd16e0ed9", size = 1247987, upload-time = "2026-07-12T08:38:50.15Z" }, - { url = "https://files.pythonhosted.org/packages/17/ee/2bb594da6fd95e32f29057f1aa7fa996701b8980090923c2d8711fdc0a24/sentencepiece-0.2.2-cp313-cp313-win_arm64.whl", hash = "sha256:b23fe17779834d3c27aaf2edac9486d04cca1a7deb8f5facda35150ac6263a91", size = 1187250, upload-time = "2026-07-12T08:38:52.246Z" }, - { url = "https://files.pythonhosted.org/packages/58/9c/dfc82846460e7a712310f5613f23d8b553cabb4e2e648663c11d8382af56/sentencepiece-0.2.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:72b7825b331b1b7e7c45be2e674b3e3c65af608fa376bad2d851b20aaf0cdc78", size = 2223080, upload-time = "2026-07-12T08:38:54.391Z" }, - { url = "https://files.pythonhosted.org/packages/8d/4e/3ff12cebe6d31662d9ceeabfb282de20bd0d6098fa282b4a3b8305abc7e8/sentencepiece-0.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d795c4ac689a57f9d4ba2288126ec7901d389ad5827d2f8b8533c883974fe563", size = 1458511, upload-time = "2026-07-12T08:38:56.811Z" }, - { url = "https://files.pythonhosted.org/packages/59/5a/16d51d05360be4cee3ebfe4837c184054c4eed16cabaeb3b039524e9a000/sentencepiece-0.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ab3f1ae98970b5590e2209341522718900ba19bcc2c207ffaa6bd417ad960c5", size = 1361138, upload-time = "2026-07-12T08:38:58.808Z" }, - { url = "https://files.pythonhosted.org/packages/0f/af/c30ee2a9f99d51db9844acaa8fa0b611a97c2fa7116646fa43db3300b187/sentencepiece-0.2.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ec27c152a1f1b24bc9168b55a5880f3c16e2334e697da6f55a1046a22405a3d", size = 1328625, upload-time = "2026-07-12T08:39:00.849Z" }, - { url = "https://files.pythonhosted.org/packages/3e/1a/4c6b39d03f5ba8439509adbd5a23c9538088a3cb679e7a47b911e8442bc6/sentencepiece-0.2.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:59d6588712101ccfcae9b03692be3aaae1514c2078666d7b05f15ba3a702e41b", size = 1398595, upload-time = "2026-07-12T08:39:02.86Z" }, - { url = "https://files.pythonhosted.org/packages/0f/bc/9eedddcec1fd57bc70200fa3ebf792d18fa63527a5369581cd416c81f97f/sentencepiece-0.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:89625fb43765cccaa1443b9adb61f283e5fe4cb1536728205d06bada730caa53", size = 1259346, upload-time = "2026-07-12T08:39:04.559Z" }, - { url = "https://files.pythonhosted.org/packages/41/15/7e74c8533848866ff560b29f7d8719921b76c4ec7149592d6d28e0deee75/sentencepiece-0.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:4f0603267cd15b92b68c2c0e852a441507614b70dc7773659baa6b8c214a91fd", size = 1196596, upload-time = "2026-07-12T08:39:06.454Z" }, ] [[package]] @@ -4704,22 +4113,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/a0/704c7292f7014c7e74ec84eddb7b109e1fbae74a16deae9c1504b1d15565/shapely-2.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:980c777c612514c0cf99bc8a9de6d286f5e186dcaf9091252fcd444e5638193d", size = 4152714, upload-time = "2025-09-24T13:50:39.9Z" }, { url = "https://files.pythonhosted.org/packages/53/46/319c9dc788884ad0785242543cdffac0e6530e4d0deb6c4862bc4143dcf3/shapely-2.1.2-cp312-cp312-win32.whl", hash = "sha256:9111274b88e4d7b54a95218e243282709b330ef52b7b86bc6aaf4f805306f454", size = 1542745, upload-time = "2025-09-24T13:50:41.414Z" }, { url = "https://files.pythonhosted.org/packages/ec/bf/cb6c1c505cb31e818e900b9312d514f381fbfa5c4363edfce0fcc4f8c1a4/shapely-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:743044b4cfb34f9a67205cee9279feaf60ba7d02e69febc2afc609047cb49179", size = 1722861, upload-time = "2025-09-24T13:50:43.35Z" }, - { url = "https://files.pythonhosted.org/packages/c3/90/98ef257c23c46425dc4d1d31005ad7c8d649fe423a38b917db02c30f1f5a/shapely-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b510dda1a3672d6879beb319bc7c5fd302c6c354584690973c838f46ec3e0fa8", size = 1832644, upload-time = "2025-09-24T13:50:44.886Z" }, - { url = "https://files.pythonhosted.org/packages/6d/ab/0bee5a830d209adcd3a01f2d4b70e587cdd9fd7380d5198c064091005af8/shapely-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8cff473e81017594d20ec55d86b54bc635544897e13a7cfc12e36909c5309a2a", size = 1642887, upload-time = "2025-09-24T13:50:46.735Z" }, - { url = "https://files.pythonhosted.org/packages/2d/5e/7d7f54ba960c13302584c73704d8c4d15404a51024631adb60b126a4ae88/shapely-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe7b77dc63d707c09726b7908f575fc04ff1d1ad0f3fb92aec212396bc6cfe5e", size = 2970931, upload-time = "2025-09-24T13:50:48.374Z" }, - { url = "https://files.pythonhosted.org/packages/f2/a2/83fc37e2a58090e3d2ff79175a95493c664bcd0b653dd75cb9134645a4e5/shapely-2.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ed1a5bbfb386ee8332713bf7508bc24e32d24b74fc9a7b9f8529a55db9f4ee6", size = 3082855, upload-time = "2025-09-24T13:50:50.037Z" }, - { url = "https://files.pythonhosted.org/packages/44/2b/578faf235a5b09f16b5f02833c53822294d7f21b242f8e2d0cf03fb64321/shapely-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a84e0582858d841d54355246ddfcbd1fce3179f185da7470f41ce39d001ee1af", size = 3979960, upload-time = "2025-09-24T13:50:51.74Z" }, - { url = "https://files.pythonhosted.org/packages/4d/04/167f096386120f692cc4ca02f75a17b961858997a95e67a3cb6a7bbd6b53/shapely-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc3487447a43d42adcdf52d7ac73804f2312cbfa5d433a7d2c506dcab0033dfd", size = 4142851, upload-time = "2025-09-24T13:50:53.49Z" }, - { url = "https://files.pythonhosted.org/packages/48/74/fb402c5a6235d1c65a97348b48cdedb75fb19eca2b1d66d04969fc1c6091/shapely-2.1.2-cp313-cp313-win32.whl", hash = "sha256:9c3a3c648aedc9f99c09263b39f2d8252f199cb3ac154fadc173283d7d111350", size = 1541890, upload-time = "2025-09-24T13:50:55.337Z" }, - { url = "https://files.pythonhosted.org/packages/41/47/3647fe7ad990af60ad98b889657a976042c9988c2807cf322a9d6685f462/shapely-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:ca2591bff6645c216695bdf1614fca9c82ea1144d4a7591a466fef64f28f0715", size = 1722151, upload-time = "2025-09-24T13:50:57.153Z" }, - { url = "https://files.pythonhosted.org/packages/3c/49/63953754faa51ffe7d8189bfbe9ca34def29f8c0e34c67cbe2a2795f269d/shapely-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2d93d23bdd2ed9dc157b46bc2f19b7da143ca8714464249bef6771c679d5ff40", size = 1834130, upload-time = "2025-09-24T13:50:58.49Z" }, - { url = "https://files.pythonhosted.org/packages/7f/ee/dce001c1984052970ff60eb4727164892fb2d08052c575042a47f5a9e88f/shapely-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01d0d304b25634d60bd7cf291828119ab55a3bab87dc4af1e44b07fb225f188b", size = 1642802, upload-time = "2025-09-24T13:50:59.871Z" }, - { url = "https://files.pythonhosted.org/packages/da/e7/fc4e9a19929522877fa602f705706b96e78376afb7fad09cad5b9af1553c/shapely-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8d8382dd120d64b03698b7298b89611a6ea6f55ada9d39942838b79c9bc89801", size = 3018460, upload-time = "2025-09-24T13:51:02.08Z" }, - { url = "https://files.pythonhosted.org/packages/a1/18/7519a25db21847b525696883ddc8e6a0ecaa36159ea88e0fef11466384d0/shapely-2.1.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:19efa3611eef966e776183e338b2d7ea43569ae99ab34f8d17c2c054d3205cc0", size = 3095223, upload-time = "2025-09-24T13:51:04.472Z" }, - { url = "https://files.pythonhosted.org/packages/48/de/b59a620b1f3a129c3fecc2737104a0a7e04e79335bd3b0a1f1609744cf17/shapely-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:346ec0c1a0fcd32f57f00e4134d1200e14bf3f5ae12af87ba83ca275c502498c", size = 4030760, upload-time = "2025-09-24T13:51:06.455Z" }, - { url = "https://files.pythonhosted.org/packages/96/b3/c6655ee7232b417562bae192ae0d3ceaadb1cc0ffc2088a2ddf415456cc2/shapely-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6305993a35989391bd3476ee538a5c9a845861462327efe00dd11a5c8c709a99", size = 4170078, upload-time = "2025-09-24T13:51:08.584Z" }, - { url = "https://files.pythonhosted.org/packages/a0/8e/605c76808d73503c9333af8f6cbe7e1354d2d238bda5f88eea36bfe0f42a/shapely-2.1.2-cp313-cp313t-win32.whl", hash = "sha256:c8876673449f3401f278c86eb33224c5764582f72b653a415d0e6672fde887bf", size = 1559178, upload-time = "2025-09-24T13:51:10.73Z" }, - { url = "https://files.pythonhosted.org/packages/36/f7/d317eb232352a1f1444d11002d477e54514a4a6045536d49d0c59783c0da/shapely-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:4a44bc62a10d84c11a7a3d7c1c4fe857f7477c3506e24c9062da0db0ae0c449c", size = 1739756, upload-time = "2025-09-24T13:51:12.105Z" }, ] [[package]] @@ -4762,10 +4155,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/bc/446357c229692f51885f4c5f3894af3aff37ccaafebc4f24066c2b9b5b80/slangpy-0.42.0-cp312-cp312-manylinux_2_34_aarch64.whl", hash = "sha256:4e2f76d3709ee2152f54620a27ab45fbb459db392c03b2d492382f675bfccc2a", size = 82123007, upload-time = "2026-05-28T22:40:30.815Z" }, { url = "https://files.pythonhosted.org/packages/cd/d1/d9822a2c38dc583850608650e6d3304f6cf6e03a1335ba21078440790a68/slangpy-0.42.0-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:8dbc36b32c1c1fc8dffa70d63ddf0ab538c473ba072f802c8d7730530bea7a7c", size = 83670144, upload-time = "2026-05-28T22:40:37.618Z" }, { url = "https://files.pythonhosted.org/packages/b7/b8/94d067236898b5bec62a7ae682c296ba2310ebf9aa894bb32de9a4cfd478/slangpy-0.42.0-cp312-cp312-win_amd64.whl", hash = "sha256:82e212b7b195aeafb23ab43a43069980e3be0952f91869b65b5b4288bad9129d", size = 78280523, upload-time = "2026-05-28T22:40:44.154Z" }, - { url = "https://files.pythonhosted.org/packages/15/3c/44f8d8c20b83e10dc2dffc45d9a93adc3e6d037ec1b72756e8dfeec9cd7d/slangpy-0.42.0-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:c070359285acf177fb7f4ca6cbd29c6c7f37a9b036f42f436353aa773c08e665", size = 37738648, upload-time = "2026-05-28T22:40:48.454Z" }, - { url = "https://files.pythonhosted.org/packages/da/23/6427597cd186477c6020124883eb1f5d7e15ae4e46bb7ee60125fbc30979/slangpy-0.42.0-cp313-cp313-manylinux_2_34_aarch64.whl", hash = "sha256:07a8a5bb32e5644ea0226e40db5c773f1218c901883e70d2f7b20ceef433f724", size = 82125926, upload-time = "2026-05-28T22:40:54.468Z" }, - { url = "https://files.pythonhosted.org/packages/9f/b1/cbde95d04d94d2c3f0241b54354f5e792e64a1c1643b4424a2ccb80f2788/slangpy-0.42.0-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:4c26022765a414925efde972e26ca3f99458435879c1133c6bbe2e962a6c2c16", size = 83670289, upload-time = "2026-05-28T22:41:00.771Z" }, - { url = "https://files.pythonhosted.org/packages/83/9e/e57c578d8a576ddfea74f2f11d561d2ceca6b4fd92fa1341dc3d93a6b7f8/slangpy-0.42.0-cp313-cp313-win_amd64.whl", hash = "sha256:dd0a32184bce8a8c507adbfedf5b49694f55543223c019a3c540ffe4a7df1730", size = 78280954, upload-time = "2026-05-28T22:41:06.903Z" }, ] [[package]] @@ -5050,7 +4439,7 @@ version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, - { name = "typing-extensions", marker = "python_full_version < '3.13' or (extra == 'extra-11-flashdreams-dev' and extra == 'group-11-flashdreams-cuda12') or (extra == 'group-11-flashdreams-cuda12' and extra == 'group-11-flashdreams-cuda13')" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } wheels = [ @@ -5123,15 +4512,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, - { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, - { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, - { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, - { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, - { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, - { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, - { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, - { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, - { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] @@ -5171,12 +4551,6 @@ wheels = [ { url = "https://download-r2.pytorch.org/whl/cu128/torch-2.11.0%2Bcu128-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:9c8f38efee365cb9d334de8a83ce52fc7e5fc9e5a7b0853285efa1b69e00b0f2", upload-time = "2026-04-27T17:41:30Z" }, { url = "https://download-r2.pytorch.org/whl/cu128/torch-2.11.0%2Bcu128-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:d252cf975fb18c94a85336323ad425f473df56dab35a44b00399bd70c7a3b997", upload-time = "2026-04-27T17:42:06Z" }, { url = "https://download-r2.pytorch.org/whl/cu128/torch-2.11.0%2Bcu128-cp312-cp312-win_amd64.whl", hash = "sha256:7c78215c3af4f62e63f2b2e360f1722fc719b0853c7ac22666483d9810613a4c", upload-time = "2026-04-27T17:43:49Z" }, - { url = "https://download-r2.pytorch.org/whl/cu128/torch-2.11.0%2Bcu128-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:7db3580106bba044da5b8950f3fb8fe5f31999eaab3f6a3aa2ac5d202c3684d2", upload-time = "2026-04-27T17:45:35Z" }, - { url = "https://download-r2.pytorch.org/whl/cu128/torch-2.11.0%2Bcu128-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:db964b33c55035a72ab3e2162287af8f1cc276039c65d015740cc88c26dcedf7", upload-time = "2026-04-27T17:46:18Z" }, - { url = "https://download-r2.pytorch.org/whl/cu128/torch-2.11.0%2Bcu128-cp313-cp313-win_amd64.whl", hash = "sha256:6f367e62fd81b75cdf23ca4b75ced834d2db2cf98d1588ac935bde345de9de23", upload-time = "2026-04-27T17:48:09Z" }, - { url = "https://download-r2.pytorch.org/whl/cu128/torch-2.11.0%2Bcu128-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:cd1cf1005c5fe419194ee294b7b584ba5ad0f2fb1778b3fe5a7b9c3f4617ddbc", upload-time = "2026-04-27T17:50:01Z" }, - { url = "https://download-r2.pytorch.org/whl/cu128/torch-2.11.0%2Bcu128-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:74b628dbc71603977b09f4e140792c6e997081a35ef3421555f3f6e201b81210", upload-time = "2026-04-27T17:50:42Z" }, - { url = "https://download-r2.pytorch.org/whl/cu128/torch-2.11.0%2Bcu128-cp313-cp313t-win_amd64.whl", hash = "sha256:c2a5984deba8e001d166bf9cb83b8351f63a28b009e1a2fa0e4bbf08c90b259b", upload-time = "2026-04-27T17:52:32Z" }, ] [[package]] @@ -5219,10 +4593,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/00/4210d76ca7424981f04033ebe7e48816ab83287a62538747a58825db770c/torch-2.12.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:2de4e19b88a481482c6c75291f2d6a52eda3ce51f311b29aa9b68499c830c07c", size = 426382721, upload-time = "2026-06-17T21:06:41.842Z" }, { url = "https://files.pythonhosted.org/packages/76/1f/bc9f5a5aa569307076365f25afcebacb22e9c754b1bcfbaaa146627c7fda/torch-2.12.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:649e4ced014ba646f76f8cb9c9726735a6323eb321b7919f942790a923f90921", size = 532261322, upload-time = "2026-06-17T21:06:06.673Z" }, { url = "https://files.pythonhosted.org/packages/9e/49/c549461daa008159d006a76a991fbc2f26fa8bac27a4030c858463dcb20f/torch-2.12.1-cp312-cp312-win_amd64.whl", hash = "sha256:e86550597877fb272ddc52db2f85b82cb601ea7bd932576a0340152cae2200b3", size = 122988095, upload-time = "2026-06-17T21:07:44.9Z" }, - { url = "https://files.pythonhosted.org/packages/ff/4a/0300261818e1560d72cc160ac826005507e8b7ca0a35788b591436d05b4a/torch-2.12.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:c75e93173c700bccd6bfcc4a9d19ce242ab6dacd1f1781483027a16239b9e650", size = 87992358, upload-time = "2026-06-17T21:07:40.299Z" }, - { url = "https://files.pythonhosted.org/packages/30/a7/874a5ca05e8f159211dca7921060f7057acc1adb26431e119fd150623efc/torch-2.12.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:fcb61ccd20784b62bdd78ec84238a5cfb383b4994902e03bac95505ab360884c", size = 426386134, upload-time = "2026-06-17T21:07:31.481Z" }, - { url = "https://files.pythonhosted.org/packages/e1/75/20bb8fe9c1ad6538cce8cd0391b51927ae5af0b17ed1eab44b8824465dc1/torch-2.12.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:f4afc8083dff08719edbea346644476e3cec0cf40ebe256be0ee5d5b7c7e8c0d", size = 532268019, upload-time = "2026-06-17T21:05:37.925Z" }, - { url = "https://files.pythonhosted.org/packages/d1/fa/824ddb662af55b2eabc0dbb7b57c7c0b1bcd93693754a2b8509ec4d16490/torch-2.12.1-cp313-cp313-win_amd64.whl", hash = "sha256:f92609e3b3ce72f25e2eb780d043ced2480c1a86c47c852604fc7a9108648386", size = 122987777, upload-time = "2026-06-17T21:07:09.49Z" }, ] [[package]] @@ -5255,9 +4625,6 @@ wheels = [ { url = "https://download-r2.pytorch.org/whl/cu130/torch-2.12.1%2Bcu130-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:2d3e87d41ffb340ddf8c99e2a690a29feea9f5271459dd57621cd11317a434f2", upload-time = "2026-06-18T02:38:10Z" }, { url = "https://download-r2.pytorch.org/whl/cu130/torch-2.12.1%2Bcu130-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:4bafc356fbb622e2756179406825c3a56c17b401196435a1487c5b40c657706c", upload-time = "2026-06-18T02:38:36Z" }, { url = "https://download-r2.pytorch.org/whl/cu130/torch-2.12.1%2Bcu130-cp312-cp312-win_amd64.whl", hash = "sha256:52c5da6a0898d5d3473c02bd304b7a3bc0b72e351c6f3bfa0783e45ef9f4cd61", upload-time = "2026-06-18T02:39:58Z" }, - { url = "https://download-r2.pytorch.org/whl/cu130/torch-2.12.1%2Bcu130-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:2a2bb858316615b90b14ff27d0c732d5af85d066f5ee7bf81fad2c9215839be7", upload-time = "2026-06-18T02:41:12Z" }, - { url = "https://download-r2.pytorch.org/whl/cu130/torch-2.12.1%2Bcu130-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:d5e1840442d2182957b3d2f778cc325c90fa5cb42aa8b1ac949f029e9bdd7f06", upload-time = "2026-06-18T02:41:44Z" }, - { url = "https://download-r2.pytorch.org/whl/cu130/torch-2.12.1%2Bcu130-cp313-cp313-win_amd64.whl", hash = "sha256:76dd848312a40d29499614b714a4318841734ff309ad922f2365868395b6b054", upload-time = "2026-06-18T02:43:02Z" }, ] [[package]] @@ -5285,12 +4652,6 @@ wheels = [ { url = "https://download-r2.pytorch.org/whl/cu128/torchvision-0.26.0%2Bcu128-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:63e35234aed13b6edda37056f417b5c281249669db631e706811917af36b21d7", upload-time = "2026-04-09T23:21:35Z" }, { url = "https://download-r2.pytorch.org/whl/cu128/torchvision-0.26.0%2Bcu128-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:ccf26b4b659cfce6f2208cb8326071d51c70219a34856dfdf468d1e19af52c0d", upload-time = "2026-03-23T15:36:22Z" }, { url = "https://download-r2.pytorch.org/whl/cu128/torchvision-0.26.0%2Bcu128-cp312-cp312-win_amd64.whl", hash = "sha256:8c0d1c4fbb2c9a4d5d41d0aaa87da20e525bcb2a154ce405725b0be59456804b", upload-time = "2026-04-09T23:21:36Z" }, - { url = "https://download-r2.pytorch.org/whl/cu128/torchvision-0.26.0%2Bcu128-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:c4a9cacd521f2a4df0bcd9d8e96704771b928f478f1f3067e4085bb53a1da298", upload-time = "2026-04-09T23:21:37Z" }, - { url = "https://download-r2.pytorch.org/whl/cu128/torchvision-0.26.0%2Bcu128-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:cb1f6184a7ba30fba40580e1a01a6604a86c55e79fdda187f40116ee680441ec", upload-time = "2026-03-23T15:36:22Z" }, - { url = "https://download-r2.pytorch.org/whl/cu128/torchvision-0.26.0%2Bcu128-cp313-cp313-win_amd64.whl", hash = "sha256:0232cb219927a52d6c98ff202f32d1cdf4802c2195a85fc1f1a0c1b0b4983a4d", upload-time = "2026-04-09T23:21:38Z" }, - { url = "https://download-r2.pytorch.org/whl/cu128/torchvision-0.26.0%2Bcu128-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:e594732552a8c2fee2ace9c6475c6c6904fc44ccca622ee6765a89a045416a44", upload-time = "2026-04-09T23:21:38Z" }, - { url = "https://download-r2.pytorch.org/whl/cu128/torchvision-0.26.0%2Bcu128-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:6168abc019803ac9e97efce27eafd2fdb33db04dcc54a86039537729e5047b29", upload-time = "2026-03-23T15:36:23Z" }, - { url = "https://download-r2.pytorch.org/whl/cu128/torchvision-0.26.0%2Bcu128-cp313-cp313t-win_amd64.whl", hash = "sha256:367d42ea703844ecdb516e9d5eb09929012a58705d2622cf4e9e3c37f278cb85", upload-time = "2026-04-09T23:21:39Z" }, ] [[package]] @@ -5321,10 +4682,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/43/42/103fa8f9366cfd1329fe449d6b1a25a640c0c17862ed48f21c4af94af322/torchvision-0.27.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:9edfb5a549fc2f30ccadb24eca907901e92e426c91a59316be6703a9360e5098", size = 7830902, upload-time = "2026-06-17T21:09:29.739Z" }, { url = "https://files.pythonhosted.org/packages/97/70/fa6052a42110a3657fc94073648da6171220469f4bf9f27e6a0b9378075c/torchvision-0.27.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:ae3d49e57c4abc8eafc1a1971f80fc4948a6268fa69340737ca4466936def080", size = 7664211, upload-time = "2026-06-17T21:09:17.206Z" }, { url = "https://files.pythonhosted.org/packages/d0/95/27aca854da7e536a339f46bab1ef67823ac2ac97c59ab2b3203b373d46cf/torchvision-0.27.1-cp312-cp312-win_amd64.whl", hash = "sha256:0b6e3aa98b7433506bbce1d0d05cb13ec787fc6eb8c5fbd998b26ce05f047543", size = 4079076, upload-time = "2026-06-17T21:09:15.907Z" }, - { url = "https://files.pythonhosted.org/packages/32/bb/b21e0f598ca191bb2a9e9fda2fee37c06ad113313b43c6769dbefa0e921d/torchvision-0.27.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:d60311a6d08df905f9656a3a312f0a8f55f0d46321bc737bad30a8dec9644309", size = 1852110, upload-time = "2026-06-17T21:09:22.577Z" }, - { url = "https://files.pythonhosted.org/packages/2f/90/d61171daa5d6cd5f9315f84f9ef947b047a9fdf283d53241327045a8dd6d/torchvision-0.27.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:08aa33bc8e062cca32aefa90ac714916c5a855cbe1ab4c6148fc0453eb40ca5a", size = 7789476, upload-time = "2026-06-17T21:09:13.105Z" }, - { url = "https://files.pythonhosted.org/packages/b8/dc/b21d7801562c23a770e7037989814582f22ca4db479204293561de4b62e8/torchvision-0.27.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:916448be4b19676677b0dbf47d08f68b7955ea0abec7fc79340c31e217a824ba", size = 7664256, upload-time = "2026-06-17T21:09:07.549Z" }, - { url = "https://files.pythonhosted.org/packages/b9/b3/4386976ff77eda55f0aed504a288564f3ff8d170b6db49ee22e172eddfac/torchvision-0.27.1-cp313-cp313-win_amd64.whl", hash = "sha256:18bc906235bfa901c135acd239f05b8c8ab90d502830cf1ef2cba3301e1f8a23", size = 4150710, upload-time = "2026-06-17T21:09:14.457Z" }, ] [[package]] @@ -5352,9 +4709,6 @@ wheels = [ { url = "https://download-r2.pytorch.org/whl/cu130/torchvision-0.27.1%2Bcu130-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:d2b15508b03a8949d8ff1e67a61342e9191c3fde2bf750996b24b3d472bcd7cb", upload-time = "2026-06-17T15:44:44Z" }, { url = "https://download-r2.pytorch.org/whl/cu130/torchvision-0.27.1%2Bcu130-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:abbfc724597c16da177002a16979aa8c44c4898c97bcb731b647cc57507f5772", upload-time = "2026-06-17T15:44:44Z" }, { url = "https://download-r2.pytorch.org/whl/cu130/torchvision-0.27.1%2Bcu130-cp312-cp312-win_amd64.whl", hash = "sha256:1bf254c102bfaf97d3e7878b76b68999bcd4dcd4303c109e76b4fbf9b15265c5", upload-time = "2026-06-18T04:00:18Z" }, - { url = "https://download-r2.pytorch.org/whl/cu130/torchvision-0.27.1%2Bcu130-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:e1d81f9e5f99a73e239e143b73d143181ab2e71a8c8ef79fa90e908cc356218c", upload-time = "2026-06-17T15:44:44Z" }, - { url = "https://download-r2.pytorch.org/whl/cu130/torchvision-0.27.1%2Bcu130-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:f9f008d4b1b2f013eaf7bec1a9ff221263581d77668d4e1e0e9c3ed351d56465", upload-time = "2026-06-17T15:44:44Z" }, - { url = "https://download-r2.pytorch.org/whl/cu130/torchvision-0.27.1%2Bcu130-cp313-cp313-win_amd64.whl", hash = "sha256:04f6bda2ab9589ad0a63d44fe6e1dc3ff86f379c100a491b6daba9ec9ec72499", upload-time = "2026-06-18T04:00:19Z" }, ] [[package]] @@ -5460,10 +4814,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/12/b05ba554d2c623bffa59922b94b0775673de251f468a9609bc9e45de95e9/triton-3.6.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8e323d608e3a9bfcc2d9efcc90ceefb764a82b99dea12a86d643c72539ad5d3", size = 188214640, upload-time = "2026-01-20T16:00:35.869Z" }, { url = "https://files.pythonhosted.org/packages/17/5d/08201db32823bdf77a0e2b9039540080b2e5c23a20706ddba942924ebcd6/triton-3.6.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:374f52c11a711fd062b4bfbb201fd9ac0a5febd28a96fb41b4a0f51dde3157f4", size = 176128243, upload-time = "2026-01-20T16:16:07.857Z" }, { url = "https://files.pythonhosted.org/packages/ab/a8/cdf8b3e4c98132f965f88c2313a4b493266832ad47fb52f23d14d4f86bb5/triton-3.6.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74caf5e34b66d9f3a429af689c1c7128daba1d8208df60e81106b115c00d6fca", size = 188266850, upload-time = "2026-01-20T16:00:43.041Z" }, - { url = "https://files.pythonhosted.org/packages/3c/12/34d71b350e89a204c2c7777a9bba0dcf2f19a5bfdd70b57c4dbc5ffd7154/triton-3.6.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:448e02fe6dc898e9e5aa89cf0ee5c371e99df5aa5e8ad976a80b93334f3494fd", size = 176133521, upload-time = "2026-01-20T16:16:13.321Z" }, - { url = "https://files.pythonhosted.org/packages/f9/0b/37d991d8c130ce81a8728ae3c25b6e60935838e9be1b58791f5997b24a54/triton-3.6.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10c7f76c6e72d2ef08df639e3d0d30729112f47a56b0c81672edc05ee5116ac9", size = 188289450, upload-time = "2026-01-20T16:00:49.136Z" }, - { url = "https://files.pythonhosted.org/packages/ce/4e/41b0c8033b503fd3cfcd12392cdd256945026a91ff02452bef40ec34bee7/triton-3.6.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1722e172d34e32abc3eb7711d0025bb69d7959ebea84e3b7f7a341cd7ed694d6", size = 176276087, upload-time = "2026-01-20T16:16:18.989Z" }, - { url = "https://files.pythonhosted.org/packages/35/f8/9c66bfc55361ec6d0e4040a0337fb5924ceb23de4648b8a81ae9d33b2b38/triton-3.6.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d002e07d7180fd65e622134fbd980c9a3d4211fb85224b56a0a0efbd422ab72f", size = 188400296, upload-time = "2026-01-20T16:00:56.042Z" }, ] [[package]] @@ -5482,8 +4832,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cd/5e/fce69606f7f240297f163e25539906732b199530d486ce67ae319877e821/triton-3.7.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6744957e9fd610a29680ec2346057d0c86948ed3812468670719f391e94b44a5", size = 197701306, upload-time = "2026-06-17T19:53:13.673Z" }, { url = "https://files.pythonhosted.org/packages/94/fa/f856e24deb462d5f18bd4b5a746957862ab9b6ee5834bda60605ec348366/triton-3.7.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9497f2e696ee368862a181a90b2dcc03ca978cc4f602abd67c7d81022a6988e1", size = 184692359, upload-time = "2026-06-17T20:03:48.288Z" }, { url = "https://files.pythonhosted.org/packages/c4/6f/fb96d15db6f36d6eae4cafb998c2e0353bf59d7c4ea1662d7497f269134a/triton-3.7.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e40869937a68206ec70d7f25bb7ec6433cb083f9135e1f36dbd318dc449a728", size = 197719725, upload-time = "2026-06-17T19:53:20.419Z" }, - { url = "https://files.pythonhosted.org/packages/00/42/c5089d4d9327fcd1e862c599cc2927f39418f84dd11a84cb2ccff9d4787a/triton-3.7.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cdbfc09d9ec58bc5e68321525653220de7515c199e7a8097a97c85e62b52cd0a", size = 184694629, upload-time = "2026-06-17T20:03:53.444Z" }, - { url = "https://files.pythonhosted.org/packages/07/42/2c3ac59253ae8892b6f307875263dd23dc875cdf732d3aea40d6d41fb7cb/triton-3.7.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58c0e131da05134a2a4788ccbcc0c1105cf0f54c8e98f19e34cd465396dc15eb", size = 197729241, upload-time = "2026-06-17T19:53:27.801Z" }, ] [[package]] @@ -5494,7 +4842,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4d/b4/c50a22dd2d493a3e8e78744cbdcb29932f367b68999c8e08874b94aebd3a/triton_windows-3.7.1.post27-cp310-cp310-win_amd64.whl", hash = "sha256:01ede775b102c91acc89fc6946b946451f966cb64856c650d244d37ea3bbdee5", size = 49678783, upload-time = "2026-06-21T16:47:51.235Z" }, { url = "https://files.pythonhosted.org/packages/26/f5/0f5eaf48abc0c9900f600dbdfa8139e678aa7d47dc1da51b0541979e96df/triton_windows-3.7.1.post27-cp311-cp311-win_amd64.whl", hash = "sha256:b739bd7d39f919280294d8af172a90aa2f17a4377bfbca2ea30a8afae61d5eaa", size = 49679173, upload-time = "2026-06-21T16:48:02.395Z" }, { url = "https://files.pythonhosted.org/packages/76/30/325b420efd0047e119679c646a9a410db216069800ec009fae3da26c69a3/triton_windows-3.7.1.post27-cp312-cp312-win_amd64.whl", hash = "sha256:f5406230d7dbf6965bc4051fcad27b81c39ba4a4bfde06f494dd7ff4eb325a9e", size = 49683004, upload-time = "2026-06-21T16:48:14.02Z" }, - { url = "https://files.pythonhosted.org/packages/ec/28/f0b2801c2cfd79be5878bf429e82b5da00fb78f046a9c21ba946681cc467/triton_windows-3.7.1.post27-cp313-cp313-win_amd64.whl", hash = "sha256:e8ed215c02afc85a81f0097196f8bebdf6f68085f1bc0fe8771b9a74783f15ab", size = 49684257, upload-time = "2026-06-21T16:48:24.901Z" }, ] [[package]] @@ -5682,31 +5029,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" }, { url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" }, { url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" }, - { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" }, - { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" }, - { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" }, - { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" }, - { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" }, - { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" }, - { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" }, - { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" }, - { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" }, - { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" }, - { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" }, - { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" }, - { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" }, - { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" }, - { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" }, - { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" }, - { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" }, - { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" }, - { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" }, - { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" }, - { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" }, - { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" }, - { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" }, - { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" }, - { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" }, { url = "https://files.pythonhosted.org/packages/23/f4/7513ef1e85fc4c6331b59479d6d72661fc391fbe543678052ac72c8b6c19/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4674d49eb94706dfe666c069fc0a1b646ffcf920473492e209f6d5f60d3f0cc2", size = 403050, upload-time = "2026-05-18T04:30:36.753Z" }, { url = "https://files.pythonhosted.org/packages/27/0b/a54103cfd732bb703c7a749222011a0483ef3705948dae3b203158601119/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:094b9b70103d4e963499bdea001ee3c2697b144cd9ae6218a62c0f89ec9e31db", size = 396629, upload-time = "2026-05-18T04:32:03.268Z" }, { url = "https://files.pythonhosted.org/packages/5e/2c/73f31a3b893886206c3f54d73e8ad8dee58cdb2f69ad2622e0a8a9e07f4e/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0ef001f8c25ad0fa9529f914c1600647ecd0f542d11c19b7894768c67b6acb7", size = 457318, upload-time = "2026-05-18T04:31:01.932Z" }, @@ -5755,15 +5077,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, - { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, - { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, - { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, - { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, - { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, - { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, - { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, - { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, - { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, @@ -5843,23 +5156,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/48/2d/1c8d89c7c5f9cad9fb2902445d94e2ab1d7aa35de029afbb8ae95c42d00f/yarl-1.24.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e30dd55825dc554ec5b66a94953b8eda8745926514c5089dfcacecb9c99b5bd1", size = 105719, upload-time = "2026-05-19T21:29:18.367Z" }, { url = "https://files.pythonhosted.org/packages/a7/25/722e3b93bd687009afb2d59a35e13d30ddd8f80571445bb0c4e4ce26ec66/yarl-1.24.2-cp312-cp312-win_amd64.whl", hash = "sha256:7dafe10c12ddd4d120d528c4b5599c953bd7b12845347d507b95451195bb6cad", size = 92901, upload-time = "2026-05-19T21:29:20.014Z" }, { url = "https://files.pythonhosted.org/packages/39/47/4486ccfb674c04854a1ef8aa77868b6a6f765feaf69633409d7ca4f02cb8/yarl-1.24.2-cp312-cp312-win_arm64.whl", hash = "sha256:044a09d8401fcf8681977faef6d286b8ade1e2d2e9dceda175d1cfa5ca496f30", size = 87229, upload-time = "2026-05-19T21:29:22.1Z" }, - { url = "https://files.pythonhosted.org/packages/82/62/fcf0ce677f17e5c471c06311dd25964be38a4c586993632910d2e75278bc/yarl-1.24.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:491ac9141decf49ee8030199e1ee251cdff0e131f25678817ff6aa5f837a3536", size = 128978, upload-time = "2026-05-19T21:29:23.83Z" }, - { url = "https://files.pythonhosted.org/packages/d3/58/8e63299bb71ed61a834121d9d3fe6c9fcf2a6a5d09754ff4f20f2d20baf5/yarl-1.24.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e89418f65eda18f99030386305bd44d7d504e328a7945db1ead514fbe03a0607", size = 91733, upload-time = "2026-05-19T21:29:25.375Z" }, - { url = "https://files.pythonhosted.org/packages/c1/24/16748d5dab6daec8b0ed81ccec639a1cded0f18dcc62a4f696b4fe366c37/yarl-1.24.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cdfcce633b4a4bb8281913c57fcafd4b5933fbc19111a5e3930bbd299d6102f1", size = 91113, upload-time = "2026-05-19T21:29:26.928Z" }, - { url = "https://files.pythonhosted.org/packages/1b/66/b63fff7b71211e866624b21432d5943cbb633eb0c2872d9ee3070648f22c/yarl-1.24.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:863297ddede92ee49024e9a9b11ecb59f310ca85b60d8537f56bed9bbb5b1986", size = 103899, upload-time = "2026-05-19T21:29:28.842Z" }, - { url = "https://files.pythonhosted.org/packages/9d/ac/ba1974b8533909636f7733fe86cf677e3619527c3c2fa913e0ea89c48757/yarl-1.24.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:374423f70754a2c96942ede36a29d37dc6b0cb8f92f8d009ddf3ed78d3da5488", size = 97862, upload-time = "2026-05-19T21:29:31.086Z" }, - { url = "https://files.pythonhosted.org/packages/1b/a5/123ac993b5c2ba6f554a140305620cb8f150fa543711bbc49be3ec0a65a4/yarl-1.24.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33a29b5d00ccbf3219bb3e351d7875739c19481e030779f48cc46a7a71681a9b", size = 111060, upload-time = "2026-05-19T21:29:32.657Z" }, - { url = "https://files.pythonhosted.org/packages/23/37/c472d3af3509688392134a88a825276770a187f1daa4de3f6dc0a327a751/yarl-1.24.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a9532c57211730c515341af11fef6e9b61d157487272a096d0c04da445642592", size = 110613, upload-time = "2026-05-19T21:29:34.379Z" }, - { url = "https://files.pythonhosted.org/packages/df/88/09c28dad91e662ccfaa1b78f1c57badde74fc9d0b23e74aef644750ecd73/yarl-1.24.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:91e72cf093fd833483a97ee648e0c053c7c629f51ff4a0e7edd84f806b0c5617", size = 107012, upload-time = "2026-05-19T21:29:36.216Z" }, - { url = "https://files.pythonhosted.org/packages/07/ab/9d4f69d571a94f4d112fa7e2e007200f5a54d319f58c82ac7b7baa61f5c6/yarl-1.24.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b3177bc0a768ef3bacceb4f272632990b7bea352f1b2f1eee9d6d6ff16516f92", size = 105887, upload-time = "2026-05-19T21:29:38.746Z" }, - { url = "https://files.pythonhosted.org/packages/8e/9a/000b2b66c0d772a499fc531d21dab92dfeb73b640a12eed6ba89f49bb2d0/yarl-1.24.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e196952aacaf3b232e265ff02980b64d483dc0972bd49bcb061171ff22ac203a", size = 103620, upload-time = "2026-05-19T21:29:40.368Z" }, - { url = "https://files.pythonhosted.org/packages/41/7c/7c1050f73450fbdaa3f0c72017059f00ce5e13366692f3dba25275a1083d/yarl-1.24.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:204e7a61ce99919c0de1bf904ab5d7aa188a129ea8f690a8f76cfb6e2844dc44", size = 100599, upload-time = "2026-05-19T21:29:42.66Z" }, - { url = "https://files.pythonhosted.org/packages/ec/b1/29e5756b3926705f5f6089bd5b9f50a56eaac550da6e260bf713ead44d04/yarl-1.24.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b156914620f0b9d78dc1adb3751141daee561cfec796088abb89ed49d220f1a", size = 110604, upload-time = "2026-05-19T21:29:44.632Z" }, - { url = "https://files.pythonhosted.org/packages/a3/4b/8415bc96e9b150cde942fbac9a8182985e58f40ce5c54c34ed015407d3ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8372a2b976cf70654b2be6619ab6068acabb35f724c0fda7b277fbf53d66a5cf", size = 105161, upload-time = "2026-05-19T21:29:46.755Z" }, - { url = "https://files.pythonhosted.org/packages/8b/d4/cde059abfa229553b7298a2eadde2752e723d50aeedaef86ce59da2718ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f9a1e9b622ca284143aab5d885848686dcd85453bb1ca9abcdb7503e64dc0056", size = 110619, upload-time = "2026-05-19T21:29:48.972Z" }, - { url = "https://files.pythonhosted.org/packages/e7/2c/d6a6c9a61549f7b6c7e6dc6937d195bcf069582b47b7200dcd0e7b256acf/yarl-1.24.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:810e19b685c8c3c5862f6a38160a1f4e4c0916c9390024ec347b6157a45a0992", size = 107362, upload-time = "2026-05-19T21:29:51Z" }, - { url = "https://files.pythonhosted.org/packages/92/dd/3ae5fe417e9d1c353a548553326eb9935e76b6b727161563b424cc296df3/yarl-1.24.2-cp313-cp313-win_amd64.whl", hash = "sha256:7d37fb7c38f2b6edab0f845c4f85148d4c44204f52bc127021bd2bc9fdbf1656", size = 92667, upload-time = "2026-05-19T21:29:52.743Z" }, - { url = "https://files.pythonhosted.org/packages/10/cc/a7beb239f78f27fca1b053c8e8595e4179c02e62249b4687ec218c370c50/yarl-1.24.2-cp313-cp313-win_arm64.whl", hash = "sha256:1e831894be7c2954240e49791fa4b50c05a0dc881de2552cfe3ffd8631c7f461", size = 87069, upload-time = "2026-05-19T21:29:54.442Z" }, { url = "https://files.pythonhosted.org/packages/fd/4d/4b880086bd0d3e034d25647be1d830afc3e3f610e98c4ab3490af6b1b6d5/yarl-1.24.2-py3-none-any.whl", hash = "sha256:2783d9226db8797636cd6896e4de81feed252d1db72265686c9558d97a4d94b9", size = 53576, upload-time = "2026-05-19T21:31:03.909Z" }, ] From 3de1996b788f7e6d9d5ddff2b471933185add11a Mon Sep 17 00:00:00 2001 From: Fangjun Zhou Date: Wed, 22 Jul 2026 16:39:21 -0700 Subject: [PATCH 16/30] Add local nvim config for pyright lsp --- .nvim.lua | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .nvim.lua diff --git a/.nvim.lua b/.nvim.lua new file mode 100644 index 000000000..ec0ca6948 --- /dev/null +++ b/.nvim.lua @@ -0,0 +1,3 @@ +vim.lsp.config("pyright", { + root_markers = { ".git" }, +}) From 2e82c2a1d05ffdea3b13c41f5336cdf41d05a580 Mon Sep 17 00:00:00 2001 From: Fangjun Zhou Date: Thu, 6 Aug 2026 11:51:16 -0700 Subject: [PATCH 17/30] Refactor of flashdreams.runtime --- docs/inference_runtime_api_design.md | 764 ------------------ ...inference_runtime_inputs_implementation.md | 292 ------- ...ence_runtime_supported_inputs_inventory.md | 354 -------- flashdreams/flashdreams/runtime/README.md | 0 flashdreams/flashdreams/runtime/__init__.py | 117 --- flashdreams/flashdreams/runtime/_utils.py | 17 - flashdreams/flashdreams/runtime/canonical.py | 387 --------- flashdreams/flashdreams/runtime/config.py | 76 -- .../flashdreams/runtime/demo/__init__.py | 31 - flashdreams/flashdreams/runtime/demo/app.py | 36 - .../flashdreams/runtime/demo/outputs.py | 45 -- .../flashdreams/runtime/demo/replay.py | 93 --- flashdreams/flashdreams/runtime/demo/spec.py | 176 ---- .../flashdreams/runtime/demo/webrtc.py | 274 ------- .../flashdreams/runtime/inference_session.py | 201 ----- flashdreams/flashdreams/runtime/inputs.py | 475 ----------- flashdreams/flashdreams/runtime/interfaces.py | 91 --- flashdreams/flashdreams/runtime/mapping.py | 396 --------- flashdreams/flashdreams/runtime/metrics.py | 124 --- flashdreams/flashdreams/runtime/output.py | 78 -- flashdreams/flashdreams/runtime/runner.py | 199 ----- flashdreams/flashdreams/runtime/types.py | 60 -- .../flashdreams/runtime/video_output.py | 157 ---- .../tests/test_inference_runtime_api.py | 281 ------- flashdreams/tests/test_inference_session.py | 198 ----- flashdreams/tests/test_runtime_canonical.py | 591 -------------- flashdreams/tests/test_runtime_demo_api.py | 458 ----------- .../tests/test_runtime_input_mapping.py | 510 ------------ flashdreams/tests/test_runtime_runner.py | 660 --------------- .../tests/test_runtime_video_output.py | 91 --- .../omnidreams/omnidreams/demo/README.md | 66 -- .../omnidreams/omnidreams/demo/__init__.py | 20 - .../omnidreams/omnidreams/demo/adapter.py | 279 ------- .../omnidreams/omnidreams/demo/cli.py | 187 ----- .../omnidreams/omnidreams/demo/replay.py | 277 ------- .../omnidreams/omnidreams/demo/spec.py | 271 ------- .../omnidreams/omnidreams/demo/webrtc.py | 178 ---- 37 files changed, 8510 deletions(-) delete mode 100644 docs/inference_runtime_api_design.md delete mode 100644 docs/inference_runtime_inputs_implementation.md delete mode 100644 docs/inference_runtime_supported_inputs_inventory.md create mode 100644 flashdreams/flashdreams/runtime/README.md delete mode 100644 flashdreams/flashdreams/runtime/__init__.py delete mode 100644 flashdreams/flashdreams/runtime/_utils.py delete mode 100644 flashdreams/flashdreams/runtime/canonical.py delete mode 100644 flashdreams/flashdreams/runtime/config.py delete mode 100644 flashdreams/flashdreams/runtime/demo/__init__.py delete mode 100644 flashdreams/flashdreams/runtime/demo/app.py delete mode 100644 flashdreams/flashdreams/runtime/demo/outputs.py delete mode 100644 flashdreams/flashdreams/runtime/demo/replay.py delete mode 100644 flashdreams/flashdreams/runtime/demo/spec.py delete mode 100644 flashdreams/flashdreams/runtime/demo/webrtc.py delete mode 100644 flashdreams/flashdreams/runtime/inference_session.py delete mode 100644 flashdreams/flashdreams/runtime/inputs.py delete mode 100644 flashdreams/flashdreams/runtime/interfaces.py delete mode 100644 flashdreams/flashdreams/runtime/mapping.py delete mode 100644 flashdreams/flashdreams/runtime/metrics.py delete mode 100644 flashdreams/flashdreams/runtime/output.py delete mode 100644 flashdreams/flashdreams/runtime/runner.py delete mode 100644 flashdreams/flashdreams/runtime/types.py delete mode 100644 flashdreams/flashdreams/runtime/video_output.py delete mode 100644 flashdreams/tests/test_inference_runtime_api.py delete mode 100644 flashdreams/tests/test_inference_session.py delete mode 100644 flashdreams/tests/test_runtime_canonical.py delete mode 100644 flashdreams/tests/test_runtime_demo_api.py delete mode 100644 flashdreams/tests/test_runtime_input_mapping.py delete mode 100644 flashdreams/tests/test_runtime_runner.py delete mode 100644 flashdreams/tests/test_runtime_video_output.py delete mode 100644 integrations/omnidreams/omnidreams/demo/README.md delete mode 100644 integrations/omnidreams/omnidreams/demo/__init__.py delete mode 100644 integrations/omnidreams/omnidreams/demo/adapter.py delete mode 100644 integrations/omnidreams/omnidreams/demo/cli.py delete mode 100644 integrations/omnidreams/omnidreams/demo/replay.py delete mode 100644 integrations/omnidreams/omnidreams/demo/spec.py delete mode 100644 integrations/omnidreams/omnidreams/demo/webrtc.py diff --git a/docs/inference_runtime_api_design.md b/docs/inference_runtime_api_design.md deleted file mode 100644 index 6bd1018d9..000000000 --- a/docs/inference_runtime_api_design.md +++ /dev/null @@ -1,764 +0,0 @@ - - -# FlashDreams Inference Runtime API Design Proposal - -Date: July 30, 2026 - -## Summary - -This proposal defines a standard inference runtime API for FlashDreams -integrations. The goal is to make world-model integrations easier to build, -benchmark, and run without forcing every model into the same input shape or -optimization stack. - -The proposed API separates the pieces that are currently mixed together in -integration-specific runner code: - -- `InferenceConfig`: how the model and inference stack should run; -- `UserInputs`: controls or events from an app, replay trace, or benchmark; -- `InferenceInput`: prompts, frames, videos, trajectories, maps, scene data, and - other values required by a specific model; -- input mapping: model/application-specific conversion from user-facing inputs - into model-facing inputs; -- runtime/session execution: model setup, warmup, per-rollout state, and - stepping; -- output targets: WebRTC, native display, MP4, benchmark artifacts, or headless - runs; -- metrics/profiling: timings, memory, traces, NVTX ranges, and benchmark - outputs. - -Current T2/T3 implementation notes are in -`docs/inference_runtime_inputs_implementation.md`. - -The supported-model input inventory used to revisit T2/T3 is in -`docs/inference_runtime_supported_inputs_inventory.md`. - -The API should standardize the envelope and lifecycle. It should not pretend -that all world models have the same inputs, that all models use the same -optimization stack, or that a raw checkpoint can fully describe how to run the -model. - -## Current Implementation Plan - -Implementation should happen on an experimental integration branch. PRs for this -work should target that branch until the API shape and OmniDreams migration are -working well enough to merge to `main` together. LingBot migration is deferred -to a separate follow-up after the OmniDreams path has clarified the shared demo -API shape. - -The experimental branch can temporarily break or simplify command-line options -while the demos are being moved to the new API. The required outcome for this -branch is that the OmniDreams demo runs through the new shared demo/runtime path, -and that benchmark and manual WebRTC checks can confirm it is at least broadly -healthy before the branch is merged back to `main`. - -Initial scope: - -- define the minimal runtime API envelope; -- migrate OmniDreams to use it through a shared demo-level API; -- support selectable output modes such as MP4, JPEG/MJPEG stream, WebRTC, and - headless/null where appropriate; -- use or update benchmark tooling to verify the migrated OmniDreams demo; -- defer broader model migrations, hosted execution, full autotune, and polished - metrics until the first branch proves the API shape. - -## Task Tracker - -| ID | Status | Workstream | Can run in parallel? | Depends on | Done when | -| --- | --- | --- | --- | --- | --- | -| T0 | Complete | Create experimental branch and contribution rules. | No, this starts the work. | None. | Branch exists, PR target is agreed, and main merge criteria are written down. | -| T1 | Complete | Minimal API envelope and naming. | Partly. | T0. | `InferenceConfig`, `UserInputs`, `InferenceInput`, runtime/session, output target, and mapping boundaries are defined well enough for demos to use. | -| T2 | Complete | Event-based `UserInputs`. | Yes, after T1 direction is agreed. | T1. | User inputs are primarily timestamped events; replay traces and derived snapshots are supported where needed. | -| T3 | Complete | `CanonicalInputs`, `InferenceInput`, schemas, and mapping boundary. | Yes, after T1 direction is agreed. | T1. | Models can declare required global/per-step inputs, and mappings can convert canonical inputs into inference inputs. | -| T4 | Complete | `ModelRunner`, `InferenceRuntime`, and `InferenceSession` skeleton. | Partly. | T1. | A minimal standard loop can initialize a runtime, run at least one sequential session, and close cleanly. | -| T5 | Planned | Output mode selection. | Yes, after the result/output shape is agreed. | T1, T4. | A run can choose output behavior such as MP4, JPEG/MJPEG stream, WebRTC, benchmark artifact, or headless/null without changing model code. | -| T6 | Deferred | LingBot migration. | Yes, but out of scope for this branch. | T2, T3, T4. | LingBot runs through the new API path with its event inputs mapped into model inputs. | -| T7 | Partially complete | OmniDreams migration. | Yes, once T2-T4 have a usable skeleton. | T2, T3, T4. | OmniDreams replay and WebRTC run through the shared demo API path; remaining work is output/stat integration, legacy demo retirement, and cleanup. | -| T8 | Partially complete | Benchmark/smoke verification for OmniDreams. | Preparation can run early; final gate is late. | T5, T7. | Existing or updated benchmark tooling can run the migrated OmniDreams demo and produce enough evidence that it still works. | -| T9 | Planned | Metrics and profiling normalization for the branch. | Yes, but final integration is late. | T4, T5, T8. | Basic canonical metrics are emitted for migrated demos; deeper metrics can remain follow-up work. | -| T10 | Planned | CLI compatibility, legacy retirement, and migration cleanup. | Yes, after demo migrations start. | T5, T7, T8. | Required demo commands are restored or replaced, old interactive-drive and old OmniDreams demo/server paths are removed or reduced to compatibility shims, code used only by retired demos is removed, and user-facing docs/notes match the branch behavior. | -| T11 | Planned | Stabilize and merge experimental branch to `main`. | No, final integration step. | T5, T7-T10. | OmniDreams passes agreed smoke/benchmark checks, review feedback is addressed, and the branch can merge as one API transition. | - -Current OmniDreams migration status: - -- The shared `flashdreams.runtime.demo` API and OmniDreams demo adapter exist. -- OmniDreams MP4 replay runs through the shared replay runner and MP4 output - target. -- The one-minute benchmark comparison can run the legacy replay path and the new - shared demo replay path side by side. -- OmniDreams WebRTC runs through `serve_flashdreams_demo(...)` and the shared - WebRTC manager path while still using the existing OmniDreams runtime and - packaged browser app. -- The migration is not complete until the new output target/stat artifact work - lands, the new OmniDreams path is updated to use it, the old interactive-drive - and old OmniDreams demo/server paths are removed or reduced to deliberate - compatibility shims, code used only by retired demos is deleted, and the - experimental demo/runtime/input code is cleaned up. - -Suggested parallel split: - -- one person owns T4 and keeps it aligned with the completed T1 envelope, - because the standard loop is now the critical path; -- one person owns T2/T3, because event inputs, schemas, and mapping need to - stay coherent; -- one person owns T5/T8/T9, because outputs, benchmarks, and metrics are tightly - related; -- LingBot should be tracked as a separate follow-up once OmniDreams has settled - the shared demo API shape; -- one person should track branch health, CLI compatibility, and merge readiness. - -## Architecture - -```text -Optional discovery for CLI, benchmark, hosted, or installed-package flows: - Model/preset registry - -> adapter/preset/default setup/scenario metadata - -> contributes defaults to the app-supplied run setup - -Main runtime flow: -App / integration / benchmark / transport - chooses how the run is driven and where output goes - supplies run setup: - InferenceConfig + UserInputs + InferenceInput + output/metrics options - | - v -ModelRunner / standard loop - orchestrates validation, lifecycle, stepping, output, and metrics - uses input mapping to: - validate that user/app inputs can drive the model - build global and per-step InferenceInput during the run - | - v -InferenceRuntime - reusable heavyweight lifecycle: distributed init, model load, compile, warmup - load once; create sessions sequentially unless the backend supports concurrency - | - v -InferenceSession - one rollout/stream: global conditioning, cache/state, current step, reset - keeps per-run state from leaking across prompts, clients, or benchmark repeats - | - v -Model implementation / inference pipeline - hot path: encode -> model step -> decode -> cache/finalize - | - v -Output target - WebRTC | native window | MP4 | benchmark | headless/null - | - v -Metrics / artifacts / logs / reports / traces -``` - -## Example Sequential Session Flow - -The runtime/session split is primarily about reusing expensive model setup while -keeping each rollout's state isolated. The default mental model should be -sequential sessions, not required concurrent sessions. - -```text -ModelRunner / standard loop - | - v -Create InferenceRuntime from InferenceConfig - load checkpoint/model - initialize distributed/backend state - compile/capture/warm up if configured - | - v -Start InferenceSession A - global conditioning: prompt/frame/scene/etc. - per-session state: cache, current step, reset state - step 0 -> step 1 -> ... -> done - outputs -> Output target - metrics -> Metrics recorder - close session A - | - v -Start InferenceSession B - new global conditioning or replay scenario - independent cache/state - step 0 -> step 1 -> ... -> done - outputs -> Output target - metrics -> Metrics recorder - close session B - | - v -Close InferenceRuntime - release model/backend resources -``` - -For v0, an `InferenceRuntime` may support only one active session at a time. -Concurrent sessions should be treated as an optional backend/model capability, -not a baseline API requirement. - -`StreamInferencePipeline` should remain an important local implementation path -for models that already use it, but it should not be treated as the only -possible model boundary. A session may call `StreamInferencePipeline`, another -local model implementation, a Dynamo-like backend, or a hosted service. - -## System Components - -| Component | Role | Boundary | -| --- | --- | --- | -| Model/preset registry | Lists what can run: model/preset slugs, scenarios, capabilities, resource hints, and supported output modes. | Must remain cheap to query and must not load checkpoints. | -| App / integration / benchmark / transport | Owns the user-facing mode: CLI, native integration, WebRTC, benchmark, hosted request, or replay. | Supplies run setup, user inputs, model inputs, and output target selection. | -| User input library | Normalizes live or replayed controls into FlashDreams-supported user input events/windows. | Shared primitives for keyboard, reset, prompt/image selection, traces, and future scalar controls. | -| Input mapping | Converts user/app inputs plus global conditioning into the model-specific inputs needed by the session. | A model adapter may provide a default mapping; runtimes, applications, benchmarks, and replay tools may override it without changing the model step. | -| ModelRunner / standard loop | Orchestrates one run from setup through runtime initialization, stepping, output, metrics, and teardown. | Shared orchestration layer used by CLIs, benchmarks, MP4 runs, and simple realtime flows. | -| InferenceRuntime | Owns heavyweight lifecycle: distributed init, model construction, checkpoint loading, compile/capture, warmup, hosted-service connection, and teardown. | Long-lived reusable runtime created from `InferenceConfig`; lets FlashDreams load/warm once and create sessions sequentially unless the backend supports concurrency. | -| InferenceSession | Owns one rollout or stream: global conditioning, cache state, current step, reset behavior, step requirements, and step execution. | Per-rollout interface consumed by the standard loop; keeps state isolated across prompts, browser clients, replay scenarios, or benchmark repeats. | -| Model implementation / inference pipeline | Implements encode, model step, decode, cache updates, and model-specific optimizations. | FlashDreams wraps this boundary; it should not replace every model implementation. | -| Output target | Consumes generated outputs and handles presentation or persistence. | Separate from model execution so the same session can feed WebRTC, MP4, benchmark, or headless output. | -| Metrics, artifacts, and profiling | Records timings, memory, quality data, logs, reports, traces, and optional NVTX ranges. | Shared observation layer for local runs, benchmarks, CI smoke, and hosted runs. | - -## API Layers - -FlashDreams should expose layered APIs rather than a single all-or-nothing -interface: - -```text -High-level runtime API - run setup -> standard loop -> output targets -> metrics/artifacts - -Adapter/runtime API - model adapter -> InferenceRuntime -> InferenceSession - -Low-level inference API - StreamInferencePipeline -> encoders/decoders -> cache/perf/profiling helpers -``` - -| Layer | Intended user | Provides | -| --- | --- | --- | -| High-level runtime API | Users who want FlashDreams to own the run loop. | Run setup, input mapping, runtime/session lifecycle, output targets, metrics, profiling, and benchmark artifacts. | -| Adapter/runtime API | Model owners who want their model to plug into the standard loop. | Model adapter, input requirements, runtime/session implementation, and model-specific mapping or validation. | -| Low-level inference API | Users who want to own their own loop while reusing FlashDreams building blocks. | `StreamInferencePipeline`, encoders, decoders, cache helpers, profiling tools, and optimization utilities. | - -These layers should remain compatible. The new runtime API sits above the -existing lower-level pieces; it does not replace them. - -## Goals - -- Make FlashDreams easier to use for new world-model integrations. -- Keep model-specific input semantics explicit instead of hiding them in runner - code. -- Avoid a single monolithic inference stack; different models should be able to - validate and use different optimization features. -- Separate model execution from presentation and persistence. -- Support both live input and deterministic replay through the same - runtime/session boundary. -- Make metrics, benchmark artifacts, and profiling first-class without forcing - profiling overhead into normal runs. -- Preserve room for local single-GPU, local distributed, Dynamo-like, and hosted - execution. - -## Non-Goals - -- Do not infer arbitrary model semantics from a raw checkpoint. -- Do not require every model to use the same encoder, decoder, scheduler, - control representation, transport, or optimization set. -- Do not make WebRTC or native display part of the model API. -- Do not make autotuning part of normal inference startup. -- Do not require users to use the high-level standard loop when they only need - lower-level inference building blocks. -- Do not require every existing integration to migrate in one large change. - -## API Placement - -The new API should sit above the existing `flashdreams.infra` layer. Existing -pipelines, encoders, decoders, runner configs, realtime input helpers, WebRTC -code, and quality/benchmark utilities should be reused where possible. - -The exact package layout and class definitions can be decided during -implementation. This document should define responsibilities and boundaries, not -the final Python shape. - -## InferenceConfig - -`InferenceConfig` describes how to run the model/runtime. It should cover: - -- model or preset identity; -- checkpoint or model asset selection; -- execution backend, such as local single GPU, local multi-GPU, Dynamo-like, or - hosted/external execution; -- device placement, precision, and resource hints; -- optimization choices such as compile, CUDA graph capture, attention backend, - cache policy, overlap, prefetch, and native extensions; -- runtime-affecting profiling or tracing options. - -It should not contain prompts, keyboard state, browser settings, MP4 paths, -benchmark output directories, or other app/output settings. Those belong in the -run setup around `InferenceConfig`. - -Existing `StreamInferencePipelineConfig` and `InstantiateConfig` style configs -can remain valid model references behind this layer. The model adapter should -validate which execution and optimization choices are supported. Unsupported -choices should fail clearly or be explicitly handled only when the user selected -an automatic mode. - -## UserInputs - -`UserInputs` describes user-facing controls produced by a live UI, browser, -native app, replay trace, synthetic benchmark driver, or no-op source. - -User inputs should primarily be represented as timestamped events. This gives -live apps, replay traces, and benchmarks the same basic shape, and lets -FlashDreams route, drain, or window those events when a model session asks for -the next chunk of inputs. Resampling and interpolation should remain -input-specific mapping or helper behavior, because controls such as rotations, -poses, or controller state may need semantics that generic runtime code cannot -infer safely. - -Initial supported user input types should stay close to what FlashDreams already -uses: - -- keyboard keydown/keyup events; -- reset requests; -- prompt or image selection/update events; -- future scalar controls such as throttle, brake, steer, or camera axes once an - integration needs them. - -Snapshot-style inputs, such as current key state, can still be supported when -useful. They should be treated as a derived or compatibility form rather than -the primary user-input abstraction. - -User inputs are not model inputs. A keyboard event does not have one universal -meaning. One model may map it to pose segments, another to steering commands, -and another may ignore it. - -## CanonicalInputs And InferenceInput - -Inputs move through three layers: - -```text -UserInputs -> CanonicalInputs -> InferenceInput - raw canonicalized encoded -``` - -Raw device events for live control are canonicalized into device-independent -modalities before application or mapping logic consumes them, so adding a -keyboard, gamepad, or wheel is a converter registration rather than an -application change. Global conditioning is application-owned and reaches -`InferenceInput` directly; it does not pass through live device canonicalization. -`InferenceInput` is what an `InferenceSession` actually receives. - -`CanonicalInputs` describes device-independent live control for one requested -input window. `InferenceInput` describes the data the model or inference -pipeline actually requires, split into two conditioning slots: - -- global conditioning: values that condition the whole rollout; -- per-step conditioning: values needed for one generated chunk or frame window. - -Examples of global conditioning include prompt, negative prompt, conditioning -frame, input video, scene id, HD map asset, camera calibration, initial camera -pose, seed, or model-specific fields. - -Global conditioning establishes session-global model state when a session -starts or resets. During an active rollout, a non-empty global-conditioning -payload passed to `InferenceSession.step()` asks the session to update that -state when the model supports it. Reset remains a separate explicit session -method. - -Examples of per-step conditioning include frame timestamps, pose segments, -camera trajectory chunks, rendered HD map frames, conditioning video windows, -control tensors, event markers, or model-specific fields. - -Inference input payloads should use semantic names, not only modality names. For -example, a first frame and an HD map frame should be distinct inputs even if -both are image-like values. - -Model input names, input modalities, and schema metadata should be open-ended. -Supported integrations such as SANA-WM, LingBot, Omnidreams, and future -external adapters may need different semantic fields. Adding a new model should -usually mean adding adapter-owned schema declarations and mappings, not changing -a central FlashDreams enum. - -Consumption cadence is a separate hint from input scope. A field may be -provided through global conditioning because it is session-global state, while -the adapter consumes or slices it during every step. That can be recorded as -`frequency_consumed` metadata without changing whether the field belongs in -`global_conditioning_fields` or `step_fields`. - -For interactive runs, most `InferenceInput` values will be app-owned global -conditioning plus per-step inputs produced by input mapping. For MP4 generation -and benchmarking, the API should also support fixed per-step model inputs so -runs can be deterministic. - -## Schemas - -The API should support lightweight `UserInputSchema`, `CanonicalInputSchema`, -and `InferenceInputSchema` -metadata. - -These schemas are not meant to be a rich type system or a replacement for -model-specific validation. They should be just enough to answer: - -- what can this app, transport, trace, or benchmark source provide? -- what does this model require before session start and at each step? -- can this event source drive this model with the selected mapping? - -The purpose is to fail early before expensive model initialization, produce -clearer errors, make fixed scenarios easier to validate, and avoid ambiguous -dict payloads where keys only describe modality. - -Schema objects may carry open-ended metadata for query-time hints such as -coordinate frame, units, rough shape summary, accepted file suffixes, schema -URI, model family, or source/transport details. Metadata should help humans and -adapter selection code, but compatibility should still be based on the declared -event capabilities, semantic model fields, input modalities, and schema phases. -Consumption-cadence hints are descriptive and adapter-owned. - -For simple CLI text-to-video or image-to-video runs, `UserInputSchema` can be -trivial or omitted because there may be no live controls. `InferenceInputSchema` is -more important because each supported model still needs to declare the -model-facing values it expects. - -## Model Requirements - -A raw checkpoint should not be treated as self-describing. It may imply tensor -shapes or architecture details, but it usually does not fully define: - -- required semantic inputs; -- initial versus per-step inputs; -- units for timestamps, poses, or calibration values; -- how user controls become model controls; -- preprocessing, encoder, decoder, mask, prompt, or cache rules. - -Therefore, a FlashDreams-supported model should have an adapter or integration -layer that declares its model input requirements, declares any user inputs it can -map by default, and prepares inputs for the underlying model implementation. - -Users running an existing FlashDreams-supported model should not need to write -that adapter. Developers bringing a new world model to FlashDreams should expect -to provide one. - -## External Model Usage - -Users should be able to run their own models without adding those models to the -FlashDreams repository. The flow depends on which API layer they use: - -```text -High-level runtime API - user supplies or installs model adapter - FlashDreams owns standard loop, outputs, metrics, benchmarks - -Adapter/runtime API - model owner implements adapter/runtime/session - adapter can be passed directly or registered by an installed package - -Low-level inference API - user owns loop and lifecycle - user reuses pipeline, encoder/decoder, cache, profiling, or optimization tools -``` - -| Flow | Registry needed? | Who provides model-specific code? | Result | -| --- | --- | --- | --- | -| Direct Python | No. | User or model owner passes an adapter/setup directly. | FlashDreams can run the standard loop without the model living in the repo. | -| Installed package | Yes, for discovery. | External or internal package registers adapters/presets. | CLIs, benchmarks, and hosted schedulers can discover the model cheaply. | -| Low-level only | No. | User owns the loop and calls lower-level FlashDreams pieces directly. | Useful when the user wants optimizations or pipeline helpers but not the standard loop. | - -The model adapter is a role/boundary, not necessarily a concrete class. It is -the model-specific code that declares input requirements, validates supported -configs, creates the runtime/session, and connects FlashDreams to the actual -model implementation. - -The registry should not be treated as a central FlashDreams-owned catalog of all -possible models. It is a discovery mechanism for installed adapters. Built-in -public integrations, internal GitLab-only integrations, and third-party packages -can all participate through the same mechanism. - -FlashDreams should not claim to run an arbitrary checkpoint with no adapter -unless the checkpoint already matches a supported generic adapter. - -## Input Mapping - -Input mapping is required whenever `UserInputs` need to become per-step -`InferenceInput`. In the T1 envelope this boundary is represented by a separate -`InputMapping` protocol. A model adapter may provide the default mapper because -it knows how its supported user controls affect model-facing inputs. Applications, -benchmarks, replay tools, or hosted runtimes may replace that mapper when they -need a different wire surface or aggregation policy. - -The selected mapping may be a single mapper or a composed set of mappers, so one -run can combine separate prompt, first-frame, and live-control mappings instead -of routing everything through one object. - -There are two separate moments to keep clear: - -- before runtime initialization, FlashDreams should select the mapping or mapper - set and check obvious compatibility between the app event source and the - model; -- during the standard loop, the runtime or runner passes app-owned global - `InferenceInput` through the selected mapping before session start, then - queues and timestamps user events, canonicalizes the session-requested window, - and uses the selected mapping to build per-step `InferenceInput`. - -This keeps the Reactor-style contract intact: the model-side integration can -declare user inputs, declare model inputs, and provide a default mapping, while -the runtime owns transport, event validation, timestamping, input queue/window -selection, output delivery, and optional overrides. - -`StepRequest` and `StepResult` are per-step runtime messages, not declarative -schemas. `InferenceSession.next_step_request()` returns a `StepRequest` to say -which step is next, which user-input time window to map, and whether this step -has any narrower `InferenceInputSchema` than the session default. The runner or -application then builds an `InferenceInput` and calls `InferenceSession.step()`, -which returns a `StepResult` carrying the generated output, output timing, -metrics, and step metadata. - -Examples: - -- T2V mapping validates a prompt and creates no per-step control inputs. -- I2V mapping validates a prompt plus first frame and creates no live controls. -- A keyboard-driven integration maps key events or event windows into pose - segments or steering controls. -- OmniDreams-like integrations may map driving commands into camera poses, HD - map frames, and dynamic actor state. -- Benchmark mapping can read fixed event traces and produce identical step - inputs each run. - -The compatibility check should be treated as early validation, not a guarantee -that the run will succeed. It can catch obvious mismatches, but the model -adapter/runtime still owns deep tensor validation and model semantics. - -## Runtime And Standard Loop - -The standard loop should be shared by CLI generation, headless playback, MP4 -generation, benchmarks, and simple realtime applications. - -The current v0 production loop is `flashdreams.runtime.run_inference_session()`. -It is intentionally narrow: one adapter, one config, one canonicalizer/source, -one selected mapping, one initial input, one output target, one metrics -recorder, and one synchronous sequential session. - -A run should: - -1. Discover the model or preset without loading checkpoints. -2. Resolve inference config, user inputs, model inputs, output target, metrics, - profiling, and optional scenario setup. -3. Validate that the event source and mapping can drive the selected model. -4. Initialize the runtime. -5. Start a session from global conditioning inputs. -6. For each step, ask the session what it needs, gather live or fixed inputs, - build step model inputs, run the session step, route outputs, and record - metrics. -7. Finalize output artifacts, metrics, logs, reports, and traces. - -Realtime transports may need an async variant, backpressure, and explicit flow -control, but the conceptual boundary should remain the same: event/input source, -input mapping, session, output target, metrics. - -The session should expose what it needs for the next step rather than requiring -the app or output layer to guess. This matters because AR step 0 can differ from -steady-state steps, and encoder/decoder temporal compression can produce -different input and output frame windows. - -Input and output timing should share a session timeline even when raw capture -rates and presentation rates differ. A session can request a user-input window -for mapping, then return an output window or equivalent metadata so an output -target can present the generated chunk at the intended cadence. - -## Output Targets - -Output handling should be separate from model execution. The model session -returns generated outputs and metadata; the output target decides what to do -with them. - -Expected output targets include: - -- WebRTC streaming; -- native window display; -- MJPEG or lightweight remote preview; -- MP4 writing; -- benchmark artifact writing; -- headless playback; -- null output for pure throughput measurements. - -Display and transport can still affect measured performance through copies, -encoding, queueing, backpressure, and presentation timing. Those costs should be -measured as output-target or end-to-end metrics instead of being mixed into core -model-stage timings. - -## Fixed Inputs, Benchmarks - -The API should support fixed runs as a first-class case. This is needed for MP4 -generation, benchmarks, regression testing, and autotune. - -Two replay levels should be supported: - -- user-event replay: records timestamped key events, prompt or image - selection/update events, reset events, and timing, then runs normal input - mapping; -- model-input replay: records or defines already-mapped per-step model inputs - for stricter model-level regression tests. - -User-event replay tests more of the application stack. Model-input replay is -better for isolating model runtime performance and reproducibility. - -## Metrics And Profiling - -Metrics should have a small canonical baseline plus optional extras. - -The baseline should cover: - -- lifecycle timing: startup, load, warmup, first-step latency; -- model-stage timing: encode, model step, decode, finalize/cache update; -- memory: allocated, reserved, peak, and per-rank where applicable; -- throughput: frames per second, chunks per second, real-time factor. - -Realtime runs may add input-to-present latency, jitter, missed deadlines, queue -depth, dropped frames, WebRTC stats, encoder bitrate, and client stats. -Benchmark runs may add quality metrics, logs, MP4/image previews, and reports. - -Persisted timing metrics should use seconds as the canonical unit because -seconds compose cleanly across Python timers, traces, and long-running -durations. Reports and UIs can display milliseconds for short latencies. - -Profiling should be optional and controlled separately from normal metrics. -NVTX ranges should be supported for Nsight profiling, but profiling should not -be required for normal inference or benchmark runs. - -## Autotune - -Autotune should be a separate harness that evaluates candidate -`InferenceConfig` variants against fixed scenarios. It should not be part of -normal startup. - -Autotune may search over compile, CUDA graph capture, attention backend, -precision, cache policy, overlap, prefetch, native extensions, and chunk size -when the model supports those knobs. - -Results are only valid for a specific model, checkpoint, hardware, driver, -FlashDreams commit, and scenario. First-run compile/capture cost should be -separated from steady-state metrics. Agent assistance could help propose search -spaces or summarize results, but the measured selection process should be -deterministic code. - -## Distributed And Hosted Execution - -The API should leave room for local single-GPU, local multi-GPU, Dynamo-like -execution, and hosted execution such as a Reactor-style platform. - -At this stage, the proposal should not define Reactor- or Dynamo-specific -contracts in detail. It should preserve the right boundary: execution backend -selection belongs in `InferenceConfig`, while backend-specific scheduling, -authentication, asset access, output streaming, artifact handling, and failure -behavior belong behind the runtime/backend implementation. - -The practical order should be local first, then local distributed, then -hosted/distributed backends once concrete backend owners can validate the -requirements. - -## Existing Code And Migration - -The new API should reuse existing code instead of replacing everything: - -- keep `flashdreams.infra.pipeline` as the common local encode/model/decode - implementation path; -- keep existing encoder and decoder contracts and reuse temporal size helpers; -- keep existing runner configs and CLI compatibility during migration; -- reuse `KeyboardResampler` and realtime input helpers behind the new input - boundary; -- treat WebRTC as a transport/output adapter and bridge it gradually; -- reuse existing quality and benchmark utilities where applicable; -- keep internal-only integrations registered only in the GitLab/internal - workspace. - -The task tracker near the start of this document is the source of truth for the -first implementation branch. The first milestone is intentionally narrower than -the full design: prove the API with OmniDreams, add shared output/stat artifact -selection, retire the old OmniDreams demo paths, clean up the experimental -runtime/demo code, and collect enough benchmark/smoke evidence to merge the -experimental branch back to `main` safely. LingBot should be handled in a -separate follow-up plan. - -## Design Risks - -- `InferenceConfig` could become too broad if prompts, controls, output paths, - browser settings, and benchmark settings are added to it. Keep it focused on - model/runtime execution. -- Dict-like model inputs are flexible but can fail late. Keep dict payloads for - flexibility, but require lightweight schemas and adapter validation for - supported models. -- Schemas could become too heavy. Keep them minimal and role-oriented. -- User inputs are not model inputs. Keep input mapping explicit and - model/application-owned. -- Per-frame, per-chunk, and AR-step clocks are easy to confuse. The session - should expose step requirements instead of making app code guess. -- Output separation is necessary but not free. Measure output and transport - costs separately from core model timings. -- Hosted/distributed execution is still under-specified. Keep the API boundary - open until backend owners validate concrete requirements. -- Existing WebRTC behavior is nontrivial. Bridge it gradually to avoid - regressions. -- Public/internal boundaries must remain clean. Internal adapters, slugs, and - scenarios should not leak into the public repo. - -## Decisions Made In T1 - -Task T1 settles the initial package and naming envelope without committing to a -registry, standard loop, concrete output modes, or model migrations: - -- The experimental API lives under `flashdreams.runtime`. -- The model-specific integration boundary is named `ModelAdapter`. -- Heavyweight lifecycle is split into `InferenceRuntime` and - `InferenceSession`. -- Step data carriers are named `StepRequest` and `StepResult`. They are runtime - messages around one call to `InferenceSession.step()`, not schema - declarations; a session returns `None` from `next_step_request()` when the - rollout is complete. -- Raw inputs use `UserInputs`, canonicalized inputs use `CanonicalInputs`, and - model-facing inputs use `InferenceInput`. - Both remain lightweight payload envelopes with shallow read-only mappings. -- `UserInputSchema`, `CanonicalInputSchema`, and `InferenceInputSchema` stay - intentionally small: they - declare supported event types and required named fields for early validation, - not a full type system. -- Input mapping is represented by a separate `InputMapping` protocol. Model - adapters may provide a default mapping; runtimes and applications may override - it while preserving the `CanonicalInputs` to `InferenceInput` boundary. Simple - fixed-input runs can use `IdentityInputMapping`. -- Output handling is represented by `OutputTarget`; `NullOutputTarget` is the - initial headless implementation. -- Metrics collection is represented by `MetricsRecorder`; timing samples use - seconds as the canonical unit. -- The minimum v0 user input shape is timestamped `UserInputEvent` records plus - optional snapshot data. Concrete event-type catalogs are left to T2 and demo - migrations. - -## Remaining Decisions - -- What direct-Python API should let users pass an external adapter without - registering it? -- What package registration mechanism should third-party and internal adapters - use for CLI discovery and benchmarks? -- Which model should migrate after OmniDreams settles the shared demo API shape? -- What metrics are required for every benchmark run? -- What metadata must be discoverable without loading checkpoints? -- What requirements do Dynamo/Reactor-style backends need before we commit to - hosted execution details? - -The document currently uses "integration" for model-specific packages and app -entrypoints. If the team prefers "model" as the public term, that can be changed -later without changing the architecture. - -## Recommendation - -Proceed with the proposed split: - -- `InferenceConfig` for model/runtime execution; -- `UserInputs` for app-facing controls and replay traces; -- `CanonicalInputs` for device-independent application-facing inputs; -- `InferenceInput` for model-facing global and per-step conditioning; -- input mapping for model/application-specific conversion; -- runtime/session boundaries for lifecycle and stepping; -- output targets for display, streaming, files, and benchmarks; -- shared metrics and optional profiling. - -The main constraint is that arbitrary world-model inputs cannot be standardized -away. FlashDreams can provide the shared envelope, loop, metrics, replay, and -output tools, but each supported model still needs an adapter that declares and -validates its own input contract. diff --git a/docs/inference_runtime_inputs_implementation.md b/docs/inference_runtime_inputs_implementation.md deleted file mode 100644 index edbc7399d..000000000 --- a/docs/inference_runtime_inputs_implementation.md +++ /dev/null @@ -1,292 +0,0 @@ - - -# Inference Runtime Inputs Implementation Notes - -This note documents the input layers of the experimental runtime API: what -exists, how the pieces fit together, what the compatibility query answers, and -what is intentionally still outside this layer. - -Implementation lives in `flashdreams.runtime`: - -- `flashdreams/flashdreams/runtime/inputs.py` — user/canonical input types - and schemas -- `flashdreams/flashdreams/runtime/inference_session.py` — model-ready - `InferenceInput`, `InferenceInputSchema`, and session lifecycle -- `flashdreams/flashdreams/runtime/canonical.py` — raw device to canonical - modality conversion -- `flashdreams/flashdreams/runtime/mapping.py` — canonical to encoded mapping - and compatibility -- `flashdreams/tests/test_runtime_canonical.py` -- `flashdreams/tests/test_runtime_input_mapping.py` -- `flashdreams/tests/test_inference_runtime_api.py` — the T1 envelope tests -- `flashdreams/tests/test_runtime_runner.py` — the production standard loop - tests that exercise all three input layers with runtime/session cleanup - -The supported-model input inventory that informed this work is in -`docs/inference_runtime_supported_inputs_inventory.md`. - -## The Three Layers - -```text -UserInputs ──InputCanonicalizer──▶ CanonicalInputs ──InputMapping──▶ InferenceInput - raw canonicalized encoded -(device events) (device-independent) (what the session gets) -``` - -| Layer | Type | Owner | Example | -| --- | --- | --- | --- | -| raw | `UserInputs` / `UserInputEvent` | transport, replay loader, benchmark driver | `key_down {"key": "w"}`, wheel axis reading | -| canonicalized | `CanonicalInputs` | device converters registered on `InputCanonicalizer` | `driver_command {throttle, brake, steer, ...}` | -| encoded | `InferenceInput` | the selected `InputMapping` | whatever the model's session consumes | - -Applications and mappings consume `CanonicalInputs`. They never read raw device -events: `InputMapping.map_step_inputs` takes `canonical_inputs`, not -`user_inputs`, so this is enforced by the signature rather than by convention. -Adding a keyboard, gamepad, or wheel is an `InputCanonicalizer.register` call -that touches no application, mapping, or model code. - -This path covers **live user control only**. Global conditioning is -application-owned data and reaches `InferenceInput` directly, without passing -through canonicalization or a device converter. Session start/reset establishes -that global conditioning. During an active rollout, a non-empty -`global_conditioning` payload passed to `step()` requests an update of the -session-global state when the model supports it. - -## Conditioning Slots - -The encoded layer splits model-facing inputs into two slots: - -- **global conditioning** — session-global model state: prompt, conditioning - frame, scene. -- **per-step conditioning** — needed to generate the next chunk or frame: - steering, HD map frames, camera trajectory. - -`InputPhase` is `Literal["global_conditioning", "step"]`. The phase names the -`InferenceInput` slot the caller provides. - -`InputField.frequency_consumed` is independent query metadata. It says how the -adapter consumes a field internally, such as `once` or `per_step`; it does not -decide whether the caller provides the field through `global_conditioning` or -`step`. - -## Global Conditioning Is Session-Global State - -`InferenceInput.global_conditioning` carries session-scoped inputs. A runtime -passes those values to `InferenceRuntime.start_session()` or to -`InferenceSession.reset()` when the backend supports resetting a rollout. -During an active rollout, passing a non-empty `global_conditioning` payload to -`InferenceSession.step()` asks the session to update that session-global state. -The model/session owns whether that update is supported. - -```python -from flashdreams.runtime import InferenceInput, InferenceInputSchema, InputField - -schema = InferenceInputSchema( - global_conditioning_fields=( - InputField(name="prompt"), - InputField(name="scene_id"), - ) -) -schema.require_global_conditioning( - InferenceInput(global_conditioning={"prompt": "drive", "scene_id": "town_02"}) -) - -step_with_prompt_update = InferenceInput( - global_conditioning={"prompt": "heavy rain"}, - step={"steering": 0.0}, -) -``` - -Per-step conditioning is different: those values are supplied through -`InferenceInput.step` for each generated chunk or frame. Converters still emit -every window, because live control is level-triggered: a key held across a step -emits no events but still means full throttle. - -## Raw Inputs - -`UserInputEvent` carries `timestamp_s`, `event_type`, `payload`, `source`, and -`source_event_id`. `UserInputs` holds an ordered batch plus a `snapshot` and -`metadata`, and slices to a half-open `TimeWindow`: - -```python -from flashdreams.runtime import TimeWindow, UserInputEvent, UserInputs - -inputs = UserInputs( - events=( - UserInputEvent(timestamp_s=0.0, event_type="prompt_set", - payload={"prompt": "drive forward"}), - UserInputEvent(timestamp_s=0.5, event_type="key_down", payload={"key": "w"}), - ) -) -step_window = inputs.window(TimeWindow(start_s=0.0, end_s=1.0)) -``` - -`UserInputSchema` describes what a transport, replay trace, or benchmark driver -can provide. `event_types` declares only that an event type exists; -`UserInputCapability` additionally pins the payload fields it carries, so a -converter can require `key_down` events that actually have a `key`. A bare -`event_types` entry still satisfies any consumer needing no specific payload -fields, so schemas written before capabilities existed keep working. - -## Canonical Modalities - -A `CanonicalModality` is a device-independent input: a name and the payload -fields it guarantees. Converters implement `DeviceConverter`, declaring -what raw capabilities they consume and which modality they produce. - -```python -from flashdreams.runtime import ( - DRIVER_COMMAND, InputCanonicalizer, KeyboardToDriverCommand, TimeWindow, -) - -canonicalizer = InputCanonicalizer([KeyboardToDriverCommand()]) -canonicalizer.register(WheelToDriverCommand()) # a wheel is one call - -canonical = canonicalizer.canonicalize( - user_inputs, window=TimeWindow(start_s=0.0, end_s=1.0), source_schema=browser -) -canonical.values["driver_command"]["throttle"] -``` - -`DRIVER_COMMAND` is the one shipped modality. `KeyboardToDriverCommand` reuses -`KeyboardState`/`normalize_key` from `flashdreams.serving.realtime.input` and -mirrors the semantics the Omnidreams interactive-drive keyboard backend already -has. Its key bindings are data (`DEFAULT_DRIVING_BINDINGS`), and the set of -tracked keys is derived from them, so a rebound layout cannot leave an action -unreachable. - -`ScriptedModality` is the mock/replay converter. It consumes no raw -capabilities, so a benchmark or test can author a scenario at the canonical -level without knowing any device vocabulary: - -```python -canonicalizer = InputCanonicalizer([ - ScriptedModality(modality=DRIVER_COMMAND, timeline=[(0.0, full_throttle)]), -]) -canonicalizer.canonicalize( - UserInputs(), window=step_window, source_schema=UserInputSchema() -) -``` - -Application code is identical between a real run and a scripted one. - -Converters are stateful, so feed windows in session order and call -`InputCanonicalizer.reset()` at a rollout boundary. Replaying the same window -sequence reproduces the same `CanonicalInputs`. - -When several devices produce the same modality, the highest-priority one that -returned a value wins; `CanonicalInputs.metadata["canonical_sources"]` records -which device supplied each. Every feedable converter still sees each window, so -a preempted device's state stays current and unplugging the higher-priority -device does not resume from stale state. - -## Mapping And Compatibility - -`InputMapping` is the canonical-to-encoded boundary. `InputMappingSchema` is its -declarative surface: `consumes` names canonical modalities; -`produces_global_conditioning` and `produces_step` name the `InferenceInput` -fields it can build. - -`InputMapping.validate()` raises, which fails a run late and cannot say *which* -optional model input a source would enable or *which* missing modality makes a -required one unreachable. `check_mapping_compatibility` answers those before -expensive runtime initialization: - -```python -from flashdreams.runtime import check_mapping_set_compatibility - -compatibility = check_mapping_set_compatibility( - canonical_schema=canonicalizer.canonical_schema(browser), - inference_input_schema=adapter.inference_input_schema, - mapping_schemas=(prompt_mapping, frame_mapping, steering_mapping), -) -if not compatibility.can_drive: - compatibility.raise_if_incompatible() -``` - -`MappingCompatibility` reports `missing_modalities`, -`missing_required_model_fields`, `satisfied_required_model_fields`, -`available_optional_model_fields`, and `unavailable_mapping_schemas`. - -Compatibility is evaluated per mapping rather than over a flattened bag, so each -mapping keeps its own consumes/produces link. A mapping the source cannot feed -is dropped and reported, costing only the inputs it produced. So a dropped -mapping that fed only optional fields degrades the run instead of vetoing it, -and those fields are correctly absent from `available_optional_model_fields`; a -dropped mapping that was the only producer of a required field still blocks. - -Because a mapping consumes modalities rather than raw events, one mapping -written against `driver_command` works for a keyboard, a wheel, or any device -registered later, with no change to the mapping or the model schema. - -`undeclared_inference_inputs()` reports payload keys a mapping produced but did -not declare, which keeps hand-written schemas honest as the code drifts. - -`StepRequest` and `StepResult` sit around a single `InferenceSession.step()` -call. They are not schema declarations. A session returns `StepRequest` from -`next_step_request()` to name the next step, optionally provide a narrower -`InferenceInputSchema`, and request a `TimeWindow` of user inputs. The runner -then builds `InferenceInput` and calls `step()`, which returns a `StepResult` -for the output target and metrics recorder. - -## What This Does Not Validate - -The schemas intentionally avoid becoming a rich type system. These remain the -responsibility of the model adapter, runtime, session, or mapping: - -- tensor shape and dtype, image decode details; -- camera coordinate systems, pose and timestamp units; -- prompt-embedding mechanics; -- whether a model can actually apply a requested global-conditioning update; -- enforcing consumption-cadence metadata; -- deep validation of scene, HD map, or actor-state data. - -The layer answers "can this source plausibly drive this model through this -mapping?" It does not replace model-owned validation. - -## Open Questions - -Tracked against the runtime API discussion, not yet settled: - -- **Alternative valid input combinations.** `InferenceInputSchema` has one flat - required set, so "accepts `{prompt}` OR `{prompt, conditioning_frame}`" cannot - be expressed. `MappingCompatibility.missing_required_model_fields` assumes a - single required set too. -- **`step()` returning a future**, for models with a dependency on their own - output. `InferenceSession.step()` is currently synchronous. -- **`Input System` ownership.** The diagrams show it pulling events, so the - Application owns an input system. `InputCanonicalizer` is currently a pure - function over a supplied window and owns no source. Whether it needs to grow - one depends on the loop-ownership decision. Mock input and key binding are - handled (`ScriptedModality`, `DEFAULT_DRIVING_BINDINGS`). - -## Owned Elsewhere - -Named here only so the boundary is explicit; these are not gaps in the input -layer: - -- **`FrameStream`**, which the architecture diagrams place between - `InferenceSession` and `Output Target`. The code writes `InferenceOutput` straight - to `OutputTarget.write()`. Output shape is T5. -- **Declared output modalities**, so an output target or quality-eval can state - what it requires and be matched the way inputs now are. T5/T8. -- **Full `Application` ownership**, the class that has-a input system, input - map, global conditioning, session, and output target. T4 now provides the - narrow synchronous runner; richer application ownership remains outside T4. -- **Loop ownership** — whether the application or the runtime/session drives the - main event loop, and whether inputs are queued and batched. - -## Validation - -```bash -.venv/bin/pytest flashdreams/tests/test_runtime_canonical.py \ - flashdreams/tests/test_runtime_input_mapping.py \ - flashdreams/tests/test_inference_runtime_api.py \ - flashdreams/tests/test_runtime_runner.py -q -.venv/bin/ty check flashdreams/flashdreams/runtime -``` - -At the time of writing these pass: 87 tests, and `ty` is clean. diff --git a/docs/inference_runtime_supported_inputs_inventory.md b/docs/inference_runtime_supported_inputs_inventory.md deleted file mode 100644 index f82f3192b..000000000 --- a/docs/inference_runtime_supported_inputs_inventory.md +++ /dev/null @@ -1,354 +0,0 @@ - - -# Supported Model Input Inventory - -This note inventories the inputs used by the currently supported FlashDreams -runners and interactive runtimes, plus the SANA-WM input surface on `main`, then -records the T2/T3 API implications. It is intentionally about input contracts, -not tensor shape validation or model quality. - -## Inventory - -WAN 2.1 T2V, Self-Forcing WAN 2.1 T2V, Causal-Forcing T2V, -FastVideo Causal WAN 2.2 T2V, and Cosmos Predict2 T2V: - -- Source/app inputs: prompt text or prompt text file, pixel height/width, and - fps or block count depending on runner. -- Model-facing global conditioning: prompt text plus latent/output height and width - derived from run config. -- Model-facing per-step inputs: no live controls; AR loop steps with fixed - session state. - -WAN 2.1 I2V, Causal-Forcing I2V, and Cosmos Predict2 I2V: - -- Source/app inputs: prompt text or prompt file, first-frame image path or URL, - and pixel height/width. -- Model-facing global conditioning: prompt text and decoded first-frame tensor. -- Model-facing per-step inputs: no live controls. - -FlashVSR: - -- Source/app inputs: input video path or URL, chunk size, crop region, sparse - ratio, and optional output FPS. -- Model-facing global conditioning: no explicit prompt at runner time; the prompt - tensor is configured in the pipeline. Input video dimensions affect - per-video runtime/pipeline setup. -- Model-facing per-step inputs: video chunks passed to - `pipeline.generate(input=clip)`. - -LingBot CLI: - -- Source/app inputs: prompt or prompt path, first-frame image path, pose path, - intrinsics path, total blocks, dimensions, and fps. -- Model-facing global conditioning: prompt text and first-frame tensor. -- Model-facing per-step inputs: `CamCtrlInput` with intrinsics, camera poses, - and world scale. - -LingBot WebRTC: - -- Source/app inputs: session prompt, uploaded/remote/default first-frame image, - keyboard events, reset requests, text-event catalog, and trigger events. -- Model-facing global conditioning: prompt text, first-frame tensor, base text - embeddings, precomputed text-event embeddings, base intrinsics, and world - scale. -- Model-facing per-step inputs: keyboard event windows become pose segments - and camera trajectories. Text-event triggers can replace rollout text - embeddings when the model supports it. - -HY-WorldPlay WAN I2V: - -- Source/app inputs: prompt or prompt path, first-frame image path or example - image, pose string or pose JSON, memory-selection settings, dimensions, fps, - and seed. -- Model-facing global conditioning: prompt text and first-frame tensor for - session setup. -- Model-facing per-step inputs: pose data is bound for the rollout as action - labels, view matrices, intrinsics, and memory-selection state before AR steps. - -Omnidreams CLI: - -- Source/app inputs: shared prompt or per-camera prompts, HDMap video paths, - first-frame image/video paths, camera names, example-data UUID, and optional - embedding save/load paths. -- Model-facing global conditioning: prompt list, first-frame tensor, view names; or - precomputed text/image/negative-text embeddings. -- Model-facing per-step inputs: HDMap video chunks passed per AR step. - -Omnidreams WebRTC: - -- Source/app inputs: scene directory or scene UUID, scene variant, camera name, - prompt/first-frame assets resolved from the scene, keyboard events, reset - requests, and optional postprocess preset. -- Model-facing global conditioning: scene data, renderer, first-frame tensor, prompt, - camera calibration/extrinsics, initial ego pose, and initial timestamp. -- Model-facing per-step inputs: keyboard event windows become ego poses, - camera poses per view, and frame timestamps. The wrapper renders HDMap - conditioning internally for each step. - -Omnidreams interactive drive: - -- Source/app inputs: scene bundle, keyboard events or wheel/controller samples, - view-mode/reset/scene-exit controls, and vehicle/chunk config. -- Model-facing global conditioning: scene bundle, selected camera, prompt, initial - RGB frame, initial rig pose, and initial timestamp. -- Model-facing per-step inputs: `DriverCommand` samples become trajectory - chunks, rendered frames, and world-model conditioning. - -Template recipe: - -- Source/app inputs: synthetic runner config: batch size, height, width, context - tokens, AR steps, and seed. -- Model-facing global conditioning: synthetic transformer context, optional negative - context, height, and width. -- Model-facing per-step inputs: optional synthetic control tensor. - -WAN 2.2 TI2V pipeline config: - -- Source/app inputs: downstream runners use this rather than a standalone runner - in this tree. -- Model-facing global conditioning: prompt text and first-frame image for - TI2V-style session setup. -- Model-facing per-step inputs: downstream runners decide controls; - HY-WorldPlay currently binds action/camera state around it. - -SANA-WM bidirectional and streaming on `main`: - -- Source/app inputs: first-frame image path, prompt or prompt path, optional - negative prompt, camera trajectory path or action DSL, optional intrinsics - path or derived intrinsics, frame count, fps, Stage-1 sampling knobs, seed, - precision/refiner options, and streaming chunk/block settings. -- Model-facing global conditioning: decoder context such as prompt, fps, - `save_stage1`, refiner seed, sink size, and streaming refiner window/block - parameters. -- Model-facing per-step inputs: bidirectional passes one - `SanaWMI2VConditioningRequest` into the single generation step. Streaming - passes one `SanaWMStreamingI2VConditioningRequest` repeatedly; the - conditioning encoder caches rollout-wide prompt, first-frame, camera, latent - shape, and chunk-boundary state, then slices per AR chunk. -- Model-facing semantic fields include prompt, negative prompt, first frame, - camera-to-world trajectory, intrinsics vec4 sequence, frame count, fps, - sampling parameters, seed, and streaming chunking parameters. - -## API Implications - -The inventory changes the T2/T3 shape in five concrete ways. - -First, a selected mapping is often a composition. A LingBot-like run needs prompt -mapping, first-frame mapping, and keyboard-to-camera mapping. Omnidreams may add -scene selection, camera selection, and HDMap mapping. The implementation should -support checking a set of mapping schemas as one compatibility surface, while -still allowing a single mapping object when that is simpler. - -Second, `InferenceInputSchema` needs explicit global-conditioning and per-step -schema slots. `global_conditioning_fields` describe the session-global state -carried through `InferenceInput.global_conditioning`. Start/reset establishes -that state; a non-empty global-conditioning payload in a step context asks the -session to update it when the model supports that. `step_fields` arrive through -`InferenceInput.step` for one generated chunk or frame window. - -This distinction matters for rollout-wide values such as full camera -trajectories, action labels, intrinsics sequences, and memory-selection config. -Those can be supplied in the global-conditioning slot, even if the adapter later -slices them internally while executing steps. If the caller must supply a fresh -value for every generated chunk, that value belongs in `step_fields`. - -`frequency_consumed` is a separate optional hint for how the adapter uses a -field internally, such as `once` or `per_step`. It does not decide where the -caller provides the value. A field can live in `global_conditioning_fields` and -still have `frequency_consumed="per_step"` when the adapter slices or reads -rollout-wide state during step execution. - -Third, `name` is the semantic model input role, while `input_modality` is only -a coarse value-kind hint. For example, `prompt` and `negative_prompt` are -different semantic names even though both usually have `input_modality="text"`. -The semantic input name is the main contract; source details such as path, URL, -bytes, decoded tensor layout, accepted suffixes, or file schema belong in -adapter validation or `metadata`. - -Fourth, schema objects need open-ended metadata for future adapters. This lets a -SANA-WM-like adapter advertise that `camera_trajectory_c2w` uses an -`[F,4,4]` OpenCV camera-to-world sequence, or lets another model advertise a -schema URI, units, coordinate frame, accepted file suffixes, cardinality hints, -or adapter notes. Metadata should remain query information and should not become -the compatibility type system. - -Fifth, `UserInputSchema` describes raw source capabilities, `CanonicalModality` -describes what an application consumes, and mapping schemas describe derived -model-facing semantics. A browser may provide `key_down`, `key_up`, -`prompt_set`, and `initial_frame_set` events. Those become canonical modalities -such as `driver_command` or `conditioning_prompt`; whether they can then drive -`steering`, `camera_trajectory`, or text embeddings depends on the -selected mapping and model schema. - -## Implemented T2/T3 Shape - -The implementation that came out of this inventory is: - -1. Keep `UserInputEvent` and `UserInputs` as the raw event API, sliced by a - half-open `TimeWindow`. Static session-start values remain timestamp-zero - events. -2. Keep `UserInputSchema` lightweight and source-facing. `event_types` declares - that an event type exists; `UserInputCapability` additionally pins the - payload fields it carries. -3. Add a canonical layer between raw and encoded. `CanonicalModality` names a - device-independent input and its payload fields; `InputCanonicalizer` - registers per-device converters and produces `CanonicalInputs`. Applications - and mappings consume canonical inputs and never read raw device events. -4. Split `InferenceInput` (formerly `ModelInputs`) into `global_conditioning` - and `step`. Global conditioning is session-global state; `step` is the - payload for one generated chunk or frame window. -5. Keep `InputField.input_modality`, `frequency_consumed`, and `metadata` as - lightweight query hints, while leaving tensor shape and model-specific - validation to adapters and sessions. `InputField.name` remains the semantic - payload key. -6. Keep `InputMappingSchema` as the canonical-to-encoded boundary, with - mapping-set compatibility helpers for composed mappings. -7. Keep input names, input modalities, and metadata open-ended. - Adding a new model should usually mean adding adapter-owned schema - declarations and mappings, not changing the core input dataclasses. -8. Leave deep validation to model adapters, sessions, and mappings. The schema - layer catches obvious source/mapping/model mismatches before expensive - runtime initialization; it does not validate every tensor and coordinate - convention. - -See `docs/inference_runtime_inputs_implementation.md` for the resulting API. - -## Extensibility Contract - -The inventory above is not a vocabulary freeze. The core API does not contain a -closed enum of allowed input names. New adapters can introduce semantic field -names that match the model boundary they own. - -Use these conventions when adding future model schemas: - -- Prefer semantic names over modality names, such as `camera_trajectory_c2w` - instead of `array`, or `hdmap_frames` instead of `image`. -- Use `input_modality` for a coarse value-kind hint, such as `text`, `image`, - `embedding`, `c2w_sequence`, or `intrinsics_vec4_sequence`. -- Use `metadata` for representation details such as paths, decoded tensor - layout, units, coordinate frame, shape summary, accepted suffixes, schema URI, - model family, value ranges, or cardinality. -- Use `frequency_consumed` for adapter-consumption cadence, such as `once` or - `per_step`; keep it independent from whether the field is declared under - `global_conditioning_fields` or `step_fields`. -- Keep deep validation in the adapter/mapping. The lightweight schemas answer - whether the selected source and mapping can plausibly drive the model before - expensive initialization. - -## Representative Schema Sketches - -These are not migration work for T4+, but they show that the current primitives -can describe the supported input surfaces. All use -`flashdreams.runtime.InferenceInputSchema` and `InputField`. - -```python -lingbot_model = InferenceInputSchema( - description="lingbot-world", - global_conditioning_fields=( - InputField(name="prompt", input_modality="text", frequency_consumed="once"), - InputField( - name="global_conditioning_frame", - input_modality="image", - frequency_consumed="once", - ), - InputField( - name="text_embeddings", - required=False, - input_modality="embedding", - frequency_consumed="once", - ), - ), - step_fields=( - InputField(name="camera_trajectory", frequency_consumed="per_step"), - ), -) -``` - -```python -omnidreams_model = InferenceInputSchema( - description="omnidreams", - global_conditioning_fields=( - InputField(name="prompts", input_modality="text", frequency_consumed="once"), - InputField( - name="global_conditioning_frames", - input_modality="image", - frequency_consumed="once", - ), - InputField(name="view_names", frequency_consumed="once"), - InputField( - name="text_embeddings", - required=False, - input_modality="embedding", - frequency_consumed="once", - ), - InputField( - name="image_embeddings", - required=False, - input_modality="embedding", - frequency_consumed="once", - ), - ), - step_fields=( - InputField(name="hdmap_frames", frequency_consumed="per_step"), - ), -) -``` - -```python -hy_worldplay_model = InferenceInputSchema( - description="hy-worldplay", - global_conditioning_fields=( - InputField(name="prompt", input_modality="text", frequency_consumed="once"), - InputField( - name="global_conditioning_frame", - input_modality="image", - frequency_consumed="once", - ), - InputField(name="action_labels", frequency_consumed="per_step"), - InputField(name="camera_viewmats", frequency_consumed="per_step"), - InputField(name="camera_intrinsics", frequency_consumed="per_step"), - InputField(name="memory_config", frequency_consumed="per_step"), - ), -) -``` - -```python -sana_wm_model = InferenceInputSchema( - description="sana-wm", - global_conditioning_fields=( - InputField(name="prompt", input_modality="text", frequency_consumed="once"), - InputField( - name="negative_prompt", - required=False, - input_modality="text", - frequency_consumed="once", - ), - InputField( - name="global_conditioning_frame", - input_modality="image", - frequency_consumed="once", - ), - InputField( - name="camera_trajectory_c2w", - input_modality="c2w_sequence", - frequency_consumed="per_step", - metadata={"shape": "[F,4,4]", "coordinates": "opencv_c2w"}, - ), - InputField( - name="camera_intrinsics_vec4", - required=False, - input_modality="intrinsics_vec4_sequence", - frequency_consumed="per_step", - metadata={"shape": "[F,4]"}, - ), - ), -) -``` - -SANA-WM's `stage1_sampling` and `streaming_chunking` are deliberately absent -above. They describe how to run the model rather than what conditions it, so -they belong in `InferenceConfig`, not in an input schema. Flagged here because -the runner currently threads them alongside the conditioning inputs. diff --git a/flashdreams/flashdreams/runtime/README.md b/flashdreams/flashdreams/runtime/README.md new file mode 100644 index 000000000..e69de29bb diff --git a/flashdreams/flashdreams/runtime/__init__.py b/flashdreams/flashdreams/runtime/__init__.py deleted file mode 100644 index 01a2f9c76..000000000 --- a/flashdreams/flashdreams/runtime/__init__.py +++ /dev/null @@ -1,117 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Experimental inference runtime API envelope. - -This package defines the small v0 boundary above ``flashdreams.infra``. It is -intentionally additive while integrations migrate onto it. -""" - -from flashdreams.runtime.canonical import ( - DEFAULT_DRIVING_BINDINGS, - DRIVER_COMMAND, - DeviceConverter, - DeviceConverterSchema, - InputCanonicalizer, - KeyboardToDriverCommand, - ScriptedModality, -) -from flashdreams.runtime.config import ExecutionBackend, InferenceConfig, Precision -from flashdreams.runtime.inference_session import ( - InferenceInput, - InferenceInputSchema, - InferenceOutput, - InferenceSessionConfig, -) -from flashdreams.runtime.inputs import ( - INPUT_PHASES, - CanonicalInputs, - CanonicalInputSchema, - CanonicalModality, - InputField, - InputPhase, - TimeWindow, - UserInputCapability, - UserInputEvent, - UserInputs, - UserInputSchema, - validate_phase, -) -from flashdreams.runtime.interfaces import ( - InferenceRuntime, - InferenceSession, - ModelAdapter, -) -from flashdreams.runtime.mapping import ( - DeclaresMappingSchema, - IdentityInputMapping, - InputMapping, - InputMappingSchema, - MappingCompatibility, - check_mapping_compatibility, - check_mapping_set_compatibility, - combine_mapping_schemas, - undeclared_inference_inputs, -) -from flashdreams.runtime.metrics import ( - InMemoryMetricsRecorder, - MetricsRecorder, - NullMetricsRecorder, - RuntimeMetricSample, -) -from flashdreams.runtime.output import NullOutputTarget, OutputArtifact, OutputTarget -from flashdreams.runtime.runner import run_inference_session -from flashdreams.runtime.types import StepRequest, StepResult -from flashdreams.runtime.video_output import Mp4VideoOutputTarget - -__all__ = [ - "CanonicalInputs", - "CanonicalInputSchema", - "CanonicalModality", - "check_mapping_compatibility", - "check_mapping_set_compatibility", - "combine_mapping_schemas", - "DeclaresMappingSchema", - "DEFAULT_DRIVING_BINDINGS", - "DeviceConverter", - "DeviceConverterSchema", - "DRIVER_COMMAND", - "ExecutionBackend", - "IdentityInputMapping", - "InferenceConfig", - "InferenceInput", - "InferenceInputSchema", - "InferenceOutput", - "InferenceRuntime", - "InferenceSession", - "InferenceSessionConfig", - "InMemoryMetricsRecorder", - "INPUT_PHASES", - "InputCanonicalizer", - "InputField", - "InputMapping", - "InputMappingSchema", - "InputPhase", - "KeyboardToDriverCommand", - "MappingCompatibility", - "MetricsRecorder", - "ModelAdapter", - "Mp4VideoOutputTarget", - "NullMetricsRecorder", - "NullOutputTarget", - "OutputArtifact", - "OutputTarget", - "Precision", - "RuntimeMetricSample", - "ScriptedModality", - "StepRequest", - "StepResult", - "TimeWindow", - "run_inference_session", - "undeclared_inference_inputs", - "UserInputCapability", - "UserInputEvent", - "UserInputs", - "UserInputSchema", - "validate_phase", -] diff --git a/flashdreams/flashdreams/runtime/_utils.py b/flashdreams/flashdreams/runtime/_utils.py deleted file mode 100644 index d8016c6b7..000000000 --- a/flashdreams/flashdreams/runtime/_utils.py +++ /dev/null @@ -1,17 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Small helpers shared by the experimental runtime API.""" - -from __future__ import annotations - -from collections.abc import Mapping -from types import MappingProxyType -from typing import TypeVar - -ValueT = TypeVar("ValueT") - - -def freeze_mapping(value: Mapping[str, ValueT]) -> Mapping[str, ValueT]: - """Return a read-only shallow copy of ``value``.""" - return MappingProxyType(dict(value)) diff --git a/flashdreams/flashdreams/runtime/canonical.py b/flashdreams/flashdreams/runtime/canonical.py deleted file mode 100644 index 55f333ce7..000000000 --- a/flashdreams/flashdreams/runtime/canonical.py +++ /dev/null @@ -1,387 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Raw device input to canonical modality conversion. - -This is the ``raw input -> canonicalized input`` leg. Applications consume -:class:`~flashdreams.runtime.inputs.CanonicalInputs`; they never read raw device -events. Adding a keyboard, gamepad, or force-feedback wheel is therefore a -:meth:`InputCanonicalizer.register` call that touches no application, mapping, -or model code. - -Converters are stateful, because HID input is edge-triggered while per-step -conditioning is level-triggered: a key held across a step emits no events yet -still means full throttle. Feed windows in session order and call -:meth:`InputCanonicalizer.reset` at a rollout boundary; replaying the same -window sequence then reproduces the same canonical inputs. - -This layer covers live user control only. Global conditioning such as a prompt -or conditioning frame is application-owned and reaches ``InferenceInput`` -directly, without passing through canonicalization. -""" - -from __future__ import annotations - -from collections.abc import Iterable, Mapping, Sequence -from dataclasses import dataclass, field -from types import MappingProxyType -from typing import Any, Protocol, runtime_checkable - -from flashdreams.runtime._utils import freeze_mapping -from flashdreams.runtime.inputs import ( - CanonicalInputs, - CanonicalInputSchema, - CanonicalModality, - TimeWindow, - UserInputCapability, - UserInputs, - UserInputSchema, -) -from flashdreams.serving.realtime.input import KeyboardState, normalize_key - -DriverBindings = Mapping[str, frozenset[str]] - -DEFAULT_DRIVING_BINDINGS: DriverBindings = MappingProxyType( - { - "throttle": frozenset({"w", "up"}), - "brake": frozenset({"s", "down"}), - "steer_left": frozenset({"a", "left"}), - "steer_right": frozenset({"d", "right"}), - "stop": frozenset({"space"}), - "reverse": frozenset(), - } -) -"""Default key bindings for :class:`KeyboardToDriverCommand`. - -Bindings are data so a layout can be rebound without editing the converter, and -so the set of tracked keys is derived from them rather than declared twice. -""" - -_DRIVER_ACTIONS = frozenset(DEFAULT_DRIVING_BINDINGS) - - -@dataclass(frozen=True, kw_only=True, slots=True) -class DeviceConverterSchema: - """Metadata for one device-to-canonical-modality converter.""" - - name: str - produces: CanonicalModality - consumes: tuple[UserInputCapability, ...] = () - device_kind: str | None = None - priority: int = 0 - metadata: Mapping[str, Any] = field( - default_factory=dict, - compare=False, - hash=False, - ) - - def __post_init__(self) -> None: - if not self.name.strip(): - raise ValueError("DeviceConverterSchema.name must be non-empty.") - if not isinstance(self.produces, CanonicalModality): - raise TypeError("produces must be a CanonicalModality object.") - object.__setattr__(self, "metadata", freeze_mapping(self.metadata)) - - -@runtime_checkable -class DeviceConverter(Protocol): - """Contract for turning one device's raw events into a canonical modality.""" - - @property - def schema(self) -> DeviceConverterSchema: - """Return converter metadata used for source selection.""" - ... - - def reset(self) -> None: - """Drop accumulated device state at a session or rollout boundary.""" - ... - - def convert( - self, - user_inputs: UserInputs, - window: TimeWindow, - ) -> Mapping[str, Any] | None: - """Return the modality value for ``window``, or ``None`` if inactive. - - ``user_inputs`` is already filtered to ``window``. Returning ``None`` - lets a present-but-idle device yield to a lower-priority one. - """ - ... - - -DRIVER_COMMAND = CanonicalModality( - name="driver_command", - payload_fields=frozenset({"throttle", "brake", "steer", "stop", "reverse"}), - description=( - "Normalized driving intent. throttle/brake are in [0, 1], steer is in " - "[-1, 1] with positive meaning left." - ), -) - - -class KeyboardToDriverCommand: - """Convert keyboard edges into :data:`DRIVER_COMMAND` level state. - - Mirrors the mapping the Omnidreams interactive-drive keyboard backend - already uses, so a keyboard reaches a model through the shared layer with - the same semantics it has today. - """ - - def __init__( - self, - *, - name: str = "keyboard-to-driver-command", - bindings: DriverBindings = DEFAULT_DRIVING_BINDINGS, - priority: int = 0, - ) -> None: - unknown = sorted(set(bindings) - _DRIVER_ACTIONS) - if unknown: - raise ValueError( - f"Unknown driver actions in bindings: {unknown}. " - f"Supported actions: {sorted(_DRIVER_ACTIONS)}." - ) - self._bindings = { - action: frozenset(normalize_key(key) for key in bindings.get(action, ())) - for action in _DRIVER_ACTIONS - } - # Tracked keys are derived, so they cannot drift from the bindings and - # silently make an action unreachable. - self._supported_keys = frozenset( - key for keys in self._bindings.values() for key in keys - ) - self._state = KeyboardState(supported_keys=self._supported_keys) - self._schema = DeviceConverterSchema( - name=name, - produces=DRIVER_COMMAND, - device_kind="keyboard", - priority=priority, - consumes=( - UserInputCapability( - event_type="key_down", - payload_fields=frozenset({"key"}), - ), - UserInputCapability( - event_type="key_up", - payload_fields=frozenset({"key"}), - ), - ), - ) - - @property - def schema(self) -> DeviceConverterSchema: - return self._schema - - def reset(self) -> None: - self._state = KeyboardState(supported_keys=self._supported_keys) - - def convert( - self, - user_inputs: UserInputs, - window: TimeWindow, - ) -> Mapping[str, Any] | None: - del window - for event in user_inputs.events: - if event.event_type not in {"key_down", "key_up"}: - continue - key = event.payload.get("key") - if not isinstance(key, str): - continue - self._state.apply_event( - event="keydown" if event.event_type == "key_down" else "keyup", - key=key, - ) - - pressed = {normalize_key(key) for key in self._state.snapshot()} - - def held(action: str) -> bool: - return bool(self._bindings[action] & pressed) - - steer = 0.0 - if held("steer_left"): - steer += 1.0 - if held("steer_right"): - steer -= 1.0 - return DRIVER_COMMAND.value( - { - "throttle": 1.0 if held("throttle") else 0.0, - "brake": 1.0 if held("brake") else 0.0, - "steer": steer, - "stop": held("stop"), - "reverse": held("reverse"), - } - ) - - -class ScriptedModality: - """Emit pre-authored canonical values, for benchmarks, replay, and tests. - - Mocking input should not require knowing the raw device vocabulary. This - converter consumes no raw capabilities, so it is feedable by any source - -- including an empty :class:`UserInputSchema` -- and application code is - identical between a real run and a scripted one. - - ``timeline`` is ``(start_s, value)`` pairs. Values are level-triggered and - held until the next entry begins, matching how live converters behave. An - entry applies to a window once it has begun by the window's end, and - ``None`` is returned for windows before the first entry. - """ - - def __init__( - self, - *, - modality: CanonicalModality, - timeline: Sequence[tuple[float, Mapping[str, Any]]], - name: str | None = None, - device_kind: str | None = "scripted", - priority: int = 0, - ) -> None: - entries = tuple(sorted(timeline, key=lambda entry: entry[0])) - for start_s, value in entries: - if start_s < 0: - raise ValueError("timeline start_s must be >= 0.") - modality.value(value) - self._entries = tuple( - (start_s, modality.value(value)) for start_s, value in entries - ) - self._modality = modality - self._schema = DeviceConverterSchema( - name=name or f"scripted-{modality.name}", - produces=modality, - device_kind=device_kind, - priority=priority, - ) - - @property - def schema(self) -> DeviceConverterSchema: - return self._schema - - def reset(self) -> None: - # The timeline is a pure function of the window, so replay is - # deterministic without any state to clear. - return None - - def convert( - self, - user_inputs: UserInputs, - window: TimeWindow, - ) -> Mapping[str, Any] | None: - del user_inputs - current: Mapping[str, Any] | None = None - for start_s, value in self._entries: - if start_s < window.end_s: - current = value - else: - break - return current - - -class InputCanonicalizer: - """Registry of device converters plus the raw-to-canonical rewrite. - - Registration is the whole extension point: a new device is a converter - registered against an existing modality, and a new modality is a converter - registered with a new :class:`CanonicalModality`. - """ - - def __init__(self, converters: Iterable[DeviceConverter] = ()) -> None: - self._converters: list[DeviceConverter] = [] - for converter in converters: - self.register(converter) - - def register(self, converter: DeviceConverter) -> None: - """Register one device converter.""" - if not isinstance(converter, DeviceConverter): - raise TypeError("converter must implement the DeviceConverter protocol.") - name = converter.schema.name - if any(existing.schema.name == name for existing in self._converters): - raise ValueError( - f"A device converter named {name!r} is already registered." - ) - self._converters.append(converter) - - @property - def converters(self) -> tuple[DeviceConverter, ...]: - """Return every registered converter.""" - return tuple(self._converters) - - def reset(self) -> None: - """Reset every registered converter's device state.""" - for converter in self._converters: - converter.reset() - - def converters_for( - self, - source_schema: UserInputSchema, - ) -> tuple[DeviceConverter, ...]: - """Return converters this source can feed, highest priority first.""" - feedable = [ - converter - for converter in self._converters - if all( - source_schema.supports(capability) - for capability in converter.schema.consumes - ) - ] - # Sort is stable, so equal-priority converters keep registration order. - return tuple(sorted(feedable, key=lambda each: -each.schema.priority)) - - def unavailable_converters( - self, - source_schema: UserInputSchema, - ) -> tuple[DeviceConverter, ...]: - """Return converters this source cannot feed, for diagnostics.""" - feedable = {id(converter) for converter in self.converters_for(source_schema)} - return tuple( - converter for converter in self._converters if id(converter) not in feedable - ) - - def canonical_schema( - self, - source_schema: UserInputSchema, - ) -> CanonicalInputSchema: - """Return the canonical modalities this raw source can supply. - - This is the boundary an application declares against. A mapping that - consumes ``driver_command`` then matches a keyboard source, a wheel - source, or any device registered later. - """ - modalities: list[CanonicalModality] = [] - for converter in self.converters_for(source_schema): - modality = converter.schema.produces - if modality not in modalities: - modalities.append(modality) - return CanonicalInputSchema( - modalities=tuple(modalities), - description=source_schema.description, - ) - - def canonicalize( - self, - user_inputs: UserInputs, - *, - window: TimeWindow, - source_schema: UserInputSchema, - ) -> CanonicalInputs: - """Convert one raw window into canonical inputs. - - Every feedable converter sees the window so its device state stays - current even while another device has precedence; that way unplugging - the higher-priority device does not resume from stale state. Among - converters producing the same modality, the highest-priority one that - returned a value wins. - """ - windowed = user_inputs.window(window) - values: dict[str, Any] = {} - sources: dict[str, str] = {} - for converter in self.converters_for(source_schema): - value = converter.convert(windowed, window) - modality = converter.schema.produces - if value is not None and modality.name not in values: - values[modality.name] = value - if converter.schema.device_kind is not None: - sources[modality.name] = converter.schema.device_kind - - metadata: dict[str, Any] = {} - if sources: - metadata["canonical_sources"] = freeze_mapping(sources) - return CanonicalInputs(values=values, metadata=metadata) diff --git a/flashdreams/flashdreams/runtime/config.py b/flashdreams/flashdreams/runtime/config.py deleted file mode 100644 index 4b8752f13..000000000 --- a/flashdreams/flashdreams/runtime/config.py +++ /dev/null @@ -1,76 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Runtime-facing configuration envelope.""" - -from __future__ import annotations - -from collections.abc import Mapping -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any, Literal - -from flashdreams.runtime._utils import freeze_mapping - -ExecutionBackend = Literal["local", "local-distributed", "external", "hosted"] -"""Where and how inference compute is run.""" - -Precision = Literal["auto", "fp32", "fp16", "bf16"] -"""Coarse runtime precision choices.""" - - -@dataclass(frozen=True, kw_only=True, slots=True) -class InferenceConfig: - """Runtime settings that affect model execution. - - Prompts, user controls, browser settings, output paths, and benchmark - directories intentionally live outside this object. The typed optimization - fields cover common cross-backend knobs; open-ended adapter-specific choices - can use :attr:`runtime_options`. - """ - - __hash__ = None - - model_id: str - """Stable identity for the model adapter or runtime integration.""" - - preset_id: str | None = None - """Optional preset identity under :attr:`model_id`.""" - - checkpoint: str | Path | None = None - """Optional checkpoint or model-asset selector understood by the adapter.""" - - backend: ExecutionBackend = "local" - """Execution placement and backend family for inference compute.""" - - device: str | None = None - """Optional device selector such as ``cuda`` or ``cuda:0``; ``None`` leaves placement to the adapter/backend.""" - - precision: Precision = "auto" - """Preferred compute precision.""" - - compile: bool | None = None - """Optional - Whether model compilation is requested or disabled. `None` means left to the adapter to decide.""" - - cuda_graph: bool | None = None - """Optional - Whether CUDA graph capture is requested or disabled. `None` means left to the adapter to decide.""" - - attention_backend: str | None = None - """Optional attention implementation selector; ``None`` leaves the choice to the adapter.""" - - cache_policy: str | None = None - """Optional cache policy selector; ``None`` leaves the choice to the adapter.""" - - runtime_options: Mapping[str, Any] = field(default_factory=dict) - """Adapter/backend-specific runtime options.""" - - resource_hints: Mapping[str, Any] = field(default_factory=dict) - """Resource hints for launchers, schedulers, or hosted backends.""" - - def __post_init__(self) -> None: - if not self.model_id.strip(): - raise ValueError("InferenceConfig.model_id must be non-empty.") - object.__setattr__( - self, "runtime_options", freeze_mapping(self.runtime_options) - ) - object.__setattr__(self, "resource_hints", freeze_mapping(self.resource_hints)) diff --git a/flashdreams/flashdreams/runtime/demo/__init__.py b/flashdreams/flashdreams/runtime/demo/__init__.py deleted file mode 100644 index 3d9d99919..000000000 --- a/flashdreams/flashdreams/runtime/demo/__init__.py +++ /dev/null @@ -1,31 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Experimental shared demo API above the inference runtime API.""" - -from flashdreams.runtime.demo.app import run_flashdreams_demo, serve_flashdreams_demo -from flashdreams.runtime.demo.outputs import build_output_target -from flashdreams.runtime.demo.replay import run_replay_demo -from flashdreams.runtime.demo.spec import ( - DemoAdapter, - DemoSpec, - Mp4OutputSpec, - NullOutputSpec, - OutputSpec, - PreparedScenario, - WebRTCOutputSpec, -) - -__all__ = [ - "DemoAdapter", - "DemoSpec", - "Mp4OutputSpec", - "NullOutputSpec", - "OutputSpec", - "PreparedScenario", - "WebRTCOutputSpec", - "build_output_target", - "run_flashdreams_demo", - "run_replay_demo", - "serve_flashdreams_demo", -] diff --git a/flashdreams/flashdreams/runtime/demo/app.py b/flashdreams/flashdreams/runtime/demo/app.py deleted file mode 100644 index 7659859b4..000000000 --- a/flashdreams/flashdreams/runtime/demo/app.py +++ /dev/null @@ -1,36 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Experimental shared demo entrypoints.""" - -from __future__ import annotations - -from typing import Any - -from .replay import run_replay_demo -from .spec import DemoAdapter, DemoSpec - - -def run_flashdreams_demo( - *, - spec: DemoSpec, - adapter: DemoAdapter, - **kwargs: Any, -) -> object: - """Run a synchronous replay demo through the shared runtime runner.""" - return run_replay_demo(spec=spec, adapter=adapter, **kwargs) - - -def serve_flashdreams_demo( - *, - spec: DemoSpec, - adapter: DemoAdapter, - **kwargs: Any, -) -> object: - """Serve a WebRTC demo through the shared serving manager.""" - from .webrtc import serve_webrtc_demo - - return serve_webrtc_demo(spec=spec, adapter=adapter, **kwargs) - - -__all__ = ["run_flashdreams_demo", "serve_flashdreams_demo"] diff --git a/flashdreams/flashdreams/runtime/demo/outputs.py b/flashdreams/flashdreams/runtime/demo/outputs.py deleted file mode 100644 index 421ec3bb4..000000000 --- a/flashdreams/flashdreams/runtime/demo/outputs.py +++ /dev/null @@ -1,45 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Shared demo output-target construction.""" - -from __future__ import annotations - -from pathlib import Path - -from flashdreams.runtime.output import NullOutputTarget, OutputTarget -from flashdreams.runtime.video_output import Mp4VideoOutputTarget, VideoWriter - -from .spec import Mp4OutputSpec, NullOutputSpec, OutputSpec, WebRTCOutputSpec - - -def build_output_target( - output: OutputSpec, - *, - mp4_writer: VideoWriter | None = None, -) -> OutputTarget: - """Build a replay output target from a demo output spec.""" - if isinstance(output, NullOutputSpec): - return NullOutputTarget(store_results=output.store_results) - if isinstance(output, Mp4OutputSpec): - output_path = Path(output.path) - if mp4_writer is not None: - return Mp4VideoOutputTarget( - output_path=output_path, - fps=output.fps, - output_layout=output.output_layout, - writer=mp4_writer, - move_to_cpu=output.move_to_cpu, - ) - return Mp4VideoOutputTarget( - output_path=output_path, - fps=output.fps, - output_layout=output.output_layout, - move_to_cpu=output.move_to_cpu, - ) - if isinstance(output, WebRTCOutputSpec): - raise ValueError("WebRTC output does not create a replay OutputTarget.") - raise TypeError(f"Unsupported demo output spec: {type(output).__name__}.") - - -__all__ = ["build_output_target"] diff --git a/flashdreams/flashdreams/runtime/demo/replay.py b/flashdreams/flashdreams/runtime/demo/replay.py deleted file mode 100644 index 18b873254..000000000 --- a/flashdreams/flashdreams/runtime/demo/replay.py +++ /dev/null @@ -1,93 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Shared replay demo runner.""" - -from __future__ import annotations - -from collections.abc import Callable, Sequence - -from flashdreams.runtime.metrics import MetricsRecorder, NullMetricsRecorder -from flashdreams.runtime.output import OutputArtifact, OutputTarget -from flashdreams.runtime.runner import run_inference_session - -from .outputs import build_output_target -from .spec import DemoAdapter, DemoSpec, OutputSpec, WebRTCOutputSpec - -OutputTargetFactory = Callable[[OutputSpec], OutputTarget] -InferenceSessionRunner = Callable[..., Sequence[OutputArtifact]] - - -def run_replay_demo( - *, - spec: DemoSpec, - adapter: DemoAdapter, - output_target_factory: OutputTargetFactory = build_output_target, - metrics: MetricsRecorder | None = None, - runner: InferenceSessionRunner = run_inference_session, -) -> tuple[OutputArtifact, ...]: - """Run one prepared demo scenario through the shared runtime runner.""" - _require_supported_mode( - mode=spec.input_mode, - supported=adapter.supported_input_modes(), - label="input_mode", - ) - if spec.input_mode != "replay": - raise ValueError( - "run_replay_demo requires input_mode='replay', " - f"got input_mode={spec.input_mode!r}." - ) - _require_supported_mode( - mode=spec.output.mode, - supported=adapter.supported_output_modes(), - label="output.mode", - ) - if isinstance(spec.output, WebRTCOutputSpec): - raise ValueError("run_replay_demo does not support WebRTC output.") - - prepared = adapter.prepare_scenario(spec) - mapping = prepared.mapping or adapter.default_input_mapping() - if mapping is None: - raise ValueError( - "Demo scenario did not provide an input mapping, and the adapter " - "has no default input mapping." - ) - if spec.config is None: - raise RuntimeError("DemoSpec.config was not initialized.") - - output = output_target_factory(spec.output) - metrics_recorder = metrics or NullMetricsRecorder() - return tuple( - runner( - adapter=adapter, - config=spec.config, - mapping=mapping, - canonicalizer=prepared.canonicalizer, - source_schema=prepared.source_schema, - user_inputs=prepared.user_inputs, - initial_inputs=prepared.initial_inputs, - output=output, - metrics=metrics_recorder, - ) - ) - - -def _require_supported_mode( - *, - mode: str, - supported: tuple[str, ...], - label: str, -) -> None: - if mode in supported: - return - supported_text = ", ".join(repr(each) for each in supported) or "" - raise ValueError( - f"Unsupported demo {label}={mode!r}; supported modes: {supported_text}." - ) - - -__all__ = [ - "InferenceSessionRunner", - "OutputTargetFactory", - "run_replay_demo", -] diff --git a/flashdreams/flashdreams/runtime/demo/spec.py b/flashdreams/flashdreams/runtime/demo/spec.py deleted file mode 100644 index bc2884ab3..000000000 --- a/flashdreams/flashdreams/runtime/demo/spec.py +++ /dev/null @@ -1,176 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Experimental shared demo API data shapes.""" - -from __future__ import annotations - -from collections.abc import Mapping -from dataclasses import dataclass, field, replace -from pathlib import Path -from typing import Any, Literal, Protocol, TypeAlias - -from flashdreams.infra.postprocess import VideoTensorLayout -from flashdreams.runtime._utils import freeze_mapping -from flashdreams.runtime.canonical import InputCanonicalizer -from flashdreams.runtime.config import InferenceConfig -from flashdreams.runtime.inputs import InferenceInput, UserInputs, UserInputSchema -from flashdreams.runtime.interfaces import ModelAdapter -from flashdreams.runtime.mapping import InputMapping - - -@dataclass(frozen=True, kw_only=True, slots=True) -class NullOutputSpec: - """Headless/null replay output.""" - - mode: Literal["null"] = "null" - store_results: bool = False - - -@dataclass(frozen=True, kw_only=True, slots=True) -class Mp4OutputSpec: - """MP4 replay output.""" - - path: str | Path - fps: int | float - mode: Literal["mp4"] = "mp4" - output_layout: VideoTensorLayout = "bvtchw" - move_to_cpu: bool = True - - def __post_init__(self) -> None: - if float(self.fps) <= 0: - raise ValueError("Mp4OutputSpec.fps must be > 0.") - object.__setattr__(self, "path", Path(self.path)) - - -@dataclass(frozen=True, kw_only=True, slots=True) -class WebRTCOutputSpec: - """Shared WebRTC serving output.""" - - mode: Literal["webrtc"] = "webrtc" - host: str = "127.0.0.1" - port: int = 8080 - fps: int = 30 - video_width: int = 1280 - video_height: int = 720 - warmup_chunks: int = 0 - warmup_timeout_s: float = 30.0 - client_liveness_timeout_s: float = 30.0 - web_dir: str | Path | None = None - request_session_path: str = "/request_session" - preload_name: str | None = None - - def __post_init__(self) -> None: - if not self.host.strip(): - raise ValueError("WebRTCOutputSpec.host must be non-empty.") - if not (0 < int(self.port) < 65536): - raise ValueError("WebRTCOutputSpec.port must be between 1 and 65535.") - if self.fps <= 0: - raise ValueError("WebRTCOutputSpec.fps must be > 0.") - if self.video_width <= 0 or self.video_height <= 0: - raise ValueError("WebRTCOutputSpec video dimensions must be > 0.") - if self.warmup_chunks < 0: - raise ValueError("WebRTCOutputSpec.warmup_chunks must be >= 0.") - if self.warmup_timeout_s <= 0: - raise ValueError("WebRTCOutputSpec.warmup_timeout_s must be > 0.") - if self.client_liveness_timeout_s <= 0: - raise ValueError("WebRTCOutputSpec.client_liveness_timeout_s must be > 0.") - if not self.request_session_path.startswith("/"): - raise ValueError( - "WebRTCOutputSpec.request_session_path must start with '/'." - ) - if self.web_dir is not None: - object.__setattr__(self, "web_dir", Path(self.web_dir)) - - -OutputSpec: TypeAlias = NullOutputSpec | Mp4OutputSpec | WebRTCOutputSpec - - -@dataclass(frozen=True, kw_only=True, slots=True) -class DemoSpec: - """User-facing shared demo run description.""" - - __hash__ = None - - model_id: str - input_mode: str - output: OutputSpec - preset_id: str | None = None - scenario: Any | None = None - config: InferenceConfig | None = None - metadata: Mapping[str, Any] = field(default_factory=dict) - - def __post_init__(self) -> None: - if not self.model_id.strip(): - raise ValueError("DemoSpec.model_id must be non-empty.") - if not self.input_mode.strip(): - raise ValueError("DemoSpec.input_mode must be non-empty.") - config = self.config - if config is None: - config = InferenceConfig( - model_id=self.model_id, - preset_id=self.preset_id, - ) - else: - if config.model_id != self.model_id: - raise ValueError( - "DemoSpec.model_id must match InferenceConfig.model_id." - ) - if self.preset_id is None: - object.__setattr__(self, "preset_id", config.preset_id) - elif config.preset_id is None: - config = replace(config, preset_id=self.preset_id) - elif config.preset_id != self.preset_id: - raise ValueError( - "DemoSpec.preset_id must match InferenceConfig.preset_id." - ) - object.__setattr__(self, "config", config) - object.__setattr__(self, "metadata", freeze_mapping(self.metadata)) - - -@dataclass(frozen=True, kw_only=True, slots=True) -class PreparedScenario: - """Runtime-ready scenario prepared by a model demo adapter.""" - - __hash__ = None - - initial_inputs: InferenceInput - user_inputs: UserInputs = field(default_factory=UserInputs) - source_schema: UserInputSchema = field(default_factory=UserInputSchema) - canonicalizer: InputCanonicalizer = field(default_factory=InputCanonicalizer) - mapping: InputMapping | None = None - metadata: Mapping[str, Any] = field(default_factory=dict) - - def __post_init__(self) -> None: - object.__setattr__(self, "metadata", freeze_mapping(self.metadata)) - - -class DemoAdapter(ModelAdapter, Protocol): - """Model-owned adapter surface consumed by shared demo launchers.""" - - def supported_input_modes(self) -> tuple[str, ...]: - """Return demo input modes this adapter can prepare.""" - ... - - def supported_output_modes(self) -> tuple[str, ...]: - """Return demo output modes this adapter can run.""" - ... - - def prepare_scenario(self, spec: DemoSpec) -> PreparedScenario: - """Validate and materialize scenario inputs before runtime creation.""" - ... - - def create_webrtc_runtime(self, spec: DemoSpec) -> Any: - """Create the model-owned runtime consumed by the shared WebRTC manager.""" - ... - - -__all__ = [ - "DemoAdapter", - "DemoSpec", - "Mp4OutputSpec", - "NullOutputSpec", - "OutputSpec", - "PreparedScenario", - "WebRTCOutputSpec", -] diff --git a/flashdreams/flashdreams/runtime/demo/webrtc.py b/flashdreams/flashdreams/runtime/demo/webrtc.py deleted file mode 100644 index f93a855db..000000000 --- a/flashdreams/flashdreams/runtime/demo/webrtc.py +++ /dev/null @@ -1,274 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Shared WebRTC demo construction.""" - -from __future__ import annotations - -from collections.abc import Callable -from dataclasses import dataclass -from pathlib import Path -from typing import Any - -from aiohttp import web - -from flashdreams.serving.webrtc.bootstrap import run_webrtc_server -from flashdreams.serving.webrtc.manager import BaseWebRTCSessionManager -from flashdreams.serving.webrtc.server import create_webrtc_app - -from .replay import _require_supported_mode -from .spec import DemoAdapter, DemoSpec, WebRTCOutputSpec - - -@dataclass(frozen=True, kw_only=True, slots=True) -class WebRTCDemoRuntimeConfig: - """Runtime config consumed by the shared WebRTC session manager.""" - - video_width: int - video_height: int - warmup_chunks: int - warmup_timeout_s: float - - -class SharedDemoWebRTCSessionManager(BaseWebRTCSessionManager[Any, Any]): - """Generic session manager wrapper for demo adapters.""" - - def __init__( - self, - *, - model_name: str, - runtime: Any, - runtime_config: Any, - fps: int, - client_liveness_timeout_s: float, - ) -> None: - self._demo_model_name = model_name - super().__init__( - runtime=runtime, - runtime_config=runtime_config, - fps=fps, - client_liveness_timeout_s=client_liveness_timeout_s, - ) - - def _model_name(self) -> str: - return self._demo_model_name - - -@dataclass(frozen=True, kw_only=True, slots=True) -class WebRTCDemo: - """Constructed WebRTC demo pieces, before or after serving.""" - - runtime: Any - runtime_config: Any - session_manager: BaseWebRTCSessionManager[Any, Any] - app: web.Application | None - host: str - port: int - - -CreateWebRTCApp = Callable[..., web.Application] -RunWebRTCServer = Callable[..., None] - - -def build_webrtc_demo( - *, - spec: DemoSpec, - adapter: DemoAdapter, - create_app: bool = False, - create_app_fn: CreateWebRTCApp = create_webrtc_app, -) -> WebRTCDemo: - """Build shared WebRTC manager/app pieces for a demo adapter runtime.""" - if not isinstance(spec.output, WebRTCOutputSpec): - raise ValueError("build_webrtc_demo requires WebRTCOutputSpec output.") - _require_supported_mode( - mode=spec.input_mode, - supported=adapter.supported_input_modes(), - label="input_mode", - ) - _require_supported_mode( - mode=spec.output.mode, - supported=adapter.supported_output_modes(), - label="output.mode", - ) - - output = spec.output - runtime = adapter.create_webrtc_runtime(spec) - runtime_config = _create_runtime_config( - spec=spec, - adapter=adapter, - runtime=runtime, - ) - manager = _create_session_manager( - spec=spec, - adapter=adapter, - runtime=runtime, - runtime_config=runtime_config, - fps=output.fps, - client_liveness_timeout_s=output.client_liveness_timeout_s, - ) - app = ( - _create_app( - spec=spec, - adapter=adapter, - session_manager=manager, - create_app_fn=create_app_fn, - ) - if create_app - else None - ) - return WebRTCDemo( - runtime=runtime, - runtime_config=runtime_config, - session_manager=manager, - app=app, - host=output.host, - port=output.port, - ) - - -def serve_webrtc_demo( - *, - spec: DemoSpec, - adapter: DemoAdapter, - world_rank: int = 0, - create_app_fn: CreateWebRTCApp = create_webrtc_app, - server_runner: RunWebRTCServer = run_webrtc_server, -) -> WebRTCDemo: - """Build and serve a shared WebRTC demo.""" - demo = build_webrtc_demo( - spec=spec, - adapter=adapter, - create_app=world_rank == 0, - create_app_fn=create_app_fn, - ) - server_runner( - world_rank=world_rank, - session_manager=demo.session_manager, - app=demo.app, - host=demo.host, - port=demo.port, - ) - return demo - - -def _create_runtime_config( - *, - spec: DemoSpec, - adapter: DemoAdapter, - runtime: Any, -) -> Any: - factory = getattr(adapter, "create_webrtc_runtime_config", None) - if callable(factory): - return factory(spec=spec, runtime=runtime) - - runtime_config = getattr(runtime, "config", None) - if _looks_like_webrtc_runtime_config(runtime_config): - return runtime_config - - output = spec.output - if not isinstance(output, WebRTCOutputSpec): - raise ValueError("WebRTC runtime config creation requires WebRTCOutputSpec.") - return WebRTCDemoRuntimeConfig( - video_width=output.video_width, - video_height=output.video_height, - warmup_chunks=output.warmup_chunks, - warmup_timeout_s=output.warmup_timeout_s, - ) - - -def _looks_like_webrtc_runtime_config(value: Any) -> bool: - return all( - hasattr(value, name) - for name in ( - "video_width", - "video_height", - "warmup_chunks", - "warmup_timeout_s", - ) - ) - - -def _create_session_manager( - *, - spec: DemoSpec, - adapter: DemoAdapter, - runtime: Any, - runtime_config: Any, - fps: int, - client_liveness_timeout_s: float, -) -> BaseWebRTCSessionManager[Any, Any]: - factory = getattr(adapter, "create_webrtc_session_manager", None) - if callable(factory): - return factory( - spec=spec, - runtime=runtime, - runtime_config=runtime_config, - fps=fps, - client_liveness_timeout_s=client_liveness_timeout_s, - ) - - return SharedDemoWebRTCSessionManager( - model_name=spec.model_id, - runtime=runtime, - runtime_config=runtime_config, - fps=fps, - client_liveness_timeout_s=client_liveness_timeout_s, - ) - - -def _create_app( - *, - spec: DemoSpec, - adapter: DemoAdapter, - session_manager: BaseWebRTCSessionManager[Any, Any], - create_app_fn: CreateWebRTCApp, -) -> web.Application: - output = spec.output - if not isinstance(output, WebRTCOutputSpec): - raise ValueError("WebRTC app creation requires WebRTCOutputSpec output.") - factory = getattr(adapter, "create_webrtc_app", None) - if callable(factory): - return factory( - spec=spec, - session_manager=session_manager, - request_session_url=_request_session_url(output), - ) - return _build_webrtc_app( - output=output, - session_manager=session_manager, - create_app_fn=create_app_fn, - preload_name=output.preload_name or spec.model_id, - ) - - -def _build_webrtc_app( - *, - output: WebRTCOutputSpec, - session_manager: BaseWebRTCSessionManager[Any, Any], - create_app_fn: CreateWebRTCApp, - preload_name: str, -) -> web.Application: - if output.web_dir is None: - raise ValueError("WebRTC app creation requires output.web_dir.") - return create_app_fn( - web_dir=Path(output.web_dir), - session_manager=session_manager, - request_session_url=_request_session_url(output), - preload_name=preload_name, - ) - - -def _request_session_url(output: WebRTCOutputSpec) -> str: - host = "127.0.0.1" if output.host in {"0.0.0.0", "::"} else output.host - return f"http://{host}:{output.port}{output.request_session_path}" - - -__all__ = [ - "CreateWebRTCApp", - "RunWebRTCServer", - "SharedDemoWebRTCSessionManager", - "WebRTCDemo", - "WebRTCDemoRuntimeConfig", - "build_webrtc_demo", - "serve_webrtc_demo", -] diff --git a/flashdreams/flashdreams/runtime/inference_session.py b/flashdreams/flashdreams/runtime/inference_session.py deleted file mode 100644 index 50a07acc2..000000000 --- a/flashdreams/flashdreams/runtime/inference_session.py +++ /dev/null @@ -1,201 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Inference session lifecycle, model-input envelope, and schema.""" - -from abc import ABC, abstractmethod -from collections.abc import Mapping -from dataclasses import dataclass, field -from typing import Any - -from flashdreams.infra.pipeline import ( - StreamInferencePipeline, - StreamInferencePipelineCache, - StreamInferencePipelineConfig, -) -from flashdreams.runtime._utils import freeze_mapping -from flashdreams.runtime.inputs import InputField, TimeWindow, check_payload -from flashdreams.runtime.types import StepRequest - - -@dataclass(frozen=True, kw_only=True, slots=True) -class InferenceInput: - """Global and per-step conditioning for one inference call.""" - - __hash__ = None - - global_conditioning: Mapping[str, Any] = field(default_factory=dict) - per_step_conditioning: Mapping[str, Any] = field(default_factory=dict) - - def __post_init__(self) -> None: - object.__setattr__( - self, "global_conditioning", freeze_mapping(self.global_conditioning) - ) - object.__setattr__( - self, - "per_step_conditioning", - freeze_mapping(self.per_step_conditioning), - ) - - -@dataclass(frozen=True, kw_only=True, slots=True) -class InferenceOutput: - """Generated output and metadata for one inference step.""" - - __hash__ = None - - step_index: int - """Zero-based index of the completed inference step.""" - - output: Any = None - """Generated payload for the step.""" - - frame_count: int | None = None - """Number of generated frames when the output is frame-based.""" - - output_window: TimeWindow | None = None - """Session time window represented by the generated output.""" - - metadata: Mapping[str, Any] = field(default_factory=dict) - """Output metadata supplied by the session or model adapter.""" - - metrics: Mapping[str, float | int] = field(default_factory=dict) - """Per-step numeric measurements.""" - - def __post_init__(self) -> None: - if self.step_index < 0: - raise ValueError("InferenceOutput.step_index must be >= 0.") - if self.frame_count is not None and self.frame_count < 0: - raise ValueError("InferenceOutput.frame_count must be >= 0.") - object.__setattr__(self, "metadata", freeze_mapping(self.metadata)) - object.__setattr__(self, "metrics", freeze_mapping(self.metrics)) - - -@dataclass(frozen=True, kw_only=True, slots=True) -class InferenceInputSchema: - """Required and optional fields in each inference input payload.""" - - global_fields: tuple[InputField, ...] = () - """Model inputs required before starting the initial generation/session.""" - - per_step_fields: tuple[InputField, ...] = () - """Per-step model inputs required after the session starts.""" - - def check_global_payload(self, inputs: InferenceInput) -> None: - """Check that required global fields are present in ``inputs``.""" - check_payload(self.global_fields, inputs.global_conditioning) - - def check_per_step_payload(self, inputs: InferenceInput) -> None: - """Check that required per-step fields are present in ``inputs``.""" - check_payload(self.per_step_fields, inputs.per_step_conditioning) - - -@dataclass(frozen=True, kw_only=True, slots=True) -class InferenceSessionConfig: - """Configuration for constructing an inference session.""" - - __hash__ = None - - pipeline: StreamInferencePipelineConfig - """Pipeline configuration to instantiate.""" - - -class InferenceSession(ABC): - """Stateful inference pipeline session.""" - - _pipeline_cache: StreamInferencePipelineCache[Any, Any, Any] | None - """Pipeline cache for the active rollout; ``None`` before its first step.""" - - _step_index: int - """Zero-based index assigned to the next generated output.""" - - def __init__(self, config: InferenceSessionConfig) -> None: - """Initialize the inference pipeline. - - Args: - config: Session configuration. - """ - self.config = config - # Initialize the inference pipeline from the provided configuration. - self.pipeline: StreamInferencePipeline = self.config.pipeline.setup() - self._pipeline_cache = None - self._step_index = 0 - - def __del__(self) -> None: - """Release session resources.""" - if hasattr(self, "pipeline"): - del self.pipeline - - def next_step_request(self) -> StepRequest: - """Return input requirements for the next pipeline step.""" - return StepRequest(step_index=self._step_index) - - @abstractmethod - def reset(self) -> None: - """Reset the pipeline and discard the active rollout state.""" - pipeline_reset = getattr(self.pipeline, "reset", None) - if callable(pipeline_reset): - pipeline_reset() - self._pipeline_cache = None - self._step_index = 0 - - @abstractmethod - def step(self, inference_input: InferenceInput) -> InferenceOutput: - """Run one inference step. - - Args: - inference_input: Model-ready inputs for the step. - - Returns: - Generated output for the step. - - Raises: - ValueError: Global conditioning is supplied after the rollout starts. - """ - request = self.next_step_request() - input_schema = request.inference_input_schema - if self._pipeline_cache is None: - if input_schema is not None: - input_schema.check_global_payload(inference_input) - self._pipeline_cache = self.pipeline.initialize_cache( - **inference_input.global_conditioning - ) - elif inference_input.global_conditioning: - raise ValueError( - "InferenceInput.global_conditioning can only be supplied on the " - "first step after reset()." - ) - - if input_schema is not None: - input_schema.check_per_step_payload(inference_input) - pipeline_input = inference_input.per_step_conditioning or None - output = self.pipeline.generate( - autoregressive_index=request.step_index, - cache=self._pipeline_cache, - input=pipeline_input, - ) - self.pipeline.finalize( - autoregressive_index=request.step_index, - cache=self._pipeline_cache, - ) - inference_output = InferenceOutput( - step_index=request.step_index, - output=output, - output_window=request.user_input_window, - metadata=request.metadata, - metrics={}, # No metrics for now, inject to pipeline later. - ) - self._step_index = request.step_index + 1 - return inference_output diff --git a/flashdreams/flashdreams/runtime/inputs.py b/flashdreams/flashdreams/runtime/inputs.py deleted file mode 100644 index 4f7cec75a..000000000 --- a/flashdreams/flashdreams/runtime/inputs.py +++ /dev/null @@ -1,475 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""User and canonical input envelopes and schemas for the runtime API.""" - -from __future__ import annotations - -import math -from collections.abc import Iterable, Mapping -from dataclasses import dataclass, field -from typing import Any, Literal, cast - -from flashdreams.runtime._utils import freeze_mapping - -InputPhase = Literal["global_conditioning", "step"] - -INPUT_PHASES: tuple[InputPhase, ...] = ("global_conditioning", "step") - - -def validate_phase(value: str) -> InputPhase: - """Return ``value`` as a validated :data:`InputPhase`.""" - if value not in INPUT_PHASES: - raise ValueError( - f"phase must be 'global_conditioning' or 'step', got {value!r}." - ) - return cast(InputPhase, value) - - -@dataclass(frozen=True, kw_only=True, slots=True) -class TimeWindow: - """Half-open time window in seconds since session start.""" - - start_s: float - end_s: float - - def __post_init__(self) -> None: - if not math.isfinite(self.start_s) or not math.isfinite(self.end_s): - raise ValueError("TimeWindow bounds must be finite seconds.") - if self.start_s < 0 or self.end_s < 0: - raise ValueError("TimeWindow bounds must be non-negative.") - if self.end_s < self.start_s: - raise ValueError("TimeWindow.end_s must be >= start_s.") - - def contains(self, timestamp_s: float) -> bool: - """Return whether ``timestamp_s`` falls within this half-open window.""" - return self.start_s <= timestamp_s < self.end_s - - -@dataclass(frozen=True, kw_only=True, slots=True) -class InputField: - """Lightweight schema field for user snapshots or model inputs. - - ``name`` is the model-facing input role and payload key, such as ``prompt`` - or ``negative_prompt``. ``input_modality``, ``frequency_consumed``, and - ``metadata`` are query hints only. Adapter-owned validation still decides - concrete shape, dtype, units, and tensor layout. - """ - - name: str - required: bool = True - input_modality: str | None = None - frequency_consumed: str | None = None - metadata: Mapping[str, Any] = field( - default_factory=dict, - compare=False, - hash=False, - ) - description: str = "" - - def __post_init__(self) -> None: - if not self.name.strip(): - raise ValueError("InputField.name must be non-empty.") - object.__setattr__(self, "metadata", freeze_mapping(self.metadata)) - - -@dataclass(frozen=True, kw_only=True, slots=True) -class UserInputCapability: - """One user event a source or mapping can provide, at payload granularity. - - ``UserInputSchema.event_types`` declares only that an event type exists. A - capability additionally pins the payload fields carried by that event, so a - mapping can state that it needs ``key_down`` events that actually carry a - ``key``. - """ - - event_type: str - input_modality: str | None = None - payload_fields: frozenset[str] = field(default_factory=frozenset) - metadata: Mapping[str, Any] = field( - default_factory=dict, - compare=False, - hash=False, - ) - description: str = "" - - def __post_init__(self) -> None: - if not self.event_type.strip(): - raise ValueError("UserInputCapability.event_type must be non-empty.") - for payload_field in self.payload_fields: - if not payload_field.strip(): - raise ValueError("payload field names must be non-empty.") - object.__setattr__(self, "metadata", freeze_mapping(self.metadata)) - - def is_satisfied_by(self, provider: "UserInputCapability") -> bool: - """Return whether ``provider`` can satisfy this consumed capability.""" - if self.event_type != provider.event_type: - return False - input_modality_ok = ( - self.input_modality is None - or provider.input_modality is None - or self.input_modality == provider.input_modality - ) - return input_modality_ok and self.payload_fields.issubset( - provider.payload_fields - ) - - -@dataclass(frozen=True, kw_only=True, slots=True) -class UserInputSchema: - """Minimal metadata for user events a source or mapping can provide.""" - - event_types: frozenset[str] = field(default_factory=frozenset) - snapshot_fields: tuple[InputField, ...] = () - capabilities: tuple[UserInputCapability, ...] = () - description: str = "" - - def supports_event_types(self, event_types: Iterable[str]) -> bool: - """Return whether every requested event type is declared supported.""" - requested = frozenset(event_types) - if not requested: - return True - return requested.issubset(self.declared_event_types()) - - def declared_event_types(self) -> frozenset[str]: - """Return event types from ``event_types`` and from ``capabilities``.""" - return self.event_types | frozenset( - capability.event_type for capability in self.capabilities - ) - - def declared_capabilities(self) -> tuple[UserInputCapability, ...]: - """Return capabilities, widened with bare ``event_types`` entries. - - A plain ``event_types`` entry carries no payload promise, so it is - modeled as a capability with no payload fields. Coarse schemas written - before capabilities existed therefore still satisfy any consumer that - does not require specific payload fields. - """ - declared = list(self.capabilities) - covered = {capability.event_type for capability in declared} - declared.extend( - UserInputCapability(event_type=event_type) - for event_type in sorted(self.event_types - covered) - ) - return tuple(declared) - - def supports(self, capability: UserInputCapability) -> bool: - """Return whether this source can satisfy ``capability``.""" - return any( - capability.is_satisfied_by(provider) - for provider in self.declared_capabilities() - ) - - def validate_event(self, event: "UserInputEvent") -> None: - """Validate one event against the event types this source declares.""" - matching = [ - capability - for capability in self.declared_capabilities() - if capability.event_type == event.event_type - ] - if not matching: - raise ValueError( - f"User input source does not provide event type {event.event_type!r}." - ) - payload_keys = set(event.payload) - if not any( - capability.payload_fields.issubset(payload_keys) for capability in matching - ): - expected = sorted( - { - payload_field - for capability in matching - for payload_field in capability.payload_fields - } - ) - raise ValueError( - f"Event {event.event_type!r} payload is missing required " - f"fields: {expected}." - ) - - def missing_snapshot(self, inputs: "UserInputs") -> tuple[str, ...]: - """Return required snapshot fields absent from ``inputs``.""" - return tuple( - input_field.name - for input_field in self.snapshot_fields - if input_field.required and input_field.name not in inputs.snapshot - ) - - def require_snapshot(self, inputs: "UserInputs") -> None: - """Raise if required snapshot fields are absent.""" - missing = self.missing_snapshot(inputs) - if missing: - raise ValueError(f"Missing required user snapshot field(s): {missing}") - - -@dataclass(frozen=True, kw_only=True, slots=True) -class InferenceInputSchema: - """Minimal metadata for global conditioning and per-step inputs.""" - - global_conditioning_fields: tuple[InputField, ...] = () - """Model inputs carried in the global conditioning slot.""" - - step_fields: tuple[InputField, ...] = () - """Model inputs required for one session step.""" - - description: str = "" - - def fields_for(self, phase: InputPhase) -> tuple[InputField, ...]: - """Return every declared field for ``phase``.""" - return ( - self.global_conditioning_fields - if validate_phase(phase) == "global_conditioning" - else self.step_fields - ) - - def required_fields( - self, - phase: InputPhase | None = None, - ) -> tuple[tuple[InputPhase, InputField], ...]: - """Return required fields as ``(phase, field)``, optionally filtered.""" - return self._select(phase, required=True) - - def optional_fields( - self, - phase: InputPhase | None = None, - ) -> tuple[tuple[InputPhase, InputField], ...]: - """Return optional fields as ``(phase, field)``, optionally filtered.""" - return self._select(phase, required=False) - - def field_for(self, *, name: str, phase: InputPhase) -> InputField | None: - """Return one declared field, if present.""" - for input_field in self.fields_for(phase): - if input_field.name == name: - return input_field - return None - - def _select( - self, - phase: InputPhase | None, - *, - required: bool, - ) -> tuple[tuple[InputPhase, InputField], ...]: - phases = INPUT_PHASES if phase is None else (validate_phase(phase),) - return tuple( - (each_phase, input_field) - for each_phase in phases - for input_field in self.fields_for(each_phase) - if input_field.required is required - ) - - def missing_global_conditioning(self, inputs: "InferenceInput") -> tuple[str, ...]: - """Return required global conditioning fields absent from ``inputs``.""" - return _missing_required( - self.global_conditioning_fields, - inputs.global_conditioning, - ) - - def missing_step(self, inputs: "InferenceInput") -> tuple[str, ...]: - """Return required per-step fields absent from ``inputs``.""" - return _missing_required(self.step_fields, inputs.step) - - def require_global_conditioning(self, inputs: "InferenceInput") -> None: - """Raise if required global conditioning fields are absent.""" - missing = self.missing_global_conditioning(inputs) - if missing: - raise ValueError( - f"Missing required global conditioning input(s): {missing}" - ) - - def require_step(self, inputs: "InferenceInput") -> None: - """Raise if required per-step fields are absent.""" - missing = self.missing_step(inputs) - if missing: - raise ValueError(f"Missing required step model input(s): {missing}") - - -@dataclass(frozen=True, kw_only=True, slots=True) -class UserInputEvent: - """User-facing input event timestamped in seconds since session start. - - Live runtimes, transports, replay loaders, or benchmark drivers stamp events - before queuing them for input mapping. Payload schema is intentionally minimal - in T1; concrete event catalogs belong to follow-up input-mapping work. - """ - - __hash__ = None - - timestamp_s: float - event_type: str - payload: Mapping[str, Any] = field(default_factory=dict) - source: str | None = None - source_event_id: str | None = None - - def __post_init__(self) -> None: - if not math.isfinite(self.timestamp_s) or self.timestamp_s < 0: - raise ValueError("UserInputEvent.timestamp_s must be finite and >= 0.") - if not self.event_type.strip(): - raise ValueError("UserInputEvent.event_type must be non-empty.") - object.__setattr__(self, "payload", freeze_mapping(self.payload)) - - -@dataclass(frozen=True, kw_only=True, slots=True) -class UserInputs: - """Transport-neutral user input batch or window. - - Events must be in non-decreasing timestamp order. Runtimes can pass the full - input history, a drained queue batch, or a session-requested time window to an - ``InputMapping``. - """ - - __hash__ = None - - events: tuple[UserInputEvent, ...] = () - snapshot: Mapping[str, Any] = field(default_factory=dict) - metadata: Mapping[str, Any] = field(default_factory=dict) - - def __post_init__(self) -> None: - previous_timestamp_s = -math.inf - for event in self.events: - if event.timestamp_s < previous_timestamp_s: - raise ValueError( - "UserInputs.events must be sorted by non-decreasing timestamp_s." - ) - previous_timestamp_s = event.timestamp_s - object.__setattr__(self, "snapshot", freeze_mapping(self.snapshot)) - object.__setattr__(self, "metadata", freeze_mapping(self.metadata)) - - def window(self, time_window: TimeWindow) -> "UserInputs": - """Return inputs with events filtered to ``time_window``.""" - return UserInputs( - events=tuple( - event - for event in self.events - if time_window.contains(event.timestamp_s) - ), - snapshot=self.snapshot, - metadata=self.metadata, - ) - - -@dataclass(frozen=True, kw_only=True, slots=True) -class CanonicalModality: - """A device-independent user input an application consumes. - - This is the middle layer of ``raw input -> canonicalized input -> encoded - inference input``. Applications and benchmarks declare and consume - modalities; they never read raw device events, so adding a new device is a - converter registration rather than an application change. - - Modalities describe live user control only. Global conditioning such as a - prompt or conditioning frame is application-owned and reaches - :class:`flashdreams.runtime.inference_session.InferenceInput` directly, - without passing through this layer. - """ - - name: str - payload_fields: frozenset[str] = field(default_factory=frozenset) - metadata: Mapping[str, Any] = field( - default_factory=dict, - compare=False, - hash=False, - ) - description: str = "" - - def __post_init__(self) -> None: - if not self.name.strip(): - raise ValueError("CanonicalModality.name must be non-empty.") - for payload_field in self.payload_fields: - if not payload_field.strip(): - raise ValueError("payload field names must be non-empty.") - object.__setattr__(self, "metadata", freeze_mapping(self.metadata)) - - def is_satisfied_by(self, provider: "CanonicalModality") -> bool: - """Return whether ``provider`` can satisfy this consumed modality.""" - return self.name == provider.name and self.payload_fields.issubset( - provider.payload_fields - ) - - def value(self, payload: Mapping[str, Any]) -> Mapping[str, Any]: - """Return ``payload`` frozen, checking it covers this modality.""" - missing = sorted(self.payload_fields - set(payload)) - if missing: - raise ValueError( - f"Canonical modality {self.name!r} requires payload fields " - f"{missing}, which the converter did not produce." - ) - return freeze_mapping(payload) - - -@dataclass(frozen=True, kw_only=True, slots=True) -class CanonicalInputSchema: - """Canonical modalities an application can be fed by a given source.""" - - modalities: tuple[CanonicalModality, ...] = () - description: str = "" - - def supports(self, modality: CanonicalModality) -> bool: - """Return whether this source can supply ``modality``.""" - return any(modality.is_satisfied_by(provided) for provided in self.modalities) - - -@dataclass(frozen=True, kw_only=True, slots=True) -class CanonicalInputs: - """Canonicalized user input for one step, keyed by modality name. - - Values are level-triggered and normally present every step: a key held down - emits no events but still means full throttle. Global conditioning does not - appear here; it is application-owned and reaches - :class:`flashdreams.runtime.inference_session.InferenceInput` directly. - """ - - __hash__ = None - - values: Mapping[str, Any] = field(default_factory=dict) - metadata: Mapping[str, Any] = field(default_factory=dict) - - def __post_init__(self) -> None: - object.__setattr__(self, "values", freeze_mapping(self.values)) - object.__setattr__(self, "metadata", freeze_mapping(self.metadata)) - - -@dataclass(frozen=True, kw_only=True, slots=True) -class InferenceInput: - """Encoded inputs for one :class:`InferenceSession` call. - - Two conditioning slots: - - - ``global_conditioning``: values that condition the whole rollout, such as - the conditioning frame or prompt. Session start/reset establishes this - state; a step call may carry a non-empty payload to request an update when - the model supports it. - - ``step``: values needed to generate the next chunk or frame. - """ - - __hash__ = None - - global_conditioning: Mapping[str, Any] = field(default_factory=dict) - step: Mapping[str, Any] = field(default_factory=dict) - metadata: Mapping[str, Any] = field(default_factory=dict) - - def __post_init__(self) -> None: - object.__setattr__( - self, "global_conditioning", freeze_mapping(self.global_conditioning) - ) - object.__setattr__(self, "step", freeze_mapping(self.step)) - object.__setattr__(self, "metadata", freeze_mapping(self.metadata)) - - def for_phase(self, phase: InputPhase) -> Mapping[str, Any]: - """Return the payload mapping for ``phase``.""" - return ( - self.global_conditioning - if validate_phase(phase) == "global_conditioning" - else self.step - ) - - -def _missing_required( - fields: tuple[InputField, ...], payload: Mapping[str, Any] -) -> tuple[str, ...]: - return tuple( - input_field.name - for input_field in fields - if input_field.required and input_field.name not in payload - ) - if missing: - raise ValueError(f"Missing required input(s): {missing}") diff --git a/flashdreams/flashdreams/runtime/interfaces.py b/flashdreams/flashdreams/runtime/interfaces.py deleted file mode 100644 index 385e69d62..000000000 --- a/flashdreams/flashdreams/runtime/interfaces.py +++ /dev/null @@ -1,91 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Protocols for model adapters, reusable runtimes, and sessions.""" - -from __future__ import annotations - -from typing import Protocol, runtime_checkable - -from flashdreams.runtime.config import InferenceConfig -from flashdreams.runtime.inference_session import ( - InferenceInput, - InferenceInputSchema, - InferenceOutput, -) -from flashdreams.runtime.inputs import CanonicalInputSchema -from flashdreams.runtime.mapping import InputMapping -from flashdreams.runtime.types import StepRequest - - -@runtime_checkable -class InferenceSession(Protocol): - """One rollout or stream with isolated model/cache state.""" - - def next_step_request(self) -> StepRequest | None: - """Return the next step's runtime request, or ``None`` when complete.""" - ... - - def step(self, inputs: InferenceInput) -> InferenceOutput: - """Run one sequential inference step.""" - ... - - def reset(self, inputs: InferenceInput | None = None) -> None: - """Reset this session's rollout state when the backend supports it.""" - ... - - def close(self) -> None: - """Release per-session resources.""" - ... - - -@runtime_checkable -class InferenceRuntime(Protocol): - """Heavyweight reusable runtime created from :class:`InferenceConfig`.""" - - def start_session(self, inputs: InferenceInput) -> InferenceSession: - """Create an isolated session from global conditioning inputs.""" - ... - - def close(self) -> None: - """Release model/backend resources.""" - ... - - -# Do not mark ModelAdapter runtime-checkable: properties make issubclass() -# unreliable, and isinstance() would only verify attribute presence. -class ModelAdapter(Protocol): - """Model-specific boundary that declares defaults and creates runtimes. - - Adapters declare model-facing input requirements, the canonical modalities - their default mapping consumes, and an optional default mapping between the - two. Runtime, application, or benchmark code may override that mapping while - preserving the same ``CanonicalInputs`` to ``InferenceInput`` boundary. - """ - - @property - def model_id(self) -> str: - """Stable identity for the model adapter or runtime integration.""" - ... - - @property - def inference_input_schema(self) -> InferenceInputSchema: - """Model-facing global conditioning and per-step input requirements.""" - ... - - @property - def canonical_input_schema(self) -> CanonicalInputSchema | None: - """Canonical modalities the adapter's default mapping consumes.""" - ... - - def default_input_mapping(self) -> InputMapping | None: - """Return the model-provided default canonical-to-model mapping.""" - ... - - def validate_config(self, config: InferenceConfig) -> None: - """Fail early for unsupported runtime settings.""" - ... - - def create_runtime(self, config: InferenceConfig) -> InferenceRuntime: - """Initialize and return the heavyweight runtime.""" - ... diff --git a/flashdreams/flashdreams/runtime/mapping.py b/flashdreams/flashdreams/runtime/mapping.py deleted file mode 100644 index e6e2b9b3e..000000000 --- a/flashdreams/flashdreams/runtime/mapping.py +++ /dev/null @@ -1,396 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Input mapping boundary from canonical inputs to encoded inference inputs.""" - -from __future__ import annotations - -from collections.abc import Mapping, Sequence -from dataclasses import dataclass, field, replace -from typing import Any, Protocol, runtime_checkable - -from flashdreams.runtime._utils import freeze_mapping -from flashdreams.runtime.inference_session import InferenceInput, InferenceInputSchema -from flashdreams.runtime.inputs import ( - INPUT_PHASES, - CanonicalInputs, - CanonicalInputSchema, - CanonicalModality, - InputField, - InputPhase, -) -from flashdreams.runtime.types import StepRequest - - -@runtime_checkable -class InputMapping(Protocol): - """Convert user-facing inputs into model-facing inputs. - - A mapping may be supplied by the model adapter as a default or by an - application/runtime override. Step mappings usually receive a timestamped - event window selected by the runner for the current model step or chunk. - """ - - def validate( - self, - *, - canonical_schema: CanonicalInputSchema | None = None, - inference_input_schema: InferenceInputSchema | None = None, - ) -> None: - """Fail early for obvious app, event-source, and model mismatches.""" - ... - - def map_global_conditioning_inputs( - self, - *, - canonical_inputs: CanonicalInputs, - inference_input: InferenceInput, - ) -> InferenceInput: - """Build global conditioning inputs for session start or reset.""" - ... - - def map_step_inputs( - self, - *, - canonical_inputs: CanonicalInputs, - inference_input: InferenceInput, - request: StepRequest, - ) -> InferenceInput: - """Build model inputs for one session step from the current input window.""" - ... - - -class IdentityInputMapping: - """No-op mapper for fixed model-input or simple generation flows.""" - - def validate( - self, - *, - canonical_schema: CanonicalInputSchema | None = None, - inference_input_schema: InferenceInputSchema | None = None, - ) -> None: - del canonical_schema, inference_input_schema - - def map_global_conditioning_inputs( - self, - *, - canonical_inputs: CanonicalInputs, - inference_input: InferenceInput, - ) -> InferenceInput: - del canonical_inputs - return inference_input - - def map_step_inputs( - self, - *, - canonical_inputs: CanonicalInputs, - inference_input: InferenceInput, - request: StepRequest, - ) -> InferenceInput: - del canonical_inputs, request - return inference_input - - -@dataclass(frozen=True, kw_only=True, slots=True) -class InputMappingSchema: - """Declarative compatibility surface for one mapping. - - ``InputMapping.validate`` fails a run late and opaquely: it raises, but it - cannot answer which optional model inputs a source would enable, or which - missing user capability is responsible for an unreachable model input. This - schema makes those questions answerable before runtime initialization. - """ - - name: str = "input-mapping" - consumes: tuple[CanonicalModality, ...] = () - produces_global_conditioning: tuple[InputField, ...] = () - produces_step: tuple[InputField, ...] = () - metadata: Mapping[str, Any] = field( - default_factory=dict, - compare=False, - hash=False, - ) - - def __post_init__(self) -> None: - if not self.name.strip(): - raise ValueError("InputMappingSchema.name must be non-empty.") - object.__setattr__(self, "metadata", freeze_mapping(self.metadata)) - - def produces_for(self, phase: InputPhase) -> tuple[InputField, ...]: - """Return the fields this mapping produces for ``phase``.""" - return ( - self.produces_global_conditioning - if phase == "global_conditioning" - else self.produces_step - ) - - def can_produce(self, phase: InputPhase, required: InputField) -> bool: - """Return whether this mapping can produce ``required`` in ``phase``.""" - return any( - _field_matches(produced, required) for produced in self.produces_for(phase) - ) - - -def _field_matches(produced: InputField, required: InputField) -> bool: - if produced.name != required.name: - return False - input_modality_ok = ( - produced.input_modality is None - or required.input_modality is None - or produced.input_modality == required.input_modality - ) - return input_modality_ok - - -@dataclass(frozen=True, kw_only=True, slots=True) -class MappingCompatibility: - """Compatibility report for one source, model schema, and mapping set. - - Mappings whose consumed capabilities the source cannot provide are reported - in ``unavailable_mapping_schemas`` and excluded from the satisfied/available - reports, so those lists only name model inputs that can really be produced. - """ - - __hash__ = None - - canonical_schema: CanonicalInputSchema - inference_input_schema: InferenceInputSchema - mapping_schema: InputMappingSchema - missing_modalities: tuple[CanonicalModality, ...] = () - missing_required_model_fields: tuple[tuple[InputPhase, InputField], ...] = () - satisfied_required_model_fields: tuple[tuple[InputPhase, InputField], ...] = () - available_optional_model_fields: tuple[tuple[InputPhase, InputField], ...] = () - unavailable_mapping_schemas: tuple[InputMappingSchema, ...] = () - - @property - def can_drive(self) -> bool: - """Return whether this source can drive this model through the mapping. - - A mapping the source cannot feed does not block the run unless it was - the only way to produce a required model input. - """ - return not (self.missing_required_model_fields or self.missing_modalities) - - @property - def unavailable_mapping_names(self) -> tuple[str, ...]: - """Return names of mappings dropped because the source cannot feed them.""" - return tuple(schema.name for schema in self.unavailable_mapping_schemas) - - def raise_if_incompatible(self) -> None: - """Raise a compact error when this mapping cannot drive the model.""" - if self.can_drive: - return - problems: list[str] = [] - if self.missing_modalities: - missing = ", ".join(modality.name for modality in self.missing_modalities) - problems.append(f"missing canonical modalities: {missing}") - if self.missing_required_model_fields: - missing = ", ".join( - f"{phase}:{input_field.name}" - for phase, input_field in self.missing_required_model_fields - ) - problems.append(f"missing required model inputs: {missing}") - if self.unavailable_mapping_schemas: - problems.append( - "unavailable mappings: " + ", ".join(self.unavailable_mapping_names) - ) - raise ValueError( - f"Input mapping {self.mapping_schema.name!r} cannot drive this model " - f"from the selected source: " + "; ".join(problems) - ) - - -def _source_can_feed( - canonical_schema: CanonicalInputSchema, - mapping_schema: InputMappingSchema, -) -> bool: - return all( - canonical_schema.supports(modality) for modality in mapping_schema.consumes - ) - - -def combine_mapping_schemas( - mapping_schemas: Sequence[InputMappingSchema], - *, - name: str = "input-mapping-set", -) -> InputMappingSchema: - """Combine independently declared mappings into one compatibility surface. - - Duplicates are collapsed. Because ``metadata`` is excluded from equality, - the metadata of collapsed duplicates is merged rather than dropped, with the - first declaration winning on conflicting keys. - """ - consumes: list[CanonicalModality] = [] - produces: dict[InputPhase, list[InputField]] = { - "global_conditioning": [], - "step": [], - } - - def _merge(target: list[Any], value: Any) -> None: - for index, existing in enumerate(target): - if existing == value: - if value.metadata: - target[index] = replace( - existing, - metadata={**dict(value.metadata), **dict(existing.metadata)}, - ) - return - target.append(value) - - for mapping_schema in mapping_schemas: - if not isinstance(mapping_schema, InputMappingSchema): - raise TypeError("mapping_schemas must contain InputMappingSchema objects.") - for modality in mapping_schema.consumes: - _merge(consumes, modality) - for phase in INPUT_PHASES: - for input_field in mapping_schema.produces_for(phase): - _merge(produces[phase], input_field) - - return InputMappingSchema( - name=name, - consumes=tuple(consumes), - produces_global_conditioning=tuple(produces["global_conditioning"]), - produces_step=tuple(produces["step"]), - ) - - -def _build_compatibility( - *, - canonical_schema: CanonicalInputSchema, - inference_input_schema: InferenceInputSchema, - mapping_schemas: Sequence[InputMappingSchema], - reported_schema: InputMappingSchema, -) -> MappingCompatibility: - feedable: list[InputMappingSchema] = [] - unavailable: list[InputMappingSchema] = [] - for mapping_schema in mapping_schemas: - if _source_can_feed(canonical_schema, mapping_schema): - feedable.append(mapping_schema) - else: - unavailable.append(mapping_schema) - - usable = combine_mapping_schemas(feedable, name=reported_schema.name) - declared_fields: tuple[tuple[InputPhase, InputField], ...] = tuple( - (phase, input_field) - for phase, fields in ( - ("global", inference_input_schema.global_fields), - ("step", inference_input_schema.per_step_fields), - ) - for input_field in fields - ) - required = tuple(declared for declared in declared_fields if declared[1].required) - missing_required = tuple( - (phase, input_field) - for phase, input_field in required - if not usable.can_produce(phase, input_field) - ) - satisfied_required = tuple( - (phase, input_field) - for phase, input_field in required - if usable.can_produce(phase, input_field) - ) - available_optional = tuple( - (phase, input_field) - for phase, input_field in declared_fields - if not input_field.required - if usable.can_produce(phase, input_field) - ) - - # Only capabilities that block a required model input make the mapping - # unusable. A dropped mapping that fed nothing but optional fields degrades - # the run instead of vetoing it. - missing_modalities: list[CanonicalModality] = [] - for mapping_schema in unavailable: - if not any( - mapping_schema.can_produce(phase, input_field) - for phase, input_field in missing_required - ): - continue - for modality in mapping_schema.consumes: - if canonical_schema.supports(modality) or modality in missing_modalities: - continue - missing_modalities.append(modality) - - return MappingCompatibility( - canonical_schema=canonical_schema, - inference_input_schema=inference_input_schema, - mapping_schema=reported_schema, - missing_modalities=tuple(missing_modalities), - missing_required_model_fields=missing_required, - satisfied_required_model_fields=satisfied_required, - available_optional_model_fields=available_optional, - unavailable_mapping_schemas=tuple(unavailable), - ) - - -def check_mapping_compatibility( - *, - canonical_schema: CanonicalInputSchema, - inference_input_schema: InferenceInputSchema, - mapping_schema: InputMappingSchema, -) -> MappingCompatibility: - """Check whether a user-input source can drive a model through a mapping.""" - if not isinstance(mapping_schema, InputMappingSchema): - raise TypeError("mapping_schema must be an InputMappingSchema object.") - return _build_compatibility( - canonical_schema=canonical_schema, - inference_input_schema=inference_input_schema, - mapping_schemas=(mapping_schema,), - reported_schema=mapping_schema, - ) - - -def check_mapping_set_compatibility( - *, - canonical_schema: CanonicalInputSchema, - inference_input_schema: InferenceInputSchema, - mapping_schemas: Sequence[InputMappingSchema], - name: str = "input-mapping-set", -) -> MappingCompatibility: - """Check compatibility for a composed set of mappings. - - Each mapping keeps its own consumes/produces link, so a mapping the source - cannot feed only costs the model inputs that mapping produced. - """ - mapping_schemas = tuple(mapping_schemas) - return _build_compatibility( - canonical_schema=canonical_schema, - inference_input_schema=inference_input_schema, - mapping_schemas=mapping_schemas, - reported_schema=combine_mapping_schemas(mapping_schemas, name=name), - ) - - -def undeclared_inference_inputs( - inputs: InferenceInput, - mapping_schema: InputMappingSchema, -) -> tuple[tuple[InputPhase, str], ...]: - """Return payload keys a mapping produced but did not declare. - - Mapping schemas are hand-written, so they drift from what - ``map_global_conditioning_inputs``/``map_step_inputs`` actually return. - Mapping tests can use this to keep the declared compatibility surface - honest. - """ - payloads: tuple[tuple[InputPhase, Mapping[str, Any]], ...] = ( - ("global", inputs.global_conditioning), - ("step", inputs.per_step_conditioning), - ) - return tuple( - (phase, key) - for phase, payload in payloads - for key in payload - if not any( - declared.name == key for declared in mapping_schema.produces_for(phase) - ) - ) - - -@runtime_checkable -class DeclaresMappingSchema(Protocol): - """Optional refinement of :class:`InputMapping` that declares its surface.""" - - @property - def mapping_schema(self) -> InputMappingSchema: - """Return the declarative compatibility surface for this mapping.""" - ... diff --git a/flashdreams/flashdreams/runtime/metrics.py b/flashdreams/flashdreams/runtime/metrics.py deleted file mode 100644 index 4286204f6..000000000 --- a/flashdreams/flashdreams/runtime/metrics.py +++ /dev/null @@ -1,124 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Runtime metrics boundary for inference sessions.""" - -from __future__ import annotations - -import math -from collections.abc import Mapping -from dataclasses import dataclass, field -from typing import Any, Protocol, runtime_checkable - -from flashdreams.runtime._utils import freeze_mapping - - -@dataclass(frozen=True, kw_only=True, slots=True) -class RuntimeMetricSample: - """One runtime metric sample. - - Timing samples should use seconds as their canonical unit. - """ - - __hash__ = None - - name: str - value: float | int - unit: str = "s" - step_index: int | None = None - category: str = "runtime" - metadata: Mapping[str, Any] = field(default_factory=dict) - - def __post_init__(self) -> None: - if not self.name.strip(): - raise ValueError("RuntimeMetricSample.name must be non-empty.") - if isinstance(self.value, bool) or not isinstance(self.value, (int, float)): - raise TypeError("RuntimeMetricSample.value must be numeric.") - if not math.isfinite(float(self.value)): - raise ValueError("RuntimeMetricSample.value must be finite.") - if self.step_index is not None and self.step_index < 0: - raise ValueError("RuntimeMetricSample.step_index must be >= 0.") - if not self.unit.strip(): - raise ValueError("RuntimeMetricSample.unit must be non-empty.") - if self.category == "timing" and self.unit != "s": - raise ValueError("Timing metric samples must use unit='s'.") - object.__setattr__(self, "metadata", freeze_mapping(self.metadata)) - - -@runtime_checkable -class MetricsRecorder(Protocol): - """Collector for runtime metrics.""" - - def record(self, sample: RuntimeMetricSample) -> None: - """Record one metric sample.""" - ... - - def record_timing( - self, - name: str, - duration_s: float, - *, - step_index: int | None = None, - metadata: Mapping[str, Any] | None = None, - ) -> None: - """Record one timing sample in seconds.""" - ... - - def close(self) -> None: - """Finalize metric collection.""" - ... - - -@dataclass(slots=True) -class InMemoryMetricsRecorder: - """Simple metrics recorder useful for tests, smoke runs, and adapters.""" - - samples: list[RuntimeMetricSample] = field(default_factory=list) - closed: bool = False - - def record(self, sample: RuntimeMetricSample) -> None: - if self.closed: - raise RuntimeError("Cannot record metrics after close().") - self.samples.append(sample) - - def record_timing( - self, - name: str, - duration_s: float, - *, - step_index: int | None = None, - metadata: Mapping[str, Any] | None = None, - ) -> None: - self.record( - RuntimeMetricSample( - name=name, - value=duration_s, - unit="s", - step_index=step_index, - category="timing", - metadata={} if metadata is None else metadata, - ) - ) - - def close(self) -> None: - self.closed = True - - -class NullMetricsRecorder: - """Metrics recorder that intentionally drops all samples.""" - - def record(self, sample: RuntimeMetricSample) -> None: - del sample - - def record_timing( - self, - name: str, - duration_s: float, - *, - step_index: int | None = None, - metadata: Mapping[str, Any] | None = None, - ) -> None: - del name, duration_s, step_index, metadata - - def close(self) -> None: - return None diff --git a/flashdreams/flashdreams/runtime/output.py b/flashdreams/flashdreams/runtime/output.py deleted file mode 100644 index 675b04d60..000000000 --- a/flashdreams/flashdreams/runtime/output.py +++ /dev/null @@ -1,78 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Output target boundary for generated inference results.""" - -from __future__ import annotations - -from collections.abc import Mapping, Sequence -from dataclasses import dataclass, field -from typing import Any, Protocol, runtime_checkable - -from flashdreams.runtime._utils import freeze_mapping -from flashdreams.runtime.inference_session import InferenceOutput - - -@dataclass(frozen=True, kw_only=True, slots=True) -class OutputArtifact: - """Artifact produced by an output target.""" - - __hash__ = None - - kind: str - uri: str - metadata: Mapping[str, Any] = field(default_factory=dict) - - def __post_init__(self) -> None: - if not self.kind.strip(): - raise ValueError("OutputArtifact.kind must be non-empty.") - if not self.uri.strip(): - raise ValueError("OutputArtifact.uri must be non-empty.") - object.__setattr__(self, "metadata", freeze_mapping(self.metadata)) - - -@runtime_checkable -class OutputTarget(Protocol): - """Consumes generated session outputs for presentation or persistence.""" - - def open(self) -> None: - """Prepare the target for a new run.""" - ... - - def write(self, result: InferenceOutput) -> None: - """Consume one generated step result.""" - ... - - def close(self) -> Sequence[OutputArtifact]: - """Finalize and return any produced artifacts.""" - ... - - -@dataclass(slots=True) -class NullOutputTarget: - """Output target for headless runs and throughput measurements.""" - - store_results: bool = False - output_count: int = field(default=0, init=False) - results: list[InferenceOutput] = field(default_factory=list, init=False) - _opened: bool = field(default=False, init=False, repr=False) - - @property - def closed(self) -> bool: - return not self._opened - - def open(self) -> None: - self._opened = True - self.output_count = 0 - self.results.clear() - - def write(self, result: InferenceOutput) -> None: - if not self._opened: - raise RuntimeError("Cannot write to a closed output target.") - self.output_count += 1 - if self.store_results: - self.results.append(result) - - def close(self) -> Sequence[OutputArtifact]: - self._opened = False - return () diff --git a/flashdreams/flashdreams/runtime/runner.py b/flashdreams/flashdreams/runtime/runner.py deleted file mode 100644 index 03d814472..000000000 --- a/flashdreams/flashdreams/runtime/runner.py +++ /dev/null @@ -1,199 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Minimal synchronous standard runner for the runtime API.""" - -from __future__ import annotations - -import math - -from flashdreams.runtime.canonical import InputCanonicalizer -from flashdreams.runtime.config import InferenceConfig -from flashdreams.runtime.inputs import ( - CanonicalInputs, - CanonicalInputSchema, - InferenceInput, - TimeWindow, - UserInputs, - UserInputSchema, -) -from flashdreams.runtime.interfaces import ( - InferenceRuntime, - InferenceSession, - ModelAdapter, -) -from flashdreams.runtime.mapping import ( - DeclaresMappingSchema, - InputMapping, - check_mapping_compatibility, -) -from flashdreams.runtime.metrics import MetricsRecorder -from flashdreams.runtime.output import OutputArtifact, OutputTarget -from flashdreams.runtime.types import StepResult - -_DEFAULT_SESSION_HORIZON_S = 3600.0 - - -def run_inference_session( - *, - adapter: ModelAdapter, - config: InferenceConfig, - mapping: InputMapping, - canonicalizer: InputCanonicalizer, - source_schema: UserInputSchema, - user_inputs: UserInputs, - initial_inputs: InferenceInput, - output: OutputTarget, - metrics: MetricsRecorder, -) -> tuple[OutputArtifact, ...]: - """Run one sequential inference session through the standard loop. - - This v0 loop intentionally handles one adapter/runtime/session, one selected - input mapping, one replay/live input batch, one output target, and one - metrics recorder. It is synchronous and owns only orchestration. - """ - - runtime: InferenceRuntime | None = None - session: InferenceSession | None = None - output_opened = False - output_artifacts: tuple[OutputArtifact, ...] = () - primary_error: BaseException | None = None - - try: - adapter.validate_config(config) - canonical_schema = canonicalizer.canonical_schema(source_schema) - _check_declared_mapping_compatibility( - mapping=mapping, - canonical_schema=canonical_schema, - adapter=adapter, - ) - mapping.validate( - canonical_schema=canonical_schema, - inference_input_schema=adapter.inference_input_schema, - ) - canonicalizer.reset() - mapped_initial_inputs = mapping.map_global_conditioning_inputs( - canonical_inputs=CanonicalInputs(), - inference_input=initial_inputs, - ) - runtime = adapter.create_runtime(config) - session = runtime.start_session(mapped_initial_inputs) - output.open() - output_opened = True - step_base_inputs = InferenceInput( - step=initial_inputs.step, - metadata=initial_inputs.metadata, - ) - - while (request := session.next_step_request()) is not None: - step_inputs = mapping.map_step_inputs( - canonical_inputs=canonicalizer.canonicalize( - user_inputs, - window=request.user_input_window - or _all_user_inputs_window(user_inputs), - source_schema=source_schema, - ), - inference_input=step_base_inputs, - request=request, - ) - result = session.step(step_inputs) - output.write(result) - _record_timing_metrics(metrics, result) - except BaseException as exc: - primary_error = exc - raise - finally: - cleanup_error, output_artifacts = _close_run_resources( - output=output if output_opened else None, - session=session, - runtime=runtime, - metrics=metrics, - ) - if cleanup_error is not None and primary_error is None: - raise cleanup_error - - return output_artifacts - - -def _check_declared_mapping_compatibility( - *, - mapping: InputMapping, - canonical_schema: CanonicalInputSchema, - adapter: ModelAdapter, -) -> None: - if not isinstance(mapping, DeclaresMappingSchema): - return - compatibility = check_mapping_compatibility( - canonical_schema=canonical_schema, - inference_input_schema=adapter.inference_input_schema, - mapping_schema=mapping.mapping_schema, - ) - compatibility.raise_if_incompatible() - - -def _all_user_inputs_window(user_inputs: UserInputs) -> TimeWindow: - if not user_inputs.events: - return TimeWindow(start_s=0.0, end_s=_DEFAULT_SESSION_HORIZON_S) - return TimeWindow( - start_s=0.0, - end_s=max( - _DEFAULT_SESSION_HORIZON_S, - math.nextafter(user_inputs.events[-1].timestamp_s, math.inf), - ), - ) - - -def _record_timing_metrics(metrics: MetricsRecorder, result: StepResult) -> None: - for name, value in result.metrics.items(): - if not name.endswith("_s") or isinstance(value, bool): - continue - sample_name = name[:-2] or name - metrics.record_timing( - sample_name, - float(value), - step_index=result.step_index, - ) - - -def _close_run_resources( - *, - output: OutputTarget | None, - session: InferenceSession | None, - runtime: InferenceRuntime | None, - metrics: MetricsRecorder, -) -> tuple[BaseException | None, tuple[OutputArtifact, ...]]: - cleanup_error: BaseException | None = None - artifacts: tuple[OutputArtifact, ...] = () - - def remember_error(exc: BaseException) -> None: - nonlocal cleanup_error - if cleanup_error is None: - cleanup_error = exc - - if output is not None: - try: - artifacts = tuple(output.close()) - except BaseException as exc: - remember_error(exc) - - if session is not None: - try: - session.close() - except BaseException as exc: - remember_error(exc) - - if runtime is not None: - try: - runtime.close() - except BaseException as exc: - remember_error(exc) - - try: - metrics.close() - except BaseException as exc: - remember_error(exc) - - return cleanup_error, artifacts - - -__all__ = ["run_inference_session"] diff --git a/flashdreams/flashdreams/runtime/types.py b/flashdreams/flashdreams/runtime/types.py deleted file mode 100644 index ecf045166..000000000 --- a/flashdreams/flashdreams/runtime/types.py +++ /dev/null @@ -1,60 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Plain data carriers shared by runtime protocols and adapters.""" - -from __future__ import annotations - -from collections.abc import Mapping -from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Any - -from flashdreams.runtime._utils import freeze_mapping -from flashdreams.runtime.inputs import TimeWindow - -if TYPE_CHECKING: - from flashdreams.runtime.inference_session import InferenceInputSchema - - -@dataclass(frozen=True, kw_only=True, slots=True) -class StepRequest: - """Per-step runtime request emitted by an inference session. - - This is not a schema declaration. ``user_input_window`` lets a runner drain - or slice timestamped user events for the current step before invoking the - selected ``InputMapping``. - """ - - __hash__ = None - - step_index: int - inference_input_schema: InferenceInputSchema | None = None - user_input_window: TimeWindow | None = None - metadata: Mapping[str, Any] = field(default_factory=dict) - - def __post_init__(self) -> None: - if self.step_index < 0: - raise ValueError("StepRequest.step_index must be >= 0.") - object.__setattr__(self, "metadata", freeze_mapping(self.metadata)) - - -@dataclass(frozen=True, kw_only=True, slots=True) -class StepResult: - """Generated output and metadata returned by one inference step.""" - - __hash__ = None - - step_index: int - output: Any = None - frame_count: int | None = None - output_window: TimeWindow | None = None - metadata: Mapping[str, Any] = field(default_factory=dict) - metrics: Mapping[str, float | int] = field(default_factory=dict) - - def __post_init__(self) -> None: - if self.step_index < 0: - raise ValueError("StepResult.step_index must be >= 0.") - if self.frame_count is not None and self.frame_count < 0: - raise ValueError("StepResult.frame_count must be >= 0.") - object.__setattr__(self, "metadata", freeze_mapping(self.metadata)) - object.__setattr__(self, "metrics", freeze_mapping(self.metrics)) diff --git a/flashdreams/flashdreams/runtime/video_output.py b/flashdreams/flashdreams/runtime/video_output.py deleted file mode 100644 index b5c372125..000000000 --- a/flashdreams/flashdreams/runtime/video_output.py +++ /dev/null @@ -1,157 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Video output targets for the runtime API.""" - -from __future__ import annotations - -from collections.abc import Callable, Sequence -from dataclasses import dataclass, field -from pathlib import Path -from typing import cast - -import torch - -from flashdreams.infra.postprocess import VideoTensorLayout -from flashdreams.infra.runner_io import ( - DEFAULT_RUNNER_INSTALL_HINT, - write_video_tensor, -) -from flashdreams.infra.runner_io import ( - VideoTensorLayout as WritableVideoTensorLayout, -) -from flashdreams.infra.video_output import RunnerVideoOutputStream, VideoStepResult -from flashdreams.runtime.output import OutputArtifact -from flashdreams.runtime.types import StepResult - -VideoWriter = Callable[..., Path] - - -@dataclass(slots=True) -class Mp4VideoOutputTarget: - """Write runtime ``VideoStepResult`` chunks to one MP4 artifact.""" - - output_path: Path - fps: int | float - output_layout: VideoTensorLayout = "bvtchw" - writer: VideoWriter = field(default=write_video_tensor, repr=False) - install_hint: str = DEFAULT_RUNNER_INSTALL_HINT - move_to_cpu: bool = True - _opened: bool = field(default=False, init=False, repr=False) - _stream: RunnerVideoOutputStream | None = field( - default=None, - init=False, - repr=False, - ) - - @property - def closed(self) -> bool: - return not self._opened - - def open(self) -> None: - self._stream = RunnerVideoOutputStream( - postprocess_stream=None, - output_layout=self.output_layout, - collect_output=True, - move_to_cpu=self.move_to_cpu, - ) - self._opened = True - - def write(self, result: StepResult) -> None: - if not self._opened or self._stream is None: - raise RuntimeError("Cannot write to a closed output target.") - video_result = result.output - if not isinstance(video_result, VideoStepResult): - raise TypeError( - "Mp4VideoOutputTarget requires StepResult.output to be " - f"VideoStepResult, got {type(video_result).__name__}." - ) - if video_result.layout != self.output_layout: - raise ValueError( - "Mp4VideoOutputTarget received layout " - f"{video_result.layout!r}; expected {self.output_layout!r}." - ) - stats = dict(video_result.stats or result.metrics) - stats_extra: dict[str, object] = { - "step_index": result.step_index, - "frames": video_result.num_frames, - } - if result.output_window is not None: - stats_extra["output_start_s"] = result.output_window.start_s - stats_extra["output_end_s"] = result.output_window.end_s - self._stream.process( - video_result.video_chunk, - autoregressive_index=video_result.chunk_index, - stats=stats if stats else None, - stats_extra=stats_extra, - ) - - def close(self) -> Sequence[OutputArtifact]: - if self._stream is None: - self._opened = False - return () - - stream = self._stream - self._stream = None - self._opened = False - video = stream.finish() - if video is None: - return () - - writable_video, writable_layout = _prepare_video_for_mp4( - video, - layout=self.output_layout, - ) - path = self.writer( - writable_video, - self.output_path, - fps=self.fps, - layout=writable_layout, - install_hint=self.install_hint, - ) - return ( - OutputArtifact( - kind="video/mp4", - uri=str(path), - metadata={ - "fps": self.fps, - "source_layout": self.output_layout, - "write_layout": writable_layout, - "shape": tuple(int(dim) for dim in writable_video.shape), - "stats_history": tuple(stream.stats_history), - }, - ), - ) - - -def _prepare_video_for_mp4( - video: torch.Tensor, - *, - layout: VideoTensorLayout, -) -> tuple[torch.Tensor, WritableVideoTensorLayout]: - """Convert runtime video layouts into layouts accepted by runner I/O.""" - if layout in {"tchw", "btchw", "bcthw"}: - return video, cast(WritableVideoTensorLayout, layout) - if layout == "bvtchw": - if video.ndim != 6: - raise ValueError( - "layout='bvtchw' expects a 6D [B,V,T,C,H,W] tensor, " - f"got {tuple(video.shape)}." - ) - if video.shape[0] != 1: - raise ValueError( - "layout='bvtchw' MP4 writing expects a single batch element, " - f"got {tuple(video.shape)}." - ) - _, views, frames, channels, height, width = video.shape - canvas = ( - video[0] - .permute(1, 3, 0, 4, 2) - .contiguous() - .reshape(frames, height, views * width, channels) - ) - return canvas, "thwc" - raise ValueError(f"unsupported runtime video layout for MP4: {layout!r}") - - -__all__ = ["Mp4VideoOutputTarget"] diff --git a/flashdreams/tests/test_inference_runtime_api.py b/flashdreams/tests/test_inference_runtime_api.py deleted file mode 100644 index 9f8091512..000000000 --- a/flashdreams/tests/test_inference_runtime_api.py +++ /dev/null @@ -1,281 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -from __future__ import annotations - -from dataclasses import fields -from typing import Any, cast - -import pytest - -from flashdreams.runtime import ( - CanonicalInputs, - IdentityInputMapping, - InferenceConfig, - InferenceInput, - InferenceInputSchema, - InMemoryMetricsRecorder, - InputField, - NullOutputTarget, - OutputArtifact, - RuntimeMetricSample, - StepRequest, - StepResult, - TimeWindow, - UserInputEvent, - UserInputs, - UserInputSchema, -) -from flashdreams.runtime.inference_session import ( - InferenceSession as PipelineInferenceSession, -) - -pytestmark = pytest.mark.ci_cpu - - -def test_inference_config_keeps_runtime_settings_separate() -> None: - denied_app_fields = {"prompt", "output_dir", "browser_settings"} - config = InferenceConfig( - model_id="lingbot-world", - preset_id="fast-taehv", - backend="local", - precision="bf16", - compile=False, - runtime_options={"chunk_size": 3}, - ) - - assert config.model_id == "lingbot-world" - assert config.preset_id == "fast-taehv" - assert config.runtime_options["chunk_size"] == 3 - assert denied_app_fields.isdisjoint(field.name for field in fields(InferenceConfig)) - with pytest.raises(TypeError): - cast(Any, config.runtime_options)["chunk_size"] = 4 - - -def test_inference_config_rejects_empty_model_id() -> None: - with pytest.raises(ValueError, match="model_id"): - InferenceConfig(model_id=" ") - - -@pytest.mark.parametrize( - ("factory", "match"), - [ - (lambda: InputField(name=" "), "InputField.name"), - (lambda: TimeWindow(start_s=1.0, end_s=0.0), "end_s"), - (lambda: TimeWindow(start_s=-1.0, end_s=0.0), "non-negative"), - (lambda: TimeWindow(start_s=0.0, end_s=float("nan")), "finite"), - ( - lambda: UserInputEvent(timestamp_s=-1.0, event_type="keydown"), - "timestamp_s", - ), - (lambda: UserInputEvent(timestamp_s=0.0, event_type=" "), "event_type"), - (lambda: StepRequest(step_index=-1), "step_index"), - (lambda: InferenceOutput(step_index=-1), "step_index"), - (lambda: InferenceOutput(step_index=0, frame_count=-1), "frame_count"), - (lambda: RuntimeMetricSample(name=" ", value=1.0), "name"), - (lambda: RuntimeMetricSample(name="sample", value=float("nan")), "finite"), - (lambda: OutputArtifact(kind=" ", uri="artifact://demo"), "kind"), - (lambda: OutputArtifact(kind="mp4", uri=" "), "uri"), - ], -) -def test_runtime_envelopes_reject_invalid_values(factory: object, match: str) -> None: - with pytest.raises(ValueError, match=match): - cast(Any, factory)() - - -def test_runtime_metric_sample_rejects_bool_values() -> None: - with pytest.raises(TypeError, match="numeric"): - RuntimeMetricSample(name="sample", value=True) - - -def test_schema_validates_global_conditioning_and_step_payloads() -> None: - schema = InferenceInputSchema( - global_conditioning_fields=( - InputField(name="prompt"), - InputField(name="global_conditioning_frame"), - ), - per_step_fields=(InputField(name="camera_poses"),), - ) - inputs = InferenceInput( - global_conditioning={ - "prompt": "drive", - "global_conditioning_frame": object(), - }, - per_step_conditioning={"camera_poses": object()}, - ) - - schema.require_global_conditioning(inputs) - assert schema.missing_step(inputs) == ("camera_poses",) - - with pytest.raises(ValueError, match="prompt"): - schema.check_global_payload(InferenceInput()) - with pytest.raises(ValueError, match="camera_poses"): - schema.check_per_step_payload(InferenceInput()) - - -def test_inference_input_and_schema_only_declare_two_fields() -> None: - assert tuple(InferenceInput.__dataclass_fields__) == ( - "global_conditioning", - "per_step_conditioning", - ) - assert tuple(InferenceInputSchema.__dataclass_fields__) == ( - "global_fields", - "per_step_fields", - ) - - -def test_inference_output_matches_step_result_fields() -> None: - assert tuple(field.name for field in fields(InferenceOutput)) == tuple( - field.name for field in fields(StepResult) - ) - - -def test_inference_session_uses_step_requests_instead_of_a_schema_property() -> None: - assert "inference_input_schema" not in PipelineInferenceSession.__dict__ - assert callable(PipelineInferenceSession.next_step_request) - - -def test_pipeline_inference_session_requires_reset_and_step_implementations() -> None: - assert PipelineInferenceSession.__abstractmethods__ == frozenset({"reset", "step"}) - - -def test_user_inputs_filter_timestamped_event_windows() -> None: - inputs = UserInputs( - events=( - UserInputEvent( - timestamp_s=0.1, - event_type="keyboard.keydown", - payload={"key": "w"}, - ), - UserInputEvent( - timestamp_s=0.4, - event_type="keyboard.keyup", - payload={"key": "w"}, - ), - UserInputEvent(timestamp_s=0.8, event_type="reset"), - ) - ) - - windowed = inputs.window(TimeWindow(start_s=0.25, end_s=0.75)) - - assert [event.event_type for event in windowed.events] == ["keyboard.keyup"] - - -def test_user_inputs_require_sorted_events() -> None: - with pytest.raises(ValueError, match="non-decreasing"): - UserInputs( - events=( - UserInputEvent(timestamp_s=1.0, event_type="late"), - UserInputEvent(timestamp_s=0.5, event_type="early"), - ) - ) - - -def test_user_input_schema_declares_event_capabilities() -> None: - schema = UserInputSchema( - event_types=frozenset({"keyboard.keydown", "keyboard.keyup", "reset"}) - ) - - assert schema.supports_event_types(["keyboard.keydown", "reset"]) - assert not schema.supports_event_types(["prompt.update"]) - - -def test_user_input_schema_validates_required_snapshot_fields() -> None: - schema = UserInputSchema( - snapshot_fields=( - InputField(name="pressed_keys"), - InputField(name="prompt", required=False), - ) - ) - inputs = UserInputs(snapshot={"pressed_keys": frozenset({"w"})}) - - schema.require_snapshot(inputs) - assert schema.missing_snapshot(UserInputs()) == ("pressed_keys",) - - with pytest.raises(ValueError, match="pressed_keys"): - schema.require_snapshot(UserInputs()) - - -def test_identity_input_mapping_leaves_inference_input_unchanged() -> None: - mapping = IdentityInputMapping() - inference_input = InferenceInput( - global_conditioning={"prompt": "fixed"}, - per_step_conditioning={"hdmap": object()}, - ) - request = StepRequest(step_index=0) - - assert ( - mapping.map_global_conditioning_inputs( - canonical_inputs=CanonicalInputs(), - inference_input=inference_input, - ) - is inference_input - ) - assert ( - mapping.map_step_inputs( - canonical_inputs=CanonicalInputs(), - inference_input=inference_input, - request=request, - ) - is inference_input - ) - - -def test_null_output_target_counts_and_optionally_stores_results() -> None: - target = NullOutputTarget(store_results=True) - result = InferenceOutput(step_index=0, output=b"frame") - - assert target.closed - with pytest.raises(RuntimeError, match="closed output target"): - target.write(result) - - target.open() - assert not target.closed - target.write(result) - artifacts = target.close() - - assert target.closed - assert artifacts == () - assert target.output_count == 1 - assert target.results == [result] - with pytest.raises(RuntimeError, match="closed output target"): - target.write(InferenceOutput(step_index=1)) - - -def test_null_output_target_open_resets_per_run_state() -> None: - target = NullOutputTarget(store_results=True) - - target.open() - target.write(InferenceOutput(step_index=0, output=b"first")) - target.close() - target.open() - - assert target.output_count == 0 - assert target.results == [] - target.write(InferenceOutput(step_index=0, output=b"second")) - assert target.output_count == 1 - assert target.results == [InferenceOutput(step_index=0, output=b"second")] - - -def test_in_memory_metrics_recorder_uses_seconds_for_timing() -> None: - recorder = InMemoryMetricsRecorder() - - recorder.record_timing("model_step", 0.125, step_index=2) - - assert len(recorder.samples) == 1 - sample = recorder.samples[0] - assert sample.name == "model_step" - assert sample.value == pytest.approx(0.125) - assert sample.unit == "s" - assert sample.category == "timing" - assert sample.step_index == 2 - - -def test_timing_metric_samples_must_use_seconds() -> None: - with pytest.raises(ValueError, match="unit='s'"): - RuntimeMetricSample( - name="model_step", - value=12.5, - unit="ms", - category="timing", - ) diff --git a/flashdreams/tests/test_inference_session.py b/flashdreams/tests/test_inference_session.py deleted file mode 100644 index 261b53843..000000000 --- a/flashdreams/tests/test_inference_session.py +++ /dev/null @@ -1,198 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Tests for inference-session pipeline orchestration.""" - -from typing import Any, cast - -import pytest -import torch - -from flashdreams.infra.pipeline import ( - StreamInferencePipeline, - StreamInferencePipelineConfig, -) -from flashdreams.runtime.inference_session import ( - InferenceInput, - InferenceInputSchema, - InferenceOutput, - InferenceSession, - InferenceSessionConfig, -) -from flashdreams.runtime.inputs import TimeWindow -from flashdreams.runtime.types import StepRequest - -pytestmark = pytest.mark.ci_cpu - - -class FakeStreamInferencePipeline(StreamInferencePipeline[Any, Any, Any]): - """Record session orchestration without constructing model components.""" - - def __init__(self, output: Any) -> None: - torch.nn.Module.__init__(self) - self.output = output - self.cache = object() - self.reset_calls = 0 - self.initialize_cache_calls: list[dict[str, Any]] = [] - self.generate_calls: list[dict[str, Any]] = [] - self.finalize_calls: list[dict[str, Any]] = [] - - def reset(self) -> None: - self.reset_calls += 1 - - def initialize_cache(self, **global_conditioning: Any) -> object: - self.initialize_cache_calls.append(global_conditioning) - self.cache = object() - return self.cache - - def generate( - self, autoregressive_index: int, cache: object, input: Any = None - ) -> Any: - self.generate_calls.append( - { - "autoregressive_index": autoregressive_index, - "cache": cache, - "input": input, - } - ) - return self.output - - def finalize(self, autoregressive_index: int, cache: object) -> dict[str, float]: - self.finalize_calls.append( - {"autoregressive_index": autoregressive_index, "cache": cache} - ) - return {"total_ms": 4.0} - - -class _FakePipelineConfig: - def __init__(self, pipeline: FakeStreamInferencePipeline) -> None: - self.pipeline = pipeline - self.setup_calls = 0 - - def setup(self) -> FakeStreamInferencePipeline: - self.setup_calls += 1 - return self.pipeline - - -class _ConcreteInferenceSession(InferenceSession): - def next_step_request(self) -> StepRequest: - return StepRequest( - step_index=self._step_index, - inference_input_schema=InferenceInputSchema(), - user_input_window=TimeWindow( - start_s=float(self._step_index), - end_s=float(self._step_index + 1), - ), - metadata={"request": "fake"}, - ) - - def reset(self) -> None: - super().reset() - - def step(self, inference_input: InferenceInput) -> InferenceOutput: - return super().step(inference_input) - - -def _create_session( - pipeline: FakeStreamInferencePipeline, -) -> tuple[_ConcreteInferenceSession, _FakePipelineConfig]: - pipeline_config = _FakePipelineConfig(pipeline) - config = InferenceSessionConfig( - pipeline=cast(StreamInferencePipelineConfig, pipeline_config) - ) - return _ConcreteInferenceSession(config), pipeline_config - - -def test_constructor_initializes_the_configured_pipeline() -> None: - pipeline = FakeStreamInferencePipeline(output=object()) - - session, pipeline_config = _create_session(pipeline) - - assert session.pipeline is pipeline - assert pipeline_config.setup_calls == 1 - - -def test_reset_resets_the_pipeline_and_rollout_state() -> None: - pipeline = FakeStreamInferencePipeline(output=object()) - session, _ = _create_session(pipeline) - session.step( - InferenceInput( - global_conditioning={"prompt": "first"}, - per_step_conditioning={"control": 1}, - ) - ) - - session.reset() - result = session.step( - InferenceInput( - global_conditioning={"prompt": "second"}, - per_step_conditioning={"control": 2}, - ) - ) - - assert pipeline.reset_calls == 1 - assert pipeline.initialize_cache_calls == [ - {"prompt": "first"}, - {"prompt": "second"}, - ] - assert result.step_index == 0 - - -def test_step_converts_inference_input_and_wraps_pipeline_output() -> None: - generated = object() - pipeline = FakeStreamInferencePipeline(output=generated) - session, _ = _create_session(pipeline) - inference_input = InferenceInput( - global_conditioning={"prompt": "drive"}, - per_step_conditioning={"steering": 0.25}, - ) - - result = session.step(inference_input) - - assert pipeline.initialize_cache_calls == [{"prompt": "drive"}] - assert pipeline.generate_calls == [ - { - "autoregressive_index": 0, - "cache": pipeline.cache, - "input": {"steering": 0.25}, - } - ] - assert pipeline.finalize_calls == [ - {"autoregressive_index": 0, "cache": pipeline.cache} - ] - assert result == InferenceOutput( - step_index=0, - output=generated, - output_window=TimeWindow(start_s=0.0, end_s=1.0), - metadata={"request": "fake"}, - metrics={"total_ms": 4.0}, - ) - - -def test_step_reuses_the_pipeline_cache_and_advances_the_index() -> None: - pipeline = FakeStreamInferencePipeline(output=object()) - session, _ = _create_session(pipeline) - session.step(InferenceInput(global_conditioning={"prompt": "drive"})) - - result = session.step(InferenceInput(per_step_conditioning={"steering": -0.5})) - - assert pipeline.initialize_cache_calls == [{"prompt": "drive"}] - assert pipeline.generate_calls[-1] == { - "autoregressive_index": 1, - "cache": pipeline.cache, - "input": {"steering": -0.5}, - } - assert result.step_index == 1 - assert session.next_step_request().step_index == 2 diff --git a/flashdreams/tests/test_runtime_canonical.py b/flashdreams/tests/test_runtime_canonical.py deleted file mode 100644 index 838fe505d..000000000 --- a/flashdreams/tests/test_runtime_canonical.py +++ /dev/null @@ -1,591 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Tests for the raw-input to canonical-modality layer. - -These cover the middle leg of ``raw input -> canonicalized input -> encoded -inference input``: applications consume canonical modalities, never raw device -events, so adding a device is a registration rather than an application change. -""" - -from __future__ import annotations - -from collections.abc import Mapping -from typing import Any - -import pytest - -from flashdreams.runtime import ( - DRIVER_COMMAND, - CanonicalInputs, - CanonicalModality, - DeviceConverterSchema, - InferenceInput, - InferenceInputSchema, - InputCanonicalizer, - InputField, - InputMappingSchema, - KeyboardToDriverCommand, - ScriptedModality, - TimeWindow, - UserInputCapability, - UserInputEvent, - UserInputs, - UserInputSchema, - check_mapping_compatibility, -) - -pytestmark = pytest.mark.ci_cpu - -KEYBOARD_SOURCE = UserInputSchema( - capabilities=( - UserInputCapability(event_type="key_down", payload_fields=frozenset({"key"})), - UserInputCapability(event_type="key_up", payload_fields=frozenset({"key"})), - ) -) -WHEEL_SOURCE = UserInputSchema( - capabilities=( - UserInputCapability( - event_type="wheel_axis", payload_fields=frozenset({"axis", "value"}) - ), - ) -) -PROMPT_SOURCE = UserInputSchema( - capabilities=( - UserInputCapability( - event_type="prompt_set", payload_fields=frozenset({"prompt"}) - ), - ) -) - -# Written once against the canonical modality. It names no key and no axis. -STEERING_MAPPING = InputMappingSchema( - name="driver-command-to-steering", - consumes=(DRIVER_COMMAND,), - produces_step=(InputField(name="steering"),), -) -STEERING_MODEL = InferenceInputSchema(per_step_fields=(InputField(name="steering"),)) - -WINDOW = TimeWindow(start_s=0.0, end_s=1.0) -NEXT_WINDOW = TimeWindow(start_s=1.0, end_s=2.0) - - -class WheelToDriverCommand: - """Minimal wheel converter standing in for a real evdev profile.""" - - def __init__(self, *, priority: int = 10) -> None: - self._steer = 0.0 - self._seen = False - self._schema = DeviceConverterSchema( - name="wheel-to-driver-command", - produces=DRIVER_COMMAND, - device_kind="wheel", - priority=priority, - consumes=( - UserInputCapability( - event_type="wheel_axis", - payload_fields=frozenset({"axis", "value"}), - ), - ), - ) - - @property - def schema(self) -> DeviceConverterSchema: - return self._schema - - def reset(self) -> None: - self._steer = 0.0 - self._seen = False - - def convert( - self, user_inputs: UserInputs, window: TimeWindow - ) -> Mapping[str, Any] | None: - del window - for event in user_inputs.events: - if event.event_type == "wheel_axis" and event.payload["axis"] == "steer": - self._seen = True - self._steer = float(event.payload["value"]) - if not self._seen: - return None - return DRIVER_COMMAND.value( - { - "throttle": 0.0, - "brake": 0.0, - "steer": self._steer, - "stop": False, - "reverse": False, - } - ) - - -def _key(event_type: str, key: str, timestamp_s: float) -> UserInputEvent: - return UserInputEvent( - timestamp_s=timestamp_s, event_type=event_type, payload={"key": key} - ) - - -def _command(canonical: CanonicalInputs) -> Mapping[str, Any]: - assert DRIVER_COMMAND.name in canonical.values - return canonical.values[DRIVER_COMMAND.name] - - -# --- per-step conditioning ---------------------------------------------- - - -def test_keyboard_edges_become_canonical_driver_command() -> None: - canonicalizer = InputCanonicalizer([KeyboardToDriverCommand()]) - inputs = UserInputs(events=(_key("key_down", "w", 0.1),)) - - canonical = canonicalizer.canonicalize( - inputs, window=WINDOW, source_schema=KEYBOARD_SOURCE - ) - - assert _command(canonical)["throttle"] == 1.0 - assert _command(canonical)["steer"] == 0.0 - assert canonical.metadata["canonical_sources"]["driver_command"] == "keyboard" - - -def test_key_aliases_are_normalized() -> None: - canonicalizer = InputCanonicalizer([KeyboardToDriverCommand()]) - inputs = UserInputs(events=(_key("key_down", "ArrowLeft", 0.1),)) - - canonical = canonicalizer.canonicalize( - inputs, window=WINDOW, source_schema=KEYBOARD_SOURCE - ) - - assert _command(canonical)["steer"] == 1.0 - - -def test_held_key_still_emits_in_a_window_with_no_events() -> None: - """Edge-triggered HID must become level-triggered per-step conditioning.""" - canonicalizer = InputCanonicalizer([KeyboardToDriverCommand()]) - inputs = UserInputs(events=(_key("key_down", "w", 0.1),)) - canonicalizer.canonicalize(inputs, window=WINDOW, source_schema=KEYBOARD_SOURCE) - - quiet = canonicalizer.canonicalize( - inputs, window=NEXT_WINDOW, source_schema=KEYBOARD_SOURCE - ) - - assert _command(quiet)["throttle"] == 1.0 - - -def test_key_release_returns_to_neutral() -> None: - canonicalizer = InputCanonicalizer([KeyboardToDriverCommand()]) - inputs = UserInputs(events=(_key("key_down", "a", 0.1), _key("key_up", "a", 1.5))) - canonicalizer.canonicalize(inputs, window=WINDOW, source_schema=KEYBOARD_SOURCE) - - released = canonicalizer.canonicalize( - inputs, window=NEXT_WINDOW, source_schema=KEYBOARD_SOURCE - ) - - assert _command(released)["steer"] == 0.0 - - -def test_reset_drops_device_state() -> None: - canonicalizer = InputCanonicalizer([KeyboardToDriverCommand()]) - inputs = UserInputs(events=(_key("key_down", "w", 0.1),)) - canonicalizer.canonicalize(inputs, window=WINDOW, source_schema=KEYBOARD_SOURCE) - - canonicalizer.reset() - after = canonicalizer.canonicalize( - UserInputs(), window=NEXT_WINDOW, source_schema=KEYBOARD_SOURCE - ) - - assert _command(after)["throttle"] == 0.0 - - -# --- boundary: global conditioning is not canonicalized ----------------- - - -def test_canonical_inputs_carry_live_control_only() -> None: - """Global conditioning is application-owned and bypasses this layer.""" - canonicalizer = InputCanonicalizer([KeyboardToDriverCommand()]) - inputs = UserInputs( - events=( - _key("key_down", "w", 0.1), - UserInputEvent( - timestamp_s=0.2, event_type="prompt_set", payload={"prompt": "rain"} - ), - ) - ) - - canonical = canonicalizer.canonicalize( - inputs, window=WINDOW, source_schema=KEYBOARD_SOURCE - ) - - assert set(canonical.values) == {"driver_command"} - - -def test_application_supplies_global_conditioning_directly() -> None: - """A prompt reaches session start without touching canonicalization.""" - inputs = InferenceInput( - global_conditioning={"prompt": "heavy rain"}, - step={"steering": 0.0}, - ) - - assert inputs.global_conditioning["prompt"] == "heavy rain" - assert inputs.step["steering"] == 0.0 - - -# --- device independence ------------------------------------------------ - - -def test_mapping_written_against_a_modality_accepts_a_keyboard() -> None: - canonicalizer = InputCanonicalizer([KeyboardToDriverCommand()]) - - compatibility = check_mapping_compatibility( - canonical_schema=canonicalizer.canonical_schema(KEYBOARD_SOURCE), - inference_input_schema=STEERING_MODEL, - mapping_schema=STEERING_MAPPING, - ) - - assert compatibility.can_drive - - -def test_adding_a_device_needs_no_application_or_model_change() -> None: - """A wheel is one register() call; mapping and model schemas are untouched.""" - canonicalizer = InputCanonicalizer([KeyboardToDriverCommand()]) - canonicalizer.register(WheelToDriverCommand()) - - compatibility = check_mapping_compatibility( - canonical_schema=canonicalizer.canonical_schema(WHEEL_SOURCE), - inference_input_schema=STEERING_MODEL, - mapping_schema=STEERING_MAPPING, - ) - assert compatibility.can_drive - - canonical = canonicalizer.canonicalize( - UserInputs( - events=( - UserInputEvent( - timestamp_s=0.5, - event_type="wheel_axis", - payload={"axis": "steer", "value": -0.4}, - ), - ) - ), - window=WINDOW, - source_schema=WHEEL_SOURCE, - ) - assert _command(canonical)["steer"] == pytest.approx(-0.4) - - -def test_source_with_no_feedable_converter_supplies_no_modalities() -> None: - canonicalizer = InputCanonicalizer([WheelToDriverCommand()]) - - schema = canonicalizer.canonical_schema(KEYBOARD_SOURCE) - - assert schema.modalities == () - assert not schema.supports(DRIVER_COMMAND) - assert canonicalizer.unavailable_converters(KEYBOARD_SOURCE) - - -def test_highest_priority_device_wins_when_both_are_present() -> None: - canonicalizer = InputCanonicalizer( - [KeyboardToDriverCommand(), WheelToDriverCommand()] - ) - both = UserInputSchema( - capabilities=KEYBOARD_SOURCE.capabilities + WHEEL_SOURCE.capabilities - ) - - canonical = canonicalizer.canonicalize( - UserInputs( - events=( - _key("key_down", "a", 0.2), - UserInputEvent( - timestamp_s=0.5, - event_type="wheel_axis", - payload={"axis": "steer", "value": -0.4}, - ), - ) - ), - window=WINDOW, - source_schema=both, - ) - - assert canonical.metadata["canonical_sources"]["driver_command"] == "wheel" - assert _command(canonical)["steer"] == pytest.approx(-0.4) - - -def test_preempted_device_keeps_its_state_current() -> None: - """Keyboard state must not be stale when the wheel disappears.""" - canonicalizer = InputCanonicalizer( - [KeyboardToDriverCommand(), WheelToDriverCommand()] - ) - both = UserInputSchema( - capabilities=KEYBOARD_SOURCE.capabilities + WHEEL_SOURCE.capabilities - ) - inputs = UserInputs( - events=( - _key("key_down", "w", 0.2), - UserInputEvent( - timestamp_s=0.5, - event_type="wheel_axis", - payload={"axis": "steer", "value": -0.4}, - ), - ) - ) - preempted = canonicalizer.canonicalize(inputs, window=WINDOW, source_schema=both) - assert preempted.metadata["canonical_sources"]["driver_command"] == "wheel" - - keyboard_only = canonicalizer.canonicalize( - inputs, window=NEXT_WINDOW, source_schema=KEYBOARD_SOURCE - ) - - assert keyboard_only.metadata["canonical_sources"]["driver_command"] == "keyboard" - assert _command(keyboard_only)["throttle"] == 1.0 - - -# --- registry ----------------------------------------------------------- - - -def test_duplicate_converter_names_are_rejected() -> None: - canonicalizer = InputCanonicalizer([KeyboardToDriverCommand()]) - - with pytest.raises(ValueError, match="already registered"): - canonicalizer.register(KeyboardToDriverCommand()) - - -def test_converter_must_fill_the_declared_modality_payload() -> None: - modality = CanonicalModality( - name="steering_wheel", payload_fields=frozenset({"steer", "throttle"}) - ) - - with pytest.raises(ValueError, match="requires payload fields"): - modality.value({"steer": 0.0}) - - -def test_new_modality_is_a_registration_not_a_core_change() -> None: - pedals = CanonicalModality( - name="pedal_state", payload_fields=frozenset({"throttle"}) - ) - - class PedalsConverter: - schema = DeviceConverterSchema( - name="pedals", - produces=pedals, - device_kind="pedals", - consumes=( - UserInputCapability( - event_type="pedal_axis", - payload_fields=frozenset({"value"}), - ), - ), - ) - - def reset(self) -> None: - return None - - def convert( - self, user_inputs: UserInputs, window: TimeWindow - ) -> Mapping[str, Any] | None: - del window - if not user_inputs.events: - return None - return pedals.value( - {"throttle": float(user_inputs.events[-1].payload["value"])} - ) - - source = UserInputSchema( - capabilities=( - UserInputCapability( - event_type="pedal_axis", payload_fields=frozenset({"value"}) - ), - ) - ) - canonicalizer = InputCanonicalizer([PedalsConverter()]) - - assert canonicalizer.canonical_schema(source).modalities == (pedals,) - canonical = canonicalizer.canonicalize( - UserInputs( - events=( - UserInputEvent( - timestamp_s=0.5, event_type="pedal_axis", payload={"value": 0.75} - ), - ) - ), - window=WINDOW, - source_schema=source, - ) - assert canonical.values["pedal_state"]["throttle"] == pytest.approx(0.75) - - -def test_replaying_the_same_windows_reproduces_the_same_canonical_inputs() -> None: - inputs = UserInputs(events=(_key("key_down", "w", 0.1), _key("key_down", "a", 1.2))) - - def run() -> list[dict[str, Any]]: - canonicalizer = InputCanonicalizer([KeyboardToDriverCommand()]) - return [ - dict( - _command( - canonicalizer.canonicalize( - inputs, window=window, source_schema=KEYBOARD_SOURCE - ) - ) - ) - for window in (WINDOW, NEXT_WINDOW) - ] - - assert run() == run() - - -# --- key bindings ------------------------------------------------------- - - -def test_bindings_are_data_and_can_be_rebound() -> None: - """A layout change must not require editing the converter.""" - azerty = InputCanonicalizer( - [ - KeyboardToDriverCommand( - bindings={ - "throttle": frozenset({"z"}), - "brake": frozenset({"s"}), - "steer_left": frozenset({"q"}), - "steer_right": frozenset({"d"}), - "stop": frozenset({"space"}), - } - ) - ] - ) - - canonical = azerty.canonicalize( - UserInputs(events=(_key("key_down", "z", 0.1),)), - window=WINDOW, - source_schema=KEYBOARD_SOURCE, - ) - - assert _command(canonical)["throttle"] == 1.0 - - -def test_tracked_keys_are_derived_so_an_action_cannot_go_unreachable() -> None: - """Declaring bindings and tracked keys separately used to disagree.""" - converter = KeyboardToDriverCommand( - bindings={"stop": frozenset({"escape"}), "throttle": frozenset({"w"})} - ) - canonicalizer = InputCanonicalizer([converter]) - - canonical = canonicalizer.canonicalize( - UserInputs(events=(_key("key_down", "escape", 0.1),)), - window=WINDOW, - source_schema=KEYBOARD_SOURCE, - ) - - assert _command(canonical)["stop"] is True - - -def test_unknown_driver_action_is_rejected() -> None: - with pytest.raises(ValueError, match="Unknown driver actions"): - KeyboardToDriverCommand(bindings={"turbo": frozenset({"t"})}) - - -def test_reverse_is_bindable() -> None: - canonicalizer = InputCanonicalizer( - [KeyboardToDriverCommand(bindings={"reverse": frozenset({"r"})})] - ) - - canonical = canonicalizer.canonicalize( - UserInputs(events=(_key("key_down", "r", 0.1),)), - window=WINDOW, - source_schema=KEYBOARD_SOURCE, - ) - - assert _command(canonical)["reverse"] is True - - -# --- scripted / mock input ---------------------------------------------- - - -def _scripted() -> InputCanonicalizer: - return InputCanonicalizer( - [ - ScriptedModality( - modality=DRIVER_COMMAND, - timeline=[ - ( - 0.0, - { - "throttle": 1.0, - "brake": 0.0, - "steer": 0.0, - "stop": False, - "reverse": False, - }, - ), - ( - 2.0, - { - "throttle": 0.0, - "brake": 0.0, - "steer": 1.0, - "stop": False, - "reverse": False, - }, - ), - ], - ) - ] - ) - - -def test_mock_input_needs_no_raw_events_or_source_schema() -> None: - """Authoring a benchmark scenario must not require raw device vocabulary.""" - canonical = _scripted().canonicalize( - UserInputs(), window=WINDOW, source_schema=UserInputSchema() - ) - - assert _command(canonical)["throttle"] == 1.0 - - -def test_scripted_values_hold_until_the_next_entry() -> None: - canonicalizer = _scripted() - windows = [TimeWindow(start_s=t, end_s=t + 1.0) for t in (0.0, 1.0, 2.0)] - - steer = [ - _command( - canonicalizer.canonicalize( - UserInputs(), window=w, source_schema=UserInputSchema() - ) - )["steer"] - for w in windows - ] - - assert steer == [0.0, 0.0, 1.0] - - -def test_scripted_converter_is_silent_before_its_first_entry() -> None: - canonicalizer = InputCanonicalizer( - [ - ScriptedModality( - modality=CanonicalModality(name="late", payload_fields=frozenset()), - timeline=[(5.0, {})], - ) - ] - ) - - canonical = canonicalizer.canonicalize( - UserInputs(), window=WINDOW, source_schema=UserInputSchema() - ) - - assert canonical.values == {} - - -def test_scripted_timeline_is_validated_against_the_modality() -> None: - with pytest.raises(ValueError, match="requires payload fields"): - ScriptedModality(modality=DRIVER_COMMAND, timeline=[(0.0, {"throttle": 1.0})]) - - -def test_scripted_replay_is_deterministic() -> None: - def run() -> list[float]: - canonicalizer = _scripted() - return [ - _command( - canonicalizer.canonicalize( - UserInputs(), - window=TimeWindow(start_s=t, end_s=t + 1.0), - source_schema=UserInputSchema(), - ) - )["steer"] - for t in (0.0, 1.0, 2.0) - ] - - assert run() == run() diff --git a/flashdreams/tests/test_runtime_demo_api.py b/flashdreams/tests/test_runtime_demo_api.py deleted file mode 100644 index 7719c7c49..000000000 --- a/flashdreams/tests/test_runtime_demo_api.py +++ /dev/null @@ -1,458 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -from __future__ import annotations - -from collections.abc import Sequence -from pathlib import Path -from typing import Any - -import pytest -import torch - -from flashdreams.infra.video_output import VideoStepResult -from flashdreams.runtime import ( - CanonicalInputs, - CanonicalInputSchema, - IdentityInputMapping, - InferenceConfig, - InferenceInput, - InferenceInputSchema, - InferenceRuntime, - InferenceSession, - InputCanonicalizer, - InputField, - InputMapping, - InputMappingSchema, - NullMetricsRecorder, - NullOutputTarget, - OutputArtifact, - OutputTarget, - StepRequest, - StepResult, - TimeWindow, - UserInputs, - UserInputSchema, -) -from flashdreams.runtime.demo import ( - DemoSpec, - Mp4OutputSpec, - NullOutputSpec, - PreparedScenario, - WebRTCOutputSpec, - build_output_target, - run_replay_demo, -) -from flashdreams.runtime.demo.webrtc import build_webrtc_demo -from flashdreams.serving.webrtc.manager import BaseWebRTCSessionManager - -pytestmark = pytest.mark.ci_cpu - - -def test_replay_demo_uses_shared_runner() -> None: - adapter = _FakeDemoAdapter() - output = _RecordingOutputTarget() - calls: list[dict[str, Any]] = [] - - def fake_runner(**kwargs: Any) -> Sequence[OutputArtifact]: - calls.append(kwargs) - return (OutputArtifact(kind="test/artifact", uri="memory://artifact"),) - - spec = DemoSpec( - model_id="fake-demo", - scenario="valid-scenario", - input_mode="replay", - output=NullOutputSpec(), - ) - - artifacts = run_replay_demo( - spec=spec, - adapter=adapter, - output_target_factory=lambda output_spec: output, - metrics=NullMetricsRecorder(), - runner=fake_runner, - ) - - assert artifacts == (OutputArtifact(kind="test/artifact", uri="memory://artifact"),) - assert len(calls) == 1 - assert calls[0]["adapter"] is adapter - assert calls[0]["config"] == spec.config - assert calls[0]["mapping"] is adapter.prepared_scenario.mapping - assert calls[0]["canonicalizer"] is adapter.prepared_scenario.canonicalizer - assert calls[0]["source_schema"] is adapter.prepared_scenario.source_schema - assert calls[0]["user_inputs"] is adapter.prepared_scenario.user_inputs - assert calls[0]["initial_inputs"] is adapter.prepared_scenario.initial_inputs - assert calls[0]["output"] is output - assert adapter.prepare_scenario_calls == [spec] - assert not adapter.create_runtime_called - - -def test_replay_demo_builds_output_target_from_spec(tmp_path: Path) -> None: - writer_calls: list[dict[str, Any]] = [] - - def fake_writer( - video: torch.Tensor, - path: Path, - *, - fps: int | float, - layout: str, - install_hint: str, - ) -> Path: - del install_hint - writer_calls.append( - { - "shape": tuple(video.shape), - "path": path, - "fps": fps, - "layout": layout, - } - ) - return path - - spec = DemoSpec( - model_id="fake-demo", - scenario="valid-scenario", - input_mode="replay", - output=Mp4OutputSpec(path=tmp_path / "demo.mp4", fps=12), - ) - - artifacts = run_replay_demo( - spec=spec, - adapter=_FakeDemoAdapter(video_output=True), - output_target_factory=lambda output_spec: build_output_target( - output_spec, - mp4_writer=fake_writer, - ), - ) - - assert len(artifacts) == 1 - assert artifacts[0].kind == "video/mp4" - assert artifacts[0].uri == str(tmp_path / "demo.mp4") - assert writer_calls == [ - { - "shape": (2, 2, 2, 3), - "path": tmp_path / "demo.mp4", - "fps": 12, - "layout": "thwc", - } - ] - - -def test_replay_demo_fails_before_runtime_creation_when_scenario_invalid() -> None: - adapter = _FakeDemoAdapter(scenario_valid=False) - output_factory_calls = 0 - - def output_factory(output_spec: object) -> OutputTarget: - nonlocal output_factory_calls - del output_spec - output_factory_calls += 1 - return NullOutputTarget() - - spec = DemoSpec( - model_id="fake-demo", - scenario="missing-scenario", - input_mode="replay", - output=NullOutputSpec(), - ) - - with pytest.raises(ValueError, match="invalid scenario"): - run_replay_demo( - spec=spec, - adapter=adapter, - output_target_factory=output_factory, - ) - - assert adapter.prepare_scenario_calls == [spec] - assert not adapter.create_runtime_called - assert output_factory_calls == 0 - - -def test_demo_adapter_declares_supported_modes() -> None: - adapter = _FakeDemoAdapter( - input_modes=("replay",), - output_modes=("null", "mp4", "webrtc"), - ) - - assert adapter.supported_input_modes() == ("replay",) - assert adapter.supported_output_modes() == ("null", "mp4", "webrtc") - - with pytest.raises(ValueError, match="input_mode='keyboard-driving'"): - run_replay_demo( - spec=DemoSpec( - model_id="fake-demo", - scenario="valid-scenario", - input_mode="keyboard-driving", - output=NullOutputSpec(), - ), - adapter=adapter, - ) - - assert adapter.prepare_scenario_calls == [] - assert not adapter.create_runtime_called - - -def test_webrtc_demo_uses_existing_session_manager_with_adapter_runtime() -> None: - adapter = _FakeDemoAdapter() - spec = DemoSpec( - model_id="fake-demo", - scenario="valid-scenario", - input_mode="keyboard-driving", - output=WebRTCOutputSpec( - host="0.0.0.0", - port=8082, - fps=24, - video_width=16, - video_height=8, - warmup_chunks=0, - warmup_timeout_s=1.0, - ), - ) - - demo = build_webrtc_demo(spec=spec, adapter=adapter) - - assert isinstance(demo.session_manager, BaseWebRTCSessionManager) - assert demo.runtime is adapter.webrtc_runtime - assert demo.session_manager._runtime is adapter.webrtc_runtime - assert demo.session_manager.runtime_config.video_width == 16 - assert demo.session_manager.runtime_config.video_height == 8 - assert demo.session_manager.fps == 24 - assert demo.session_manager._model_name() == "fake-demo" - assert demo.app is None - assert demo.host == "0.0.0.0" - assert demo.port == 8082 - assert adapter.create_webrtc_runtime_calls == [spec] - assert not adapter.create_runtime_called - - -class _ChunkIndexMapping: - mapping_schema = InputMappingSchema( - name="chunk-index", - produces_global_conditioning=(InputField(name="prompt"),), - produces_step=(InputField(name="chunk_index"),), - ) - - def validate( - self, - *, - canonical_schema: CanonicalInputSchema | None = None, - inference_input_schema: InferenceInputSchema | None = None, - ) -> None: - del canonical_schema, inference_input_schema - - def map_global_conditioning_inputs( - self, - *, - canonical_inputs: CanonicalInputs, - inference_input: InferenceInput, - ) -> InferenceInput: - del canonical_inputs - return inference_input - - def map_step_inputs( - self, - *, - canonical_inputs: CanonicalInputs, - inference_input: InferenceInput, - request: StepRequest, - ) -> InferenceInput: - del canonical_inputs - return InferenceInput( - global_conditioning=inference_input.global_conditioning, - step={"chunk_index": request.step_index}, - metadata=inference_input.metadata, - ) - - -class _FakeDemoAdapter: - model_id = "fake-demo" - inference_input_schema = InferenceInputSchema( - global_conditioning_fields=(InputField(name="prompt"),), - step_fields=(InputField(name="chunk_index"),), - ) - canonical_input_schema = CanonicalInputSchema() - - def __init__( - self, - *, - scenario_valid: bool = True, - video_output: bool = False, - input_modes: tuple[str, ...] = ("replay", "keyboard-driving"), - output_modes: tuple[str, ...] = ("null", "mp4", "webrtc"), - ) -> None: - self._scenario_valid = scenario_valid - self._video_output = video_output - self._input_modes = input_modes - self._output_modes = output_modes - self.mapping = _ChunkIndexMapping() - self.prepared_scenario = PreparedScenario( - initial_inputs=InferenceInput( - global_conditioning={"prompt": "drive forward"}, - ), - user_inputs=UserInputs(), - source_schema=UserInputSchema(), - canonicalizer=InputCanonicalizer(), - mapping=self.mapping, - ) - self.prepare_scenario_calls: list[DemoSpec] = [] - self.create_runtime_called = False - self.runtime: _FakeRuntime | None = None - self.webrtc_runtime: _FakeWebRTCRuntime | None = None - self.create_webrtc_runtime_calls: list[DemoSpec] = [] - - def supported_input_modes(self) -> tuple[str, ...]: - return self._input_modes - - def supported_output_modes(self) -> tuple[str, ...]: - return self._output_modes - - def default_input_mapping(self) -> InputMapping: - return IdentityInputMapping() - - def validate_config(self, config: InferenceConfig) -> None: - if config.model_id != self.model_id: - raise ValueError(f"Unsupported model_id={config.model_id!r}.") - - def create_runtime(self, config: InferenceConfig) -> InferenceRuntime: - self.validate_config(config) - self.create_runtime_called = True - self.runtime = _FakeRuntime( - inference_input_schema=self.inference_input_schema, - video_output=self._video_output, - ) - return self.runtime - - def prepare_scenario(self, spec: DemoSpec) -> PreparedScenario: - self.prepare_scenario_calls.append(spec) - if not self._scenario_valid: - raise ValueError("invalid scenario") - return self.prepared_scenario - - def create_webrtc_runtime(self, spec: DemoSpec) -> "_FakeWebRTCRuntime": - self.create_webrtc_runtime_calls.append(spec) - self.webrtc_runtime = _FakeWebRTCRuntime() - return self.webrtc_runtime - - -class _FakeRuntime: - def __init__( - self, - *, - inference_input_schema: InferenceInputSchema, - video_output: bool, - ) -> None: - self._inference_input_schema = inference_input_schema - self._video_output = video_output - self.session: _FakeSession | None = None - self.closed = False - - def start_session(self, inputs: InferenceInput) -> InferenceSession: - self._inference_input_schema.require_global_conditioning(inputs) - self.session = _FakeSession( - inference_input_schema=self._inference_input_schema, - video_output=self._video_output, - ) - return self.session - - def close(self) -> None: - self.closed = True - - -class _FakeSession: - def __init__( - self, - *, - inference_input_schema: InferenceInputSchema, - video_output: bool, - ) -> None: - self._inference_input_schema = inference_input_schema - self._video_output = video_output - self.step_index = 0 - self.closed = False - - def next_step_request(self) -> StepRequest | None: - if self.step_index >= 2: - return None - return StepRequest( - step_index=self.step_index, - user_input_window=TimeWindow( - start_s=0.5 * self.step_index, - end_s=0.5 * (self.step_index + 1), - ), - ) - - def step(self, inputs: InferenceInput) -> StepResult: - self._inference_input_schema.require_step(inputs) - output: object - if self._video_output: - output = VideoStepResult.from_video_chunk( - chunk_index=self.step_index, - video_chunk=torch.full( - (1, 1, 1, 3, 2, 2), - self.step_index, - dtype=torch.float32, - ), - layout="bvtchw", - ) - else: - output = f"chunk-{self.step_index}" - result = StepResult( - step_index=self.step_index, - output=output, - frame_count=1, - output_window=TimeWindow( - start_s=0.5 * self.step_index, - end_s=0.5 * (self.step_index + 1), - ), - ) - self.step_index += 1 - return result - - def reset(self, inputs: InferenceInput | None = None) -> None: - del inputs - self.step_index = 0 - - def close(self) -> None: - self.closed = True - - -class _RecordingOutputTarget: - def open(self) -> None: - return None - - def write(self, result: StepResult) -> None: - del result - - def close(self) -> Sequence[OutputArtifact]: - return () - - -class _FakeWebRTCRuntime: - async def initialize(self) -> None: - return None - - async def reset_for_new_session(self) -> None: - return None - - def peek_steady_chunk_num_frames(self) -> int: - return 1 - - def peek_next_chunk_num_frames(self) -> int: - return 1 - - async def generate_chunk( - self, - *, - segments: list[Any], - frame_times: list[float], - ) -> Any: - del segments, frame_times - return None - - async def close(self) -> None: - return None - - def send_exit_signal(self) -> None: - return None - - def wait_for_termination(self) -> None: - return None diff --git a/flashdreams/tests/test_runtime_input_mapping.py b/flashdreams/tests/test_runtime_input_mapping.py deleted file mode 100644 index 3a7e3bb97..000000000 --- a/flashdreams/tests/test_runtime_input_mapping.py +++ /dev/null @@ -1,510 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Tests for declarative input-mapping compatibility in the runtime API. - -These cover the T2/T3 contract: sources declare what user events they can -provide at payload granularity, models declare required and optional global -conditioning/per-step inputs, and a mapping declares what it consumes and -produces so compatibility can be answered before expensive runtime -initialization. -""" - -from __future__ import annotations - -from typing import Any - -import pytest - -from flashdreams.runtime import ( - DRIVER_COMMAND, - CanonicalInputs, - CanonicalInputSchema, - CanonicalModality, - IdentityInputMapping, - InferenceInput, - InferenceInputSchema, - InputField, - InputMappingSchema, - StepRequest, - TimeWindow, - UserInputCapability, - UserInputEvent, - UserInputs, - UserInputSchema, - check_mapping_compatibility, - check_mapping_set_compatibility, - combine_mapping_schemas, - undeclared_inference_inputs, -) - -pytestmark = pytest.mark.ci_cpu - -KEY_DOWN = UserInputCapability(event_type="key_down", payload_fields=frozenset({"key"})) -KEY_UP = UserInputCapability(event_type="key_up", payload_fields=frozenset({"key"})) -PROMPT_SET = UserInputCapability( - event_type="prompt_set", - input_modality="text", - payload_fields=frozenset({"prompt"}), -) -FRAME_SET = UserInputCapability( - event_type="initial_frame_set", payload_fields=frozenset({"image"}) -) - -BROWSER_SOURCE = UserInputSchema( - capabilities=(KEY_DOWN, KEY_UP, PROMPT_SET, FRAME_SET), - description="browser webrtc client", -) - -CAMERA_LOOK = CanonicalModality( - name="camera_look", payload_fields=frozenset({"yaw", "pitch"}) -) - -CANONICAL_ALL = CanonicalInputSchema(modalities=(DRIVER_COMMAND, CAMERA_LOOK)) - -# Global conditioning is application-owned and does not come from a canonical -# modality, so this mapping consumes nothing and only declares what it produces. -PROMPT_MAPPING = InputMappingSchema( - name="prompt", - produces_global_conditioning=(InputField(name="prompt", input_modality="text"),), -) -FRAME_MAPPING = InputMappingSchema( - name="conditioning-frame", - produces_global_conditioning=( - InputField(name="global_conditioning_frame", required=False), - ), -) -STEERING_MAPPING = InputMappingSchema( - name="driver-command-to-steering", - consumes=(DRIVER_COMMAND,), - produces_step=(InputField(name="steering"),), -) -LOOK_MAPPING = InputMappingSchema( - name="camera-look", - consumes=(CAMERA_LOOK,), - produces_step=(InputField(name="camera_delta", required=False),), -) - -DRIVING_MODEL = InferenceInputSchema( - global_conditioning_fields=(InputField(name="prompt", input_modality="text"),), - step_fields=( - InputField(name="steering"), - InputField(name="camera_delta", required=False), - ), -) - - -# --- user input events and windowing ------------------------------------ - - -def test_session_start_values_are_represented_as_events() -> None: - inputs = UserInputs( - events=( - UserInputEvent( - timestamp_s=0.0, event_type="prompt_set", payload={"prompt": "drive"} - ), - UserInputEvent( - timestamp_s=0.5, event_type="key_down", payload={"key": "w"} - ), - ) - ) - - assert inputs.events[0].event_type == "prompt_set" - assert inputs.events[0].payload["prompt"] == "drive" - - -def test_windowing_is_half_open_and_deterministic() -> None: - inputs = UserInputs( - events=tuple( - UserInputEvent(timestamp_s=t, event_type="key_down", payload={"key": "w"}) - for t in (0.0, 0.5, 1.0, 1.5) - ) - ) - - windowed = inputs.window(TimeWindow(start_s=0.5, end_s=1.5)) - - assert [event.timestamp_s for event in windowed.events] == [0.5, 1.0] - - -def test_out_of_order_events_are_rejected() -> None: - with pytest.raises(ValueError, match="non-decreasing"): - UserInputs( - events=( - UserInputEvent(timestamp_s=1.0, event_type="key_down"), - UserInputEvent(timestamp_s=0.5, event_type="key_up"), - ) - ) - - -# --- user input schemas ------------------------------------------------- - - -def test_source_declares_capabilities_at_payload_granularity() -> None: - assert BROWSER_SOURCE.supports(KEY_DOWN) - assert not BROWSER_SOURCE.supports( - UserInputCapability( - event_type="key_down", payload_fields=frozenset({"key", "modifiers"}) - ) - ) - - -def test_bare_event_types_still_satisfy_payload_free_consumers() -> None: - """Coarse pre-capability schemas keep working against the finer query.""" - coarse = UserInputSchema(event_types=frozenset({"reset"})) - - assert coarse.supports(UserInputCapability(event_type="reset")) - assert not coarse.supports( - UserInputCapability(event_type="reset", payload_fields=frozenset({"reason"})) - ) - assert coarse.supports_event_types({"reset"}) - - -def test_capabilities_widen_declared_event_types() -> None: - assert "key_down" in BROWSER_SOURCE.declared_event_types() - assert BROWSER_SOURCE.supports_event_types({"key_down", "prompt_set"}) - - -def test_input_modality_mismatch_blocks_capability_match() -> None: - source = UserInputSchema( - capabilities=( - UserInputCapability(event_type="prompt_set", input_modality="embedding"), - ) - ) - - assert not source.supports( - UserInputCapability(event_type="prompt_set", input_modality="text") - ) - - -def test_event_validation_reports_missing_payload_fields() -> None: - event = UserInputEvent(timestamp_s=0.0, event_type="key_down", payload={}) - - with pytest.raises(ValueError, match="missing required"): - BROWSER_SOURCE.validate_event(event) - - -def test_event_validation_rejects_undeclared_event_type() -> None: - event = UserInputEvent(timestamp_s=0.0, event_type="wheel_axis") - - with pytest.raises(ValueError, match="does not provide event type"): - BROWSER_SOURCE.validate_event(event) - - -# --- model input schemas ------------------------------------------------ - - -def test_model_declares_required_and_optional_fields_per_phase() -> None: - required = DRIVING_MODEL.required_fields() - optional = DRIVING_MODEL.optional_fields() - - assert {(phase, f.name) for phase, f in required} == { - ("global_conditioning", "prompt"), - ("step", "steering"), - } - assert {(phase, f.name) for phase, f in optional} == {("step", "camera_delta")} - - -def test_required_fields_can_be_filtered_by_phase() -> None: - step_only = DRIVING_MODEL.required_fields("step") - - assert [f.name for _, f in step_only] == ["steering"] - - -def test_field_lookup_is_phase_scoped() -> None: - assert ( - DRIVING_MODEL.field_for(name="prompt", phase="global_conditioning") is not None - ) - assert DRIVING_MODEL.field_for(name="prompt", phase="step") is None - - -def test_invalid_phase_is_rejected() -> None: - bad_phase: Any = "final" - - with pytest.raises(ValueError, match="phase must be"): - DRIVING_MODEL.fields_for(bad_phase) - - -def test_inference_input_expose_payload_per_phase() -> None: - inputs = InferenceInput( - global_conditioning={"prompt": "drive"}, - per_step_conditioning={"steering": 0.25}, - ) - - assert inputs.for_phase("global_conditioning")["prompt"] == "drive" - assert inputs.for_phase("step")["steering"] == 0.25 - - -def test_step_context_can_carry_global_conditioning_update_payload() -> None: - inputs = InferenceInput( - global_conditioning={"prompt": "heavy rain"}, - step={"steering": 0.25}, - ) - - assert inputs.global_conditioning["prompt"] == "heavy rain" - assert inputs.step["steering"] == 0.25 - - -def test_field_metadata_is_queryable() -> None: - field = InputField( - name="prompt", - frequency_consumed="once", - metadata={"coordinates": "opencv_c2w"}, - ) - - assert field.frequency_consumed == "once" - assert field.metadata["coordinates"] == "opencv_c2w" - - -def test_metadata_is_excluded_from_field_equality() -> None: - plain = InputField(name="prompt") - annotated = InputField(name="prompt", metadata={"note": "hint"}) - - assert plain == annotated - - -# --- mapping compatibility ---------------------------------------------- - - -def test_compatible_source_model_and_mapping_can_drive() -> None: - compatibility = check_mapping_set_compatibility( - canonical_schema=CANONICAL_ALL, - inference_input_schema=DRIVING_MODEL, - mapping_schemas=(PROMPT_MAPPING, STEERING_MAPPING, LOOK_MAPPING), - ) - - assert compatibility.can_drive - assert {(p, f.name) for p, f in compatibility.satisfied_required_model_fields} == { - ("global_conditioning", "prompt"), - ("step", "steering"), - } - assert {(p, f.name) for p, f in compatibility.available_optional_model_fields} == { - ("step", "camera_delta") - } - - -def test_missing_required_model_field_blocks_the_run() -> None: - compatibility = check_mapping_set_compatibility( - canonical_schema=CANONICAL_ALL, - inference_input_schema=DRIVING_MODEL, - mapping_schemas=(PROMPT_MAPPING,), - ) - - assert not compatibility.can_drive - assert [f.name for _, f in compatibility.missing_required_model_fields] == [ - "steering" - ] - - -def test_missing_source_capability_is_reported_when_it_blocks() -> None: - no_wheel = CanonicalInputSchema(modalities=(CAMERA_LOOK,)) - - compatibility = check_mapping_set_compatibility( - canonical_schema=no_wheel, - inference_input_schema=DRIVING_MODEL, - mapping_schemas=(PROMPT_MAPPING, STEERING_MAPPING), - ) - - assert not compatibility.can_drive - assert compatibility.unavailable_mapping_names == ("driver-command-to-steering",) - assert {m.name for m in compatibility.missing_modalities} == {"driver_command"} - - -def test_unfeedable_optional_mapping_degrades_instead_of_vetoing() -> None: - """Losing a mapping that fed only optional fields must not block the run.""" - no_look = CanonicalInputSchema(modalities=(DRIVER_COMMAND,)) - - compatibility = check_mapping_set_compatibility( - canonical_schema=no_look, - inference_input_schema=DRIVING_MODEL, - mapping_schemas=(PROMPT_MAPPING, STEERING_MAPPING, LOOK_MAPPING), - ) - - assert compatibility.can_drive - assert compatibility.unavailable_mapping_names == ("camera-look",) - # The dropped mapping's field must not be advertised as available. - assert compatibility.available_optional_model_fields == () - - -def test_optional_field_needs_mapping_support_to_be_available() -> None: - compatibility = check_mapping_set_compatibility( - canonical_schema=CANONICAL_ALL, - inference_input_schema=DRIVING_MODEL, - mapping_schemas=(PROMPT_MAPPING, STEERING_MAPPING), - ) - - assert compatibility.can_drive - assert compatibility.available_optional_model_fields == () - - -def test_global_conditioning_mapping_matches_global_conditioning_field() -> None: - model = InferenceInputSchema( - global_conditioning_fields=( - InputField(name="camera_trajectory", frequency_consumed="per_step"), - ) - ) - mapping = InputMappingSchema( - name="trajectory", - produces_global_conditioning=( - InputField(name="camera_trajectory", frequency_consumed="once"), - ), - ) - - compatibility = check_mapping_compatibility( - canonical_schema=CANONICAL_ALL, - inference_input_schema=model, - mapping_schema=mapping, - ) - - assert compatibility.can_drive - - -def test_unspecified_input_modality_stays_permissive() -> None: - model = InferenceInputSchema( - global_conditioning_fields=(InputField(name="prompt"),) - ) - - compatibility = check_mapping_compatibility( - canonical_schema=CANONICAL_ALL, - inference_input_schema=model, - mapping_schema=PROMPT_MAPPING, - ) - - assert compatibility.can_drive - - -def test_raise_if_incompatible_names_both_failure_kinds() -> None: - compatibility = check_mapping_set_compatibility( - canonical_schema=CanonicalInputSchema(modalities=(CAMERA_LOOK,)), - inference_input_schema=DRIVING_MODEL, - mapping_schemas=(PROMPT_MAPPING, STEERING_MAPPING), - ) - - with pytest.raises(ValueError) as excinfo: - compatibility.raise_if_incompatible() - - message = str(excinfo.value) - assert "missing canonical modalities" in message - assert "missing required model inputs" in message - - -def test_raise_if_incompatible_is_a_no_op_when_compatible() -> None: - compatibility = check_mapping_set_compatibility( - canonical_schema=CANONICAL_ALL, - inference_input_schema=DRIVING_MODEL, - mapping_schemas=(PROMPT_MAPPING, FRAME_MAPPING, STEERING_MAPPING), - ) - - compatibility.raise_if_incompatible() - - -def test_check_mapping_compatibility_rejects_a_non_schema() -> None: - not_a_schema: Any = object() - - with pytest.raises(TypeError, match="InputMappingSchema"): - check_mapping_compatibility( - canonical_schema=CANONICAL_ALL, - inference_input_schema=DRIVING_MODEL, - mapping_schema=not_a_schema, - ) - - -# --- mapping schema composition ----------------------------------------- - - -def test_combining_mappings_unions_their_surfaces() -> None: - combined = combine_mapping_schemas((PROMPT_MAPPING, STEERING_MAPPING)) - - assert {m.name for m in combined.consumes} == {"driver_command"} - assert [f.name for f in combined.produces_global_conditioning] == ["prompt"] - assert [f.name for f in combined.produces_step] == ["steering"] - - -def test_duplicate_declarations_collapse_and_merge_metadata() -> None: - first = InputMappingSchema( - name="a", - produces_global_conditioning=( - InputField(name="prompt", metadata={"source": "a"}), - ), - ) - second = InputMappingSchema( - name="b", - produces_global_conditioning=( - InputField(name="prompt", metadata={"source": "b", "extra": "kept"}), - ), - ) - - combined = combine_mapping_schemas((first, second)) - - assert len(combined.produces_global_conditioning) == 1 - metadata = combined.produces_global_conditioning[0].metadata - assert metadata["source"] == "a" - assert metadata["extra"] == "kept" - - -def test_combine_rejects_non_schema_entries() -> None: - not_a_schema: Any = object() - - with pytest.raises(TypeError, match="InputMappingSchema"): - combine_mapping_schemas((PROMPT_MAPPING, not_a_schema)) - - -# --- declaration drift -------------------------------------------------- - - -def test_undeclared_inference_input_catches_schema_drift() -> None: - produced = InferenceInput( - global_conditioning={"prompt": "drive"}, per_step_conditioning={"steering": 0.0} - ) - - undeclared = undeclared_inference_inputs(produced, PROMPT_MAPPING) - - assert undeclared == (("step", "steering"),) - - -def test_declared_outputs_report_no_drift() -> None: - combined = combine_mapping_schemas((PROMPT_MAPPING, STEERING_MAPPING)) - produced = InferenceInput( - global_conditioning={"prompt": "drive"}, per_step_conditioning={"steering": 0.0} - ) - - assert undeclared_inference_inputs(produced, combined) == () - - -# --- interoperability with the T1 envelope ------------------------------ - - -def test_identity_mapping_needs_no_declared_surface() -> None: - """Fixed-input runs stay possible without any schema declaration.""" - mapping = IdentityInputMapping() - fixed = InferenceInput( - global_conditioning={"prompt": "fixed"}, per_step_conditioning={"steering": 0.0} - ) - - mapped = mapping.map_step_inputs( - canonical_inputs=CanonicalInputs(), - inference_input=fixed, - request=StepRequest(step_index=0), - ) - - assert mapped.per_step_conditioning["steering"] == 0.0 - - -def test_empty_mapping_set_cannot_satisfy_a_required_field() -> None: - compatibility = check_mapping_set_compatibility( - canonical_schema=CANONICAL_ALL, - inference_input_schema=DRIVING_MODEL, - mapping_schemas=(), - ) - - assert not compatibility.can_drive - assert len(compatibility.missing_required_model_fields) == 2 - - -def test_model_with_no_requirements_is_always_drivable() -> None: - compatibility = check_mapping_set_compatibility( - canonical_schema=CanonicalInputSchema(), - inference_input_schema=InferenceInputSchema(), - mapping_schemas=(), - ) - - assert compatibility.can_drive diff --git a/flashdreams/tests/test_runtime_runner.py b/flashdreams/tests/test_runtime_runner.py deleted file mode 100644 index b755ff48a..000000000 --- a/flashdreams/tests/test_runtime_runner.py +++ /dev/null @@ -1,660 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -from __future__ import annotations - -from collections.abc import Mapping, Sequence -from typing import Any - -import pytest - -from flashdreams.runtime import ( - DRIVER_COMMAND, - CanonicalInputs, - CanonicalInputSchema, - CanonicalModality, - DeviceConverterSchema, - IdentityInputMapping, - InferenceConfig, - InferenceInput, - InferenceInputSchema, - InferenceRuntime, - InferenceSession, - InMemoryMetricsRecorder, - InputCanonicalizer, - InputField, - InputMapping, - InputMappingSchema, - NullOutputTarget, - OutputArtifact, - RuntimeMetricSample, - StepRequest, - StepResult, - TimeWindow, - UserInputCapability, - UserInputEvent, - UserInputs, - UserInputSchema, - run_inference_session, -) - -pytestmark = pytest.mark.ci_cpu - - -def test_run_inference_session_completes_two_step_run() -> None: - adapter = _FakeAdapter() - output = NullOutputTarget(store_results=True) - metrics = InMemoryMetricsRecorder() - - artifacts = run_inference_session( - adapter=adapter, - config=InferenceConfig(model_id="fake-model"), - mapping=_ChunkIndexMapping(), - canonicalizer=InputCanonicalizer(), - source_schema=UserInputSchema(), - user_inputs=UserInputs(), - initial_inputs=InferenceInput(global_conditioning={"prompt": "drive forward"}), - output=output, - metrics=metrics, - ) - - assert artifacts == () - assert output.closed - assert output.output_count == 2 - assert [result.output for result in output.results] == ["chunk-0", "chunk-1"] - assert [result.frame_count for result in output.results] == [3, 3] - assert output.results[0].output_window == TimeWindow(start_s=0.0, end_s=0.5) - assert adapter.runtime is not None - assert adapter.runtime.closed - assert adapter.runtime.session is not None - assert adapter.runtime.session.closed - assert [sample.name for sample in metrics.samples] == ["model_step", "model_step"] - assert [sample.step_index for sample in metrics.samples] == [0, 1] - assert metrics.closed - - -def test_runner_preserves_initial_step_inputs_for_identity_mapping() -> None: - adapter = _FakeAdapter() - - run_inference_session( - adapter=adapter, - config=InferenceConfig(model_id="fake-model"), - mapping=IdentityInputMapping(), - canonicalizer=InputCanonicalizer(), - source_schema=UserInputSchema(), - user_inputs=UserInputs(), - initial_inputs=InferenceInput( - global_conditioning={"prompt": "drive forward"}, - step={"chunk_index": 42}, - ), - output=NullOutputTarget(), - metrics=InMemoryMetricsRecorder(), - ) - - assert adapter.runtime is not None - assert adapter.runtime.session is not None - assert [dict(inputs.step) for inputs in adapter.runtime.session.step_inputs] == [ - {"chunk_index": 42}, - {"chunk_index": 42}, - ] - assert [ - dict(inputs.global_conditioning) - for inputs in adapter.runtime.session.step_inputs - ] == [{}, {}] - - -def test_runner_validates_mapping_before_runtime_creation() -> None: - mapping = _OrderCheckingMapping() - adapter = _OrderCheckingAdapter(mapping=mapping) - - run_inference_session( - adapter=adapter, - config=InferenceConfig(model_id="fake-model"), - mapping=mapping, - canonicalizer=InputCanonicalizer(), - source_schema=UserInputSchema(), - user_inputs=UserInputs(), - initial_inputs=InferenceInput(global_conditioning={"prompt": "drive forward"}), - output=NullOutputTarget(), - metrics=InMemoryMetricsRecorder(), - ) - - assert mapping.validated - assert adapter.created_runtime_after_validate - - -def test_runner_closes_runtime_when_session_start_fails() -> None: - adapter = _FailingStartAdapter() - output = _RecordingOutputTarget() - metrics = InMemoryMetricsRecorder() - - with pytest.raises(RuntimeError, match="start failed"): - run_inference_session( - adapter=adapter, - config=InferenceConfig(model_id="fake-model"), - mapping=_ChunkIndexMapping(), - canonicalizer=InputCanonicalizer(), - source_schema=UserInputSchema(), - user_inputs=UserInputs(), - initial_inputs=InferenceInput( - global_conditioning={"prompt": "drive forward"} - ), - output=output, - metrics=metrics, - ) - - assert adapter.runtime is not None - assert adapter.runtime.closed - assert output.events == () - assert metrics.closed - - -def test_runner_does_not_canonicalize_global_conditioning() -> None: - mapping = _CanonicalRecordingMapping() - - run_inference_session( - adapter=_FakeAdapter(), - config=InferenceConfig(model_id="fake-model"), - mapping=mapping, - canonicalizer=InputCanonicalizer([_CountingDeviceConverter()]), - source_schema=UserInputSchema( - capabilities=( - UserInputCapability( - event_type="stateful_event", - payload_fields=frozenset(), - ), - ) - ), - user_inputs=UserInputs( - events=(UserInputEvent(timestamp_s=0.75, event_type="stateful_event"),) - ), - initial_inputs=InferenceInput(global_conditioning={"prompt": "drive forward"}), - output=NullOutputTarget(), - metrics=InMemoryMetricsRecorder(), - ) - - assert mapping.global_canonical_values == {} - assert mapping.step_canonical_values == ( - {"stateful_counter": {"count": 0}}, - {"stateful_counter": {"count": 1}}, - ) - - -def test_runner_closes_opened_resources_after_output_failure() -> None: - events: list[str] = [] - adapter = _RecordingAdapter(events=events) - output = _FailingWriteOutputTarget(events=events) - metrics = _RecordingMetricsRecorder(events=events) - - with pytest.raises(RuntimeError, match="write failed"): - run_inference_session( - adapter=adapter, - config=InferenceConfig(model_id="fake-model"), - mapping=_ChunkIndexMapping(), - canonicalizer=InputCanonicalizer(), - source_schema=UserInputSchema(), - user_inputs=UserInputs(), - initial_inputs=InferenceInput( - global_conditioning={"prompt": "drive forward"} - ), - output=output, - metrics=metrics, - ) - - assert events == [ - "runtime.start_session", - "output.open", - "session.step:0", - "output.write:0", - "output.close", - "session.close", - "runtime.close", - "metrics.close", - ] - - -def test_runner_attempts_later_cleanup_when_output_close_fails() -> None: - events: list[str] = [] - adapter = _RecordingAdapter(events=events) - output = _FailingCloseOutputTarget(events=events) - metrics = _RecordingMetricsRecorder(events=events) - - with pytest.raises(RuntimeError, match="close failed"): - run_inference_session( - adapter=adapter, - config=InferenceConfig(model_id="fake-model"), - mapping=_ChunkIndexMapping(), - canonicalizer=InputCanonicalizer(), - source_schema=UserInputSchema(), - user_inputs=UserInputs(), - initial_inputs=InferenceInput( - global_conditioning={"prompt": "drive forward"} - ), - output=output, - metrics=metrics, - ) - - assert events == [ - "runtime.start_session", - "output.open", - "session.step:0", - "output.write:0", - "session.step:1", - "output.write:1", - "output.close", - "session.close", - "runtime.close", - "metrics.close", - ] - - -def test_runner_checks_declared_mapping_compatibility_before_runtime_creation() -> None: - adapter = _DrivingAdapter() - mapping = _UnfeedableDriverCommandMapping() - metrics = InMemoryMetricsRecorder() - - with pytest.raises(ValueError, match="cannot drive this model"): - run_inference_session( - adapter=adapter, - config=InferenceConfig(model_id="fake-model"), - mapping=mapping, - canonicalizer=InputCanonicalizer(), - source_schema=UserInputSchema(), - user_inputs=UserInputs(), - initial_inputs=InferenceInput( - global_conditioning={"prompt": "drive forward"} - ), - output=NullOutputTarget(), - metrics=metrics, - ) - - assert not adapter.create_runtime_called - assert not mapping.validated - assert metrics.closed - - -class _ChunkIndexMapping: - mapping_schema = InputMappingSchema( - name="chunk-index", - produces_global_conditioning=(InputField(name="prompt"),), - produces_step=(InputField(name="chunk_index"),), - ) - - def __init__(self) -> None: - self.validated = False - - def validate( - self, - *, - canonical_schema: CanonicalInputSchema | None = None, - inference_input_schema: InferenceInputSchema | None = None, - ) -> None: - del canonical_schema, inference_input_schema - self.validated = True - - def map_global_conditioning_inputs( - self, - *, - canonical_inputs: CanonicalInputs, - inference_input: InferenceInput, - ) -> InferenceInput: - del canonical_inputs - return inference_input - - def map_step_inputs( - self, - *, - canonical_inputs: CanonicalInputs, - inference_input: InferenceInput, - request: StepRequest, - ) -> InferenceInput: - del canonical_inputs - return InferenceInput( - global_conditioning=inference_input.global_conditioning, - step={"chunk_index": request.step_index}, - metadata=inference_input.metadata, - ) - - -class _UnfeedableDriverCommandMapping(_ChunkIndexMapping): - mapping_schema = InputMappingSchema( - name="driver-command", - consumes=(DRIVER_COMMAND,), - produces_global_conditioning=(InputField(name="prompt"),), - produces_step=(InputField(name="steering"),), - ) - - def map_step_inputs( - self, - *, - canonical_inputs: CanonicalInputs, - inference_input: InferenceInput, - request: StepRequest, - ) -> InferenceInput: - del request - return InferenceInput( - global_conditioning=inference_input.global_conditioning, - step={ - "steering": canonical_inputs.values[DRIVER_COMMAND.name]["steer"], - }, - metadata=inference_input.metadata, - ) - - -class _CanonicalRecordingMapping(_ChunkIndexMapping): - def __init__(self) -> None: - super().__init__() - self.global_canonical_values: Mapping[str, Any] | None = None - self._step_canonical_values: list[Mapping[str, Any]] = [] - - @property - def step_canonical_values(self) -> tuple[Mapping[str, Any], ...]: - return tuple(self._step_canonical_values) - - def map_global_conditioning_inputs( - self, - *, - canonical_inputs: CanonicalInputs, - inference_input: InferenceInput, - ) -> InferenceInput: - self.global_canonical_values = canonical_inputs.values - return super().map_global_conditioning_inputs( - canonical_inputs=canonical_inputs, - inference_input=inference_input, - ) - - def map_step_inputs( - self, - *, - canonical_inputs: CanonicalInputs, - inference_input: InferenceInput, - request: StepRequest, - ) -> InferenceInput: - self._step_canonical_values.append(canonical_inputs.values) - return super().map_step_inputs( - canonical_inputs=canonical_inputs, - inference_input=inference_input, - request=request, - ) - - -_STATEFUL_COUNTER = CanonicalModality( - name="stateful_counter", - payload_fields=frozenset({"count"}), -) - - -class _CountingDeviceConverter: - schema = DeviceConverterSchema( - name="stateful-counter", - produces=_STATEFUL_COUNTER, - consumes=(UserInputCapability(event_type="stateful_event"),), - ) - - def __init__(self) -> None: - self.count = 0 - - def reset(self) -> None: - self.count = 0 - - def convert( - self, - user_inputs: UserInputs, - window: TimeWindow, - ) -> Mapping[str, Any] | None: - del window - self.count += len(user_inputs.events) - return _STATEFUL_COUNTER.value({"count": self.count}) - - -class _FakeAdapter: - model_id = "fake-model" - inference_input_schema = InferenceInputSchema( - global_conditioning_fields=(InputField(name="prompt"),), - step_fields=(InputField(name="chunk_index"),), - ) - canonical_input_schema = CanonicalInputSchema() - - def __init__(self) -> None: - self.runtime: _FakeRuntime | None = None - self.create_runtime_called = False - - def default_input_mapping(self) -> InputMapping: - return _ChunkIndexMapping() - - def validate_config(self, config: InferenceConfig) -> None: - if config.model_id != self.model_id: - raise ValueError(f"Unsupported model_id={config.model_id!r}.") - - def create_runtime(self, config: InferenceConfig) -> InferenceRuntime: - self.validate_config(config) - self.create_runtime_called = True - self.runtime = _FakeRuntime(inference_input_schema=self.inference_input_schema) - return self.runtime - - -class _DrivingAdapter(_FakeAdapter): - inference_input_schema = InferenceInputSchema( - global_conditioning_fields=(InputField(name="prompt"),), - step_fields=(InputField(name="steering"),), - ) - - -class _OrderCheckingMapping(_ChunkIndexMapping): - pass - - -class _OrderCheckingAdapter(_FakeAdapter): - def __init__(self, *, mapping: _OrderCheckingMapping) -> None: - super().__init__() - self._mapping = mapping - self.created_runtime_after_validate = False - - def create_runtime(self, config: InferenceConfig) -> InferenceRuntime: - self.validate_config(config) - self.created_runtime_after_validate = self._mapping.validated - self.create_runtime_called = True - self.runtime = _FakeRuntime(inference_input_schema=self.inference_input_schema) - return self.runtime - - -class _FailingStartAdapter(_FakeAdapter): - def create_runtime(self, config: InferenceConfig) -> InferenceRuntime: - self.validate_config(config) - self.create_runtime_called = True - self.runtime = _FailingRuntime( - inference_input_schema=self.inference_input_schema - ) - return self.runtime - - -class _RecordingAdapter(_FakeAdapter): - def __init__(self, *, events: list[str]) -> None: - super().__init__() - self._events = events - - def create_runtime(self, config: InferenceConfig) -> InferenceRuntime: - self.validate_config(config) - self.create_runtime_called = True - self.runtime = _RecordingRuntime( - inference_input_schema=self.inference_input_schema, - events=self._events, - ) - return self.runtime - - -class _FakeRuntime: - def __init__(self, *, inference_input_schema: InferenceInputSchema) -> None: - self._inference_input_schema = inference_input_schema - self.session: _FakeSession | None = None - self.closed = False - - def start_session(self, inputs: InferenceInput) -> InferenceSession: - self._inference_input_schema.require_global_conditioning(inputs) - self.session = _FakeSession(inference_input_schema=self._inference_input_schema) - return self.session - - def close(self) -> None: - self.closed = True - - -class _FailingRuntime(_FakeRuntime): - def start_session(self, inputs: InferenceInput) -> InferenceSession: - del inputs - raise RuntimeError("start failed") - - -class _RecordingRuntime(_FakeRuntime): - def __init__( - self, - *, - inference_input_schema: InferenceInputSchema, - events: list[str], - ) -> None: - super().__init__(inference_input_schema=inference_input_schema) - self._events = events - - def start_session(self, inputs: InferenceInput) -> InferenceSession: - self._events.append("runtime.start_session") - self._inference_input_schema.require_global_conditioning(inputs) - self.session = _RecordingSession( - inference_input_schema=self._inference_input_schema, - events=self._events, - ) - return self.session - - def close(self) -> None: - self._events.append("runtime.close") - super().close() - - -class _FakeSession: - def __init__(self, *, inference_input_schema: InferenceInputSchema) -> None: - self._inference_input_schema = inference_input_schema - self.step_index = 0 - self.step_inputs: list[InferenceInput] = [] - self.closed = False - - def next_step_request(self) -> StepRequest | None: - if self.step_index >= 2: - return None - return StepRequest( - step_index=self.step_index, - inference_input_schema=self._inference_input_schema, - user_input_window=TimeWindow( - start_s=0.5 * self.step_index, - end_s=0.5 * (self.step_index + 1), - ), - ) - - def step(self, inputs: InferenceInput) -> StepResult: - self._inference_input_schema.require_step(inputs) - self.step_inputs.append(inputs) - result = StepResult( - step_index=self.step_index, - output=f"chunk-{self.step_index}", - frame_count=3, - output_window=TimeWindow( - start_s=0.5 * self.step_index, - end_s=0.5 * (self.step_index + 1), - ), - metrics={"model_step_s": 0.01, "frames": 3}, - ) - self.step_index += 1 - return result - - def reset(self, inputs: InferenceInput | None = None) -> None: - del inputs - self.step_index = 0 - - def close(self) -> None: - self.closed = True - - -class _RecordingSession(_FakeSession): - def __init__( - self, - *, - inference_input_schema: InferenceInputSchema, - events: list[str], - ) -> None: - super().__init__(inference_input_schema=inference_input_schema) - self._events = events - - def step(self, inputs: InferenceInput) -> StepResult: - self._events.append(f"session.step:{self.step_index}") - return super().step(inputs) - - def close(self) -> None: - self._events.append("session.close") - super().close() - - -class _RecordingOutputTarget: - def __init__(self, *, events: list[str] | None = None) -> None: - self._events = events - self._opened = False - - @property - def events(self) -> tuple[str, ...]: - return () if self._events is None else tuple(self._events) - - def open(self) -> None: - self._opened = True - if self._events is not None: - self._events.append("output.open") - - def write(self, result: StepResult) -> None: - if not self._opened: - raise RuntimeError("Cannot write to a closed output target.") - if self._events is not None: - self._events.append(f"output.write:{result.step_index}") - - def close(self) -> Sequence[OutputArtifact]: - self._opened = False - if self._events is not None: - self._events.append("output.close") - return () - - -class _FailingWriteOutputTarget(_RecordingOutputTarget): - def write(self, result: StepResult) -> None: - super().write(result) - raise RuntimeError("write failed") - - -class _FailingCloseOutputTarget(_RecordingOutputTarget): - def close(self) -> Sequence[OutputArtifact]: - super().close() - raise RuntimeError("close failed") - - -class _RecordingMetricsRecorder: - def __init__(self, *, events: list[str]) -> None: - self._events = events - self.samples: list[RuntimeMetricSample] = [] - - def record(self, sample: RuntimeMetricSample) -> None: - self.samples.append(sample) - - def record_timing( - self, - name: str, - duration_s: float, - *, - step_index: int | None = None, - metadata: Mapping[str, Any] | None = None, - ) -> None: - self.record( - RuntimeMetricSample( - name=name, - value=duration_s, - unit="s", - step_index=step_index, - category="timing", - metadata={} if metadata is None else metadata, - ) - ) - - def close(self) -> None: - self._events.append("metrics.close") diff --git a/flashdreams/tests/test_runtime_video_output.py b/flashdreams/tests/test_runtime_video_output.py deleted file mode 100644 index 898acf734..000000000 --- a/flashdreams/tests/test_runtime_video_output.py +++ /dev/null @@ -1,91 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -from __future__ import annotations - -from pathlib import Path -from typing import Any - -import pytest -import torch - -from flashdreams.infra.video_output import VideoStepResult -from flashdreams.runtime import Mp4VideoOutputTarget, StepResult, TimeWindow - -pytestmark = pytest.mark.ci_cpu - - -def test_mp4_video_output_target_rejects_non_video_payload(tmp_path: Path) -> None: - target = Mp4VideoOutputTarget(output_path=tmp_path / "out.mp4", fps=30) - target.open() - - with pytest.raises(TypeError, match="VideoStepResult"): - target.write(StepResult(step_index=0, output="not-video")) - - -def test_mp4_video_output_target_writes_artifact_on_close(tmp_path: Path) -> None: - calls: list[dict[str, Any]] = [] - - def fake_writer( - video: torch.Tensor, - path: Path, - *, - fps: int | float, - layout: str, - install_hint: str, - ) -> Path: - del install_hint - calls.append( - { - "shape": tuple(video.shape), - "path": path, - "fps": fps, - "layout": layout, - } - ) - return path - - target = Mp4VideoOutputTarget( - output_path=tmp_path / "omnidreams.mp4", - fps=24, - writer=fake_writer, - move_to_cpu=False, - ) - target.open() - target.write( - StepResult( - step_index=3, - output=VideoStepResult.from_video_chunk( - chunk_index=3, - video_chunk=torch.zeros((1, 2, 4, 3, 5, 6)), - layout="bvtchw", - stats={"model_step_s": 0.5}, - ), - frame_count=4, - output_window=TimeWindow(start_s=1.0, end_s=2.0), - ) - ) - - artifacts = target.close() - - assert len(artifacts) == 1 - assert artifacts[0].kind == "video/mp4" - assert artifacts[0].uri == str(tmp_path / "omnidreams.mp4") - assert calls == [ - { - "shape": (4, 5, 12, 3), - "path": tmp_path / "omnidreams.mp4", - "fps": 24, - "layout": "thwc", - } - ] - assert artifacts[0].metadata["stats_history"] == ( - { - "autoregressive_index": 3, - "model_step_s": 0.5, - "step_index": 3, - "frames": 4, - "output_start_s": 1.0, - "output_end_s": 2.0, - }, - ) diff --git a/integrations/omnidreams/omnidreams/demo/README.md b/integrations/omnidreams/omnidreams/demo/README.md deleted file mode 100644 index d69c0170a..000000000 --- a/integrations/omnidreams/omnidreams/demo/README.md +++ /dev/null @@ -1,66 +0,0 @@ - - -# OmniDreams Shared Demo API - -This folder contains the experimental OmniDreams demo built on -`flashdreams.runtime.demo`. - -Run commands from the FlashDreams workspace root: - -```bash -cd /path/to/flashdreams -export HF_TOKEN= -``` - -## MP4 Replay - -Generate an MP4 from the bundled single-view sample data: - -```bash -mkdir -p outputs -uv run --package flashdreams-omnidreams omnidreams-demo replay \ - --output outputs/omnidreams-demo.mp4 -``` - -This replay path mirrors the benchmark runner path: it uses a prompt, first -frame, and pre-rendered HDMap video. It does not load a Ludus scene or render -HDMaps at runtime. The demo defaults to the stable non-perf OmniDreams preset -used by the benchmark path. - -To provide benchmark-style assets explicitly: - -```bash -uv run --package flashdreams-omnidreams omnidreams-demo replay \ - --prompt "Driving scene from a front-facing car camera." \ - --hdmap-video-paths /path/to/camera_front_wide_120fov_hdmap.mp4 \ - --first-frame-paths /path/to/first_frame.png \ - --camera-names camera_front_wide_120fov \ - --output outputs/omnidreams-demo.mp4 -``` - -Pass `--example-data-uuid ` to select another bundled single-view sample, -or `--no-example-data` to require explicit asset paths. - -The `omnidreams-sv-2steps-chunk2-loc6-lightvae-lighttae-perf` preset remains an -explicit `--preset-id` opt-in. It should become the default only after the -compile/cache behavior is reliable enough for the demo path. - -## WebRTC - -WebRTC uses the shared demo launcher around the existing Omnidreams live WebRTC -runtime. It is still scene-driven and uses Ludus to render HDMap conditioning -from a scene: - -```bash -uv run --package flashdreams-omnidreams omnidreams-demo webrtc \ - --host 0.0.0.0 \ - --port 8082 -``` - -The scene UUID is optional; when omitted, the runtime uses the default -Hugging Face WebRTC scene. Override the scene with `--scene-uuid`, select a -weather variant with `--scene-variant default|rain|snow`, or use -`--scene-dir /path/to/local/scene` for a local staged scene. diff --git a/integrations/omnidreams/omnidreams/demo/__init__.py b/integrations/omnidreams/omnidreams/demo/__init__.py deleted file mode 100644 index 6fa3a9b21..000000000 --- a/integrations/omnidreams/omnidreams/demo/__init__.py +++ /dev/null @@ -1,20 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Experimental OmniDreams demo adapter built on ``flashdreams.runtime.demo``.""" - -from omnidreams.demo.adapter import OmnidreamsDemoAdapter -from omnidreams.demo.spec import ( - DEFAULT_OMNIDREAMS_PRESET, - OMNIDREAMS_MODEL_ID, - OmnidreamsReplayScenario, - OmnidreamsWebRTCScenario, -) - -__all__ = [ - "DEFAULT_OMNIDREAMS_PRESET", - "OMNIDREAMS_MODEL_ID", - "OmnidreamsDemoAdapter", - "OmnidreamsReplayScenario", - "OmnidreamsWebRTCScenario", -] diff --git a/integrations/omnidreams/omnidreams/demo/adapter.py b/integrations/omnidreams/omnidreams/demo/adapter.py deleted file mode 100644 index e16c5c0a1..000000000 --- a/integrations/omnidreams/omnidreams/demo/adapter.py +++ /dev/null @@ -1,279 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""OmniDreams adapter for the shared demo API.""" - -from __future__ import annotations - -from collections.abc import Callable -from dataclasses import replace -from typing import Any - -from omnidreams.config import OMNIDREAMS_CONFIGS, OMNIDREAMS_RUNNERS -from omnidreams.webrtc.session import ( - OmnidreamsInferenceRuntime, - OmnidreamsRuntimeConfig, -) - -from flashdreams.infra.postprocess import VideoPostprocessChainConfig -from flashdreams.runtime import ( - CanonicalInputSchema, - IdentityInputMapping, - InferenceConfig, - InferenceInput, - InferenceInputSchema, - InputCanonicalizer, - InputField, - UserInputSchema, -) -from flashdreams.runtime.demo import ( - DemoSpec, - Mp4OutputSpec, - PreparedScenario, - WebRTCOutputSpec, -) -from flashdreams.runtime.interfaces import InferenceRuntime - -from .replay import ( - OmnidreamsReplayRuntime, - OmnidreamsReplayRuntimeOptions, - PipelineFactory, -) -from .spec import ( - DEFAULT_OMNIDREAMS_PRESET, - OMNIDREAMS_MODEL_ID, - resolve_replay_scenario, - resolve_webrtc_scenario, -) -from .webrtc import ( - OmnidreamsDemoWebRTCSessionManager, - create_omnidreams_webrtc_app, - validate_postprocess_preset, -) - -ReplayRuntimeFactory = Callable[..., InferenceRuntime] -WebRTCRuntimeFactory = Callable[..., Any] - - -class OmnidreamsDemoAdapter: - """Model-owned OmniDreams adapter consumed by shared demo launchers.""" - - def __init__( - self, - *, - replay_runtime_factory: ReplayRuntimeFactory = OmnidreamsReplayRuntime, - webrtc_runtime_factory: WebRTCRuntimeFactory = OmnidreamsInferenceRuntime, - pipeline_factory: PipelineFactory | None = None, - ) -> None: - self._replay_runtime_factory = replay_runtime_factory - self._webrtc_runtime_factory = webrtc_runtime_factory - self._pipeline_factory = pipeline_factory - self._mapping = IdentityInputMapping() - - @property - def model_id(self) -> str: - return OMNIDREAMS_MODEL_ID - - @property - def inference_input_schema(self) -> InferenceInputSchema: - return InferenceInputSchema( - global_conditioning_fields=( - InputField( - name="scenario", - input_modality="omnidreams/replay-scenario", - description="Resolved OmniDreams replay scenario.", - ), - ) - ) - - @property - def canonical_input_schema(self) -> CanonicalInputSchema | None: - return None - - def default_input_mapping(self) -> IdentityInputMapping: - return self._mapping - - def supported_input_modes(self) -> tuple[str, ...]: - return ("replay", "keyboard-driving") - - def supported_output_modes(self) -> tuple[str, ...]: - return ("mp4", "webrtc") - - def prepare_scenario(self, spec: DemoSpec) -> PreparedScenario: - if spec.input_mode != "replay": - raise ValueError( - "OmniDreams prepare_scenario currently supports only " - f"input_mode='replay', got {spec.input_mode!r}." - ) - if not isinstance(spec.output, Mp4OutputSpec): - raise ValueError("OmniDreams replay demo currently requires MP4 output.") - scenario = resolve_replay_scenario( - spec.scenario, - default_prompt=self._default_replay_prompt(spec.config), - ) - return PreparedScenario( - initial_inputs=InferenceInput( - global_conditioning={"scenario": scenario}, - ), - source_schema=UserInputSchema(description="fixed OmniDreams replay input"), - canonicalizer=InputCanonicalizer(), - mapping=self._mapping, - metadata={ - "model_id": self.model_id, - "preset_id": self._preset_id(spec.config), - "num_views": len(scenario.camera_names), - }, - ) - - def validate_config(self, config: InferenceConfig) -> None: - if config.model_id != self.model_id: - raise ValueError( - f"OmniDreams adapter requires model_id={self.model_id!r}, " - f"got {config.model_id!r}." - ) - self._pipeline_config(config) - - def create_runtime(self, config: InferenceConfig) -> InferenceRuntime: - self.validate_config(config) - return self._replay_runtime_factory( - config=config, - options=OmnidreamsReplayRuntimeOptions( - pipeline_config=self._pipeline_config(config), - pipeline_factory=self._pipeline_factory, - ), - ) - - def create_webrtc_runtime(self, spec: DemoSpec) -> Any: - runtime_config = self.create_webrtc_runtime_config(spec=spec, runtime=None) - return self._webrtc_runtime_factory(config=runtime_config) - - def create_webrtc_runtime_config( - self, - *, - spec: DemoSpec, - runtime: Any, - ) -> OmnidreamsRuntimeConfig: - runtime_config = getattr(runtime, "config", None) - if isinstance(runtime_config, OmnidreamsRuntimeConfig): - return runtime_config - if spec.input_mode != "keyboard-driving": - raise ValueError( - "OmniDreams WebRTC requires input_mode='keyboard-driving', " - f"got {spec.input_mode!r}." - ) - if not isinstance(spec.output, WebRTCOutputSpec): - raise ValueError("OmniDreams WebRTC requires WebRTC output.") - config = spec.config - if config is None: - raise RuntimeError("DemoSpec.config was not initialized.") - self.validate_config(config) - scenario = resolve_webrtc_scenario(spec.scenario) - validate_postprocess_preset(scenario.postprocess_preset) - - preset_id = self._preset_id(config) - pipeline_config = self._pipeline_config(config) - seed = _option(config, "seed", 42) - device = config.device or str(_option(config, "device", "cuda:0")) - runtime_config = OmnidreamsRuntimeConfig( - pipeline_config_name=preset_id, - pipeline_config=pipeline_config, - scene_dir=scenario.scene_dir, - scene_uuid=scenario.scene_uuid, - scene_variant=scenario.scene_variant, - seed=None if seed is None else int(seed), - device=device, - video_height=spec.output.video_height, - video_width=spec.output.video_width, - fps=spec.output.fps, - camera_name=scenario.camera_name, - warmup_chunks=spec.output.warmup_chunks, - warmup_timeout_s=spec.output.warmup_timeout_s, - debug_serve_hdmaps=scenario.debug_serve_hdmaps, - postprocess=VideoPostprocessChainConfig(preset=scenario.postprocess_preset), - encoder_backend="default" if scenario.prefer_sw_encoder else "auto", - ) - return _apply_webrtc_runtime_options(runtime_config, config.runtime_options) - - def create_webrtc_session_manager( - self, - *, - spec: DemoSpec, - runtime: Any, - runtime_config: OmnidreamsRuntimeConfig, - fps: int, - client_liveness_timeout_s: float, - ) -> OmnidreamsDemoWebRTCSessionManager: - del spec - return OmnidreamsDemoWebRTCSessionManager( - runtime=runtime, - runtime_config=runtime_config, - fps=fps, - client_liveness_timeout_s=client_liveness_timeout_s, - ) - - def create_webrtc_app( - self, - *, - spec: DemoSpec, - session_manager: Any, - request_session_url: str, - ) -> Any: - return create_omnidreams_webrtc_app( - spec=spec, - session_manager=session_manager, - request_session_url=request_session_url, - ) - - def _preset_id(self, config: InferenceConfig | None) -> str: - return ( - DEFAULT_OMNIDREAMS_PRESET - if config is None or config.preset_id is None - else config.preset_id - ) - - def _pipeline_config(self, config: InferenceConfig) -> Any: - custom = config.runtime_options.get("pipeline_config") - if custom is not None: - return custom - preset_id = self._preset_id(config) - try: - return OMNIDREAMS_CONFIGS[preset_id] - except KeyError as exc: - supported = ", ".join(sorted(OMNIDREAMS_CONFIGS)) - raise ValueError( - f"Unsupported OmniDreams preset_id={preset_id!r}. " - f"Supported presets: {supported}." - ) from exc - - def _default_replay_prompt(self, config: InferenceConfig | None) -> str: - runner = OMNIDREAMS_RUNNERS.get(self._preset_id(config)) - return "" if runner is None else str(getattr(runner, "prompt", "")) - - -def _option(config: InferenceConfig, name: str, default: Any) -> Any: - return config.runtime_options.get(name, default) - - -def _apply_webrtc_runtime_options( - runtime_config: OmnidreamsRuntimeConfig, - options: Any, -) -> OmnidreamsRuntimeConfig: - if not isinstance(options, dict): - options = dict(options) - overrides: dict[str, Any] = {} - for name in ( - "move_speed_per_s", - "rotate_speed_rad_per_s", - "encoder_bitrate_bps", - "encoder_gop", - ): - if name in options: - overrides[name] = options[name] - return replace(runtime_config, **overrides) if overrides else runtime_config - - -__all__ = [ - "OmnidreamsDemoAdapter", - "ReplayRuntimeFactory", - "WebRTCRuntimeFactory", -] diff --git a/integrations/omnidreams/omnidreams/demo/cli.py b/integrations/omnidreams/omnidreams/demo/cli.py deleted file mode 100644 index d35a62b78..000000000 --- a/integrations/omnidreams/omnidreams/demo/cli.py +++ /dev/null @@ -1,187 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""CLI for the experimental shared OmniDreams demo path.""" - -from __future__ import annotations - -import argparse -from pathlib import Path - -import torch -import torch.distributed as dist -from omnidreams.runner import DEFAULT_EXAMPLE_DATA_UUID_1V - -from flashdreams.core.distributed import init as distributed_init -from flashdreams.runtime import InferenceConfig -from flashdreams.runtime.demo import ( - DemoSpec, - Mp4OutputSpec, - WebRTCOutputSpec, - run_flashdreams_demo, - serve_flashdreams_demo, -) -from flashdreams.serving.webrtc.bootstrap import ( - configure_logging, - initialize_cuda_distributed, -) - -from .adapter import OmnidreamsDemoAdapter -from .spec import ( - DEFAULT_OMNIDREAMS_PRESET, - OMNIDREAMS_MODEL_ID, - OmnidreamsWebRTCScenario, -) - - -def parse_args(argv: list[str] | None = None) -> argparse.Namespace: - parser = argparse.ArgumentParser( - description="Experimental OmniDreams demo using flashdreams.runtime.demo." - ) - subparsers = parser.add_subparsers(dest="command", required=True) - - replay = subparsers.add_parser("replay", help="Run an MP4 replay demo.") - replay.add_argument("--preset-id", default=DEFAULT_OMNIDREAMS_PRESET) - replay.add_argument("--device", default="cuda") - replay.add_argument("--prompt", default=None) - replay.add_argument("--hdmap-video-paths", type=_split_paths, default=()) - replay.add_argument("--first-frame-paths", type=_split_paths, default=()) - replay.add_argument("--camera-names", type=_split_strings, default=()) - replay.add_argument( - "--example-data", - action=argparse.BooleanOptionalAction, - default=None, - help=( - "Use the bundled single-view HF sample when asset paths are omitted " - "(default: auto)." - ), - ) - replay.add_argument("--example-data-uuid", default=DEFAULT_EXAMPLE_DATA_UUID_1V) - replay.add_argument("--total-blocks", type=int, default=60) - replay.add_argument("--pixel-height", type=int, default=704) - replay.add_argument("--pixel-width", type=int, default=1280) - replay.add_argument("--fps", type=int, default=30) - replay.add_argument("--output", type=Path, required=True) - - webrtc = subparsers.add_parser("webrtc", help="Serve a WebRTC driving demo.") - webrtc.add_argument("--preset-id", default=DEFAULT_OMNIDREAMS_PRESET) - webrtc.add_argument("--host", default="0.0.0.0") - webrtc.add_argument("--port", type=int, default=8082) - webrtc.add_argument("--device", default="cuda:0") - webrtc.add_argument("--seed", type=int, default=42) - webrtc.add_argument("--scene-dir", type=Path, default=None) - webrtc.add_argument("--scene-uuid", default=None) - webrtc.add_argument("--scene-variant", default="default") - webrtc.add_argument("--camera-name", default="camera_front_wide_120fov") - webrtc.add_argument("--fps", type=int, default=30) - webrtc.add_argument("--video-height", type=int, default=704) - webrtc.add_argument("--video-width", type=int, default=1280) - webrtc.add_argument("--warmup-chunks", type=int, default=10) - webrtc.add_argument("--warmup-timeout-s", type=float, default=600.0) - webrtc.add_argument("--client-liveness-timeout-s", type=float, default=10.0) - webrtc.add_argument("--debug-serve-hdmaps", action="store_true") - webrtc.add_argument("--postprocess-preset", default="") - webrtc.add_argument("--prefer-sw-encoder", action="store_true") - return parser.parse_args(argv) - - -def main(argv: list[str] | None = None) -> None: - configure_logging() - args = parse_args(argv) - adapter = OmnidreamsDemoAdapter() - if args.command == "replay": - run_flashdreams_demo(spec=_replay_spec(args), adapter=adapter) - return - if args.command == "webrtc": - context = initialize_cuda_distributed( - default_device=args.device, - distributed_init_fn=distributed_init, - configure_logging_fn=configure_logging, - torch_module=torch, - dist_module=dist, - ) - serve_flashdreams_demo( - spec=_webrtc_spec(args, device=str(context.device)), - adapter=adapter, - world_rank=context.world_rank, - ) - return - raise AssertionError(f"Unhandled command: {args.command}") - - -def _replay_spec(args: argparse.Namespace) -> DemoSpec: - scenario: dict[str, object] = { - "example_data": args.example_data, - "example_data_uuid": args.example_data_uuid, - "total_blocks": args.total_blocks, - "pixel_height": args.pixel_height, - "pixel_width": args.pixel_width, - "fps": args.fps, - } - if args.prompt: - scenario["prompt"] = args.prompt - if args.hdmap_video_paths: - scenario["hdmap_video_paths"] = args.hdmap_video_paths - if args.first_frame_paths: - scenario["first_frame_paths"] = args.first_frame_paths - if args.camera_names: - scenario["camera_names"] = args.camera_names - - return DemoSpec( - model_id=OMNIDREAMS_MODEL_ID, - preset_id=args.preset_id, - input_mode="replay", - scenario=scenario, - output=Mp4OutputSpec(path=args.output, fps=args.fps), - config=InferenceConfig( - model_id=OMNIDREAMS_MODEL_ID, - preset_id=args.preset_id, - device=args.device, - ), - ) - - -def _webrtc_spec(args: argparse.Namespace, *, device: str) -> DemoSpec: - return DemoSpec( - model_id=OMNIDREAMS_MODEL_ID, - preset_id=args.preset_id, - input_mode="keyboard-driving", - scenario=OmnidreamsWebRTCScenario( - scene_dir=args.scene_dir, - scene_uuid=args.scene_uuid, - scene_variant=args.scene_variant, - camera_name=args.camera_name, - debug_serve_hdmaps=args.debug_serve_hdmaps, - postprocess_preset=args.postprocess_preset, - prefer_sw_encoder=args.prefer_sw_encoder, - ), - output=WebRTCOutputSpec( - host=args.host, - port=args.port, - fps=args.fps, - video_width=args.video_width, - video_height=args.video_height, - warmup_chunks=args.warmup_chunks, - warmup_timeout_s=args.warmup_timeout_s, - client_liveness_timeout_s=args.client_liveness_timeout_s, - preload_name="Omnidreams", - ), - config=InferenceConfig( - model_id=OMNIDREAMS_MODEL_ID, - preset_id=args.preset_id, - device=device, - runtime_options={"seed": args.seed}, - ), - ) - - -def _split_paths(value: str) -> tuple[Path, ...]: - return tuple(Path(part) for part in value.split(",") if part) - - -def _split_strings(value: str) -> tuple[str, ...]: - return tuple(part for part in value.split(",") if part) - - -if __name__ == "__main__": - main() diff --git a/integrations/omnidreams/omnidreams/demo/replay.py b/integrations/omnidreams/omnidreams/demo/replay.py deleted file mode 100644 index 908d84568..000000000 --- a/integrations/omnidreams/omnidreams/demo/replay.py +++ /dev/null @@ -1,277 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""OmniDreams replay runtime for the shared demo runner.""" - -from __future__ import annotations - -import os -import time -from collections.abc import Callable, Mapping -from dataclasses import dataclass -from typing import Any - -import torch -import torch.distributed as dist -from loguru import logger -from omnidreams.runner import _load_video - -from flashdreams.core.distributed import init as init_distributed -from flashdreams.infra.postprocess import VideoTensorLayout -from flashdreams.infra.runner_io import ( - DEFAULT_RUNNER_INSTALL_HINT, - load_first_frame_tensor, -) -from flashdreams.infra.video_output import VideoStepResult -from flashdreams.runtime.config import InferenceConfig -from flashdreams.runtime.inputs import InferenceInput -from flashdreams.runtime.interfaces import InferenceSession -from flashdreams.runtime.types import StepRequest, StepResult - -from .spec import OmnidreamsReplayScenario - -PipelineFactory = Callable[[Any, str], Any] - - -@dataclass(frozen=True, kw_only=True, slots=True) -class OmnidreamsReplayRuntimeOptions: - """Construction knobs for the replay runtime.""" - - pipeline_config: Any - pipeline_factory: PipelineFactory | None = None - output_layout: VideoTensorLayout = "bvtchw" - - -class OmnidreamsReplayRuntime: - """Heavyweight OmniDreams runtime consumed by ``run_inference_session``.""" - - def __init__( - self, - *, - config: InferenceConfig, - options: OmnidreamsReplayRuntimeOptions, - ) -> None: - self.config = config - self.options = options - if _is_torchrun_env() and not dist.is_initialized(): - init_distributed() - - if dist.is_initialized(): - self.local_rank = int(os.environ.get("LOCAL_RANK", "0")) - self.world_size = dist.get_world_size() - self.global_rank = dist.get_rank() - device = f"cuda:{self.local_rank}" - else: - self.local_rank = 0 - self.world_size = 1 - self.global_rank = 0 - device = config.device or "cuda" - - self.is_rank_zero = self.global_rank == 0 - factory = options.pipeline_factory or _default_pipeline_factory - self.pipeline = factory(options.pipeline_config, device) - - def start_session(self, inputs: InferenceInput) -> InferenceSession: - scenario = _scenario_from_inputs(inputs) - return OmnidreamsReplaySession( - pipeline=self.pipeline, - scenario=scenario, - device=torch.device(f"cuda:{self.local_rank}") - if dist.is_initialized() - else torch.device(self.config.device or "cuda"), - is_rank_zero=self.is_rank_zero, - output_layout=self.options.output_layout, - ) - - def close(self) -> None: - pipeline = getattr(self, "pipeline", None) - if pipeline is not None: - close = getattr(pipeline, "close", None) - if callable(close): - close() - del self.pipeline - device = torch.device(self.config.device or "cuda") - if device.type == "cuda" and torch.cuda.is_available(): - torch.cuda.empty_cache() - - -class OmnidreamsReplaySession: - """One MP4 replay rollout over a prepared scenario.""" - - def __init__( - self, - *, - pipeline: Any, - scenario: OmnidreamsReplayScenario, - device: torch.device, - is_rank_zero: bool, - output_layout: VideoTensorLayout, - ) -> None: - self.pipeline = pipeline - self.scenario = scenario - self.device = device - self.is_rank_zero = is_rank_zero - self.output_layout = output_layout - self.dtype = torch.bfloat16 - self._closed = False - self._step_index = 0 - self._frame_start = 0 - self._cache = self._initialize_cache() - self._hdmap_videos = self._load_hdmaps() - if self.device.type == "cuda" and torch.cuda.is_available(): - torch.cuda.synchronize(device=self.device) - if dist.is_initialized(): - dist.barrier() - - def next_step_request(self) -> StepRequest | None: - if self._closed: - return None - if self._step_index >= self.scenario.total_blocks: - return None - num_frames = int(self.pipeline.get_num_frames(self._step_index)) - if self._frame_start + num_frames > self._hdmap_videos.shape[2]: - return None - return StepRequest(step_index=self._step_index) - - def step(self, inputs: InferenceInput) -> StepResult: - del inputs - if self._closed: - raise RuntimeError("OmniDreams replay session is closed.") - - step_index = self._step_index - num_frames = int(self.pipeline.get_num_frames(step_index)) - frame_end = self._frame_start + num_frames - logger.info( - "OmniDreams demo replay step {} frames=[{}, {})", - step_index, - self._frame_start, - frame_end, - ) - start_t = time.perf_counter() - video_chunk = self.pipeline.generate( - autoregressive_index=step_index, - cache=self._cache, - hdmap=self._hdmap_videos[:, :, self._frame_start : frame_end], - ) - stats = self.pipeline.finalize( - autoregressive_index=step_index, - cache=self._cache, - ) - elapsed_s = time.perf_counter() - start_t - self._step_index += 1 - self._frame_start = frame_end - - metrics = _numeric_stats(stats) - metrics.setdefault("model_step_s", elapsed_s) - return StepResult( - step_index=step_index, - output=VideoStepResult.from_video_chunk( - chunk_index=step_index, - video_chunk=video_chunk, - layout=self.output_layout, - stats=metrics, - ), - frame_count=num_frames, - metrics=metrics, - ) - - def reset(self, inputs: InferenceInput | None = None) -> None: - if inputs is not None: - scenario = _scenario_from_inputs(inputs) - if scenario != self.scenario: - raise ValueError("OmniDreams replay reset cannot swap scenarios.") - cache = getattr(self, "_cache", None) - if cache is not None: - del self._cache - self._cache = self._initialize_cache() - self._step_index = 0 - self._frame_start = 0 - - def close(self) -> None: - self._closed = True - cache = getattr(self, "_cache", None) - if cache is not None: - del self._cache - - def _initialize_cache(self) -> Any: - scenario = self.scenario - first_frames = [ - load_first_frame_tensor( - path, - pixel_height=scenario.pixel_height, - pixel_width=scenario.pixel_width, - device=self.device, - dtype=self.dtype, - allow_video=True, - install_hint=DEFAULT_RUNNER_INSTALL_HINT, - ) - for path in scenario.first_frame_paths - ] - first_frames_t = torch.stack(first_frames, dim=0).unsqueeze(0) - cache = self.pipeline.initialize_cache( - text=[list(scenario.prompts)], - image=first_frames_t, - view_names=list(scenario.camera_names), - ) - release = getattr(self.pipeline, "release_oneshot_encoders", None) - if callable(release): - release() - return cache - - def _load_hdmaps(self) -> torch.Tensor: - scenario = self.scenario - videos = [ - _load_video( - path, - pixel_height=scenario.pixel_height, - pixel_width=scenario.pixel_width, - device=self.device, - dtype=self.dtype, - ) - for path in scenario.hdmap_video_paths - ] - # [B=1, V, T, C, H, W] - hdmap_videos = torch.stack(videos, dim=0).unsqueeze(0) - if self.is_rank_zero: - logger.info( - "Loaded OmniDreams demo HDMaps shape={} views={}", - tuple(hdmap_videos.shape), - len(scenario.camera_names), - ) - return hdmap_videos - - -def _default_pipeline_factory(pipeline_config: Any, device: str) -> Any: - return pipeline_config.setup().to(device=device).eval() - - -def _scenario_from_inputs(inputs: InferenceInput) -> OmnidreamsReplayScenario: - scenario = inputs.global_conditioning.get("scenario") - if not isinstance(scenario, OmnidreamsReplayScenario): - raise TypeError( - "OmniDreams replay runtime requires global_conditioning['scenario'] " - "to be an OmnidreamsReplayScenario." - ) - return scenario - - -def _numeric_stats(stats: Any) -> dict[str, float | int]: - if not isinstance(stats, Mapping): - return {} - return { - str(key): value - for key, value in stats.items() - if isinstance(value, (float, int)) and not isinstance(value, bool) - } - - -def _is_torchrun_env() -> bool: - return "RANK" in os.environ and "WORLD_SIZE" in os.environ - - -__all__ = [ - "OmnidreamsReplayRuntime", - "OmnidreamsReplayRuntimeOptions", - "OmnidreamsReplaySession", - "PipelineFactory", -] diff --git a/integrations/omnidreams/omnidreams/demo/spec.py b/integrations/omnidreams/omnidreams/demo/spec.py deleted file mode 100644 index 0a5dcc062..000000000 --- a/integrations/omnidreams/omnidreams/demo/spec.py +++ /dev/null @@ -1,271 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""OmniDreams demo-specific scenario shapes.""" - -from __future__ import annotations - -from collections.abc import Mapping, Sequence -from dataclasses import dataclass -from pathlib import Path -from typing import Any - -from omnidreams.runner import ( - DEFAULT_EXAMPLE_DATA_UUID_1V, - DEFAULT_VIDEO_HEIGHT, - DEFAULT_VIDEO_WIDTH, - _ensure_hf_single_view_example_data_synced, - _example_camera_names, -) -from omnidreams.scenes import SCENE_VARIANT_DEFAULT -from omnidreams.webrtc.session import DEFAULT_WEBRTC_SCENE_UUID - -DEFAULT_OMNIDREAMS_PRESET = "omnidreams-sv-2steps-chunk2-loc6-lightvae-lighttae" -OMNIDREAMS_MODEL_ID = "omnidreams" - - -@dataclass(frozen=True, kw_only=True, slots=True) -class OmnidreamsReplayScenario: - """Resolved replay assets for the shared MP4 demo path.""" - - prompts: tuple[str, ...] - hdmap_video_paths: tuple[Path, ...] - first_frame_paths: tuple[Path, ...] - camera_names: tuple[str, ...] - total_blocks: int = 60 - pixel_height: int = DEFAULT_VIDEO_HEIGHT - pixel_width: int = DEFAULT_VIDEO_WIDTH - fps: int = 30 - - def __post_init__(self) -> None: - if not self.prompts: - raise ValueError("OmnidreamsReplayScenario.prompts must be non-empty.") - num_views = len(self.prompts) - for name, values in ( - ("hdmap_video_paths", self.hdmap_video_paths), - ("first_frame_paths", self.first_frame_paths), - ("camera_names", self.camera_names), - ): - if len(values) != num_views: - raise ValueError( - f"OmnidreamsReplayScenario.{name} has {len(values)} " - f"entries but prompts has {num_views}." - ) - if self.total_blocks <= 0: - raise ValueError("OmnidreamsReplayScenario.total_blocks must be > 0.") - if self.pixel_height <= 0 or self.pixel_width <= 0: - raise ValueError("OmnidreamsReplayScenario pixel dimensions must be > 0.") - if self.fps <= 0: - raise ValueError("OmnidreamsReplayScenario.fps must be > 0.") - object.__setattr__( - self, - "hdmap_video_paths", - tuple(Path(path) for path in self.hdmap_video_paths), - ) - object.__setattr__( - self, - "first_frame_paths", - tuple(Path(path) for path in self.first_frame_paths), - ) - - -@dataclass(frozen=True, kw_only=True, slots=True) -class OmnidreamsWebRTCScenario: - """Scene/options for the shared WebRTC demo path.""" - - scene_dir: Path | None = None - scene_uuid: str | None = DEFAULT_WEBRTC_SCENE_UUID - scene_variant: str = SCENE_VARIANT_DEFAULT - camera_name: str = "camera_front_wide_120fov" - debug_serve_hdmaps: bool = False - postprocess_preset: str = "" - prefer_sw_encoder: bool = False - - def __post_init__(self) -> None: - if self.scene_dir is not None: - object.__setattr__(self, "scene_dir", Path(self.scene_dir)) - if not self.scene_variant.strip(): - raise ValueError("OmnidreamsWebRTCScenario.scene_variant is required.") - if not self.camera_name.strip(): - raise ValueError("OmnidreamsWebRTCScenario.camera_name is required.") - - -def resolve_replay_scenario( - value: Any, - *, - default_prompt: str = "", -) -> OmnidreamsReplayScenario: - """Normalize a user/demo scenario into a validated replay scenario.""" - if isinstance(value, OmnidreamsReplayScenario): - _require_existing_paths(value.hdmap_video_paths, label="hdmap_video_paths") - _require_existing_paths(value.first_frame_paths, label="first_frame_paths") - return value - if value is None: - value = {} - if not isinstance(value, Mapping): - raise TypeError( - "OmniDreams replay scenario must be an OmnidreamsReplayScenario " - "a mapping, or None." - ) - - hdmap_paths = _path_tuple(value.get("hdmap_video_paths", ())) - first_paths = _path_tuple(value.get("first_frame_paths", ())) - example_data = _resolve_example_data_default(value) - if example_data and (not hdmap_paths or not first_paths): - example_hdmaps, example_first_frames = ( - _ensure_hf_single_view_example_data_synced( - str(value.get("example_data_uuid", DEFAULT_EXAMPLE_DATA_UUID_1V)) - ) - ) - if not hdmap_paths: - hdmap_paths = example_hdmaps - if not first_paths: - first_paths = example_first_frames - - _require_existing_paths(hdmap_paths, label="hdmap_video_paths") - _require_existing_paths(first_paths, label="first_frame_paths") - if len(hdmap_paths) != len(first_paths): - raise ValueError( - "OmniDreams replay scenario requires one HDMap video and first " - "frame per view." - ) - - num_views = len(hdmap_paths) - prompts = _resolve_prompts(value, num_views, default_prompt=default_prompt) - camera_names = _string_tuple(value.get("camera_names", ())) - if not camera_names: - camera_names = ( - _example_camera_names(num_views) - if example_data - else tuple(f"view_{i}" for i in range(num_views)) - ) - - return OmnidreamsReplayScenario( - prompts=prompts, - hdmap_video_paths=hdmap_paths, - first_frame_paths=first_paths, - camera_names=camera_names, - total_blocks=int(value.get("total_blocks", 60)), - pixel_height=int(value.get("pixel_height", DEFAULT_VIDEO_HEIGHT)), - pixel_width=int(value.get("pixel_width", DEFAULT_VIDEO_WIDTH)), - fps=int(value.get("fps", 30)), - ) - - -def resolve_webrtc_scenario(value: Any) -> OmnidreamsWebRTCScenario: - """Normalize a user/demo scenario into a WebRTC scenario.""" - if value is None: - return OmnidreamsWebRTCScenario() - if isinstance(value, OmnidreamsWebRTCScenario): - return value - if not isinstance(value, Mapping): - raise TypeError( - "OmniDreams WebRTC scenario must be an OmnidreamsWebRTCScenario, " - "a mapping, or None." - ) - scene_dir = value.get("scene_dir") - return OmnidreamsWebRTCScenario( - scene_dir=Path(scene_dir) if scene_dir is not None else None, - scene_uuid=value.get("scene_uuid", DEFAULT_WEBRTC_SCENE_UUID), - scene_variant=str(value.get("scene_variant", SCENE_VARIANT_DEFAULT)), - camera_name=str(value.get("camera_name", "camera_front_wide_120fov")), - debug_serve_hdmaps=bool(value.get("debug_serve_hdmaps", False)), - postprocess_preset=str(value.get("postprocess_preset", "")), - prefer_sw_encoder=bool(value.get("prefer_sw_encoder", False)), - ) - - -def _resolve_prompts( - value: Mapping[str, Any], - num_views: int, - *, - default_prompt: str, -) -> tuple[str, ...]: - prompts = _string_tuple(value.get("prompts", ())) - if prompts: - if len(prompts) != num_views: - raise ValueError( - f"OmniDreams replay prompts has {len(prompts)} entries but " - f"there are {num_views} views." - ) - return prompts - prompt = str(value.get("prompt", "")).strip() - if not prompt: - prompt = default_prompt.strip() - if not prompt: - raise ValueError("OmniDreams replay scenario requires prompt or prompts.") - return (prompt,) * num_views - - -def _resolve_example_data_default(value: Mapping[str, Any]) -> bool: - explicit = value.get("example_data") - if explicit is not None: - return _bool_value(explicit) - return not ( - _has_nonempty_value(value, "hdmap_video_paths") - or _has_nonempty_value(value, "first_frame_paths") - ) - - -def _bool_value(value: Any) -> bool: - if isinstance(value, bool): - return value - if isinstance(value, str): - lowered = value.strip().lower() - if lowered in {"1", "true", "yes", "on"}: - return True - if lowered in {"0", "false", "no", "off"}: - return False - return bool(value) - - -def _has_nonempty_value(value: Mapping[str, Any], key: str) -> bool: - if key not in value: - return False - raw = value[key] - if raw is None or raw == "": - return False - if isinstance(raw, Sequence) and not isinstance(raw, str): - return len(raw) > 0 - return True - - -def _path_tuple(value: Any) -> tuple[Path, ...]: - if value is None or value == "": - return () - if isinstance(value, (str, Path)): - return (Path(value),) - if isinstance(value, Sequence): - return tuple(Path(path) for path in value) - raise TypeError(f"Expected path or path sequence, got {type(value).__name__}.") - - -def _string_tuple(value: Any) -> tuple[str, ...]: - if value is None or value == "": - return () - if isinstance(value, str): - return (value,) - if isinstance(value, Sequence): - return tuple(str(item) for item in value) - raise TypeError(f"Expected string or string sequence, got {type(value).__name__}.") - - -def _require_existing_paths(paths: tuple[Path, ...], *, label: str) -> None: - if not paths: - raise ValueError(f"OmniDreams replay scenario requires {label}.") - missing = tuple(path for path in paths if not path.exists()) - if missing: - raise FileNotFoundError( - f"OmniDreams replay scenario missing {label}: " - + ", ".join(str(path) for path in missing) - ) - - -__all__ = [ - "DEFAULT_OMNIDREAMS_PRESET", - "OMNIDREAMS_MODEL_ID", - "OmnidreamsReplayScenario", - "OmnidreamsWebRTCScenario", - "resolve_replay_scenario", - "resolve_webrtc_scenario", -] diff --git a/integrations/omnidreams/omnidreams/demo/webrtc.py b/integrations/omnidreams/omnidreams/demo/webrtc.py deleted file mode 100644 index 699d47091..000000000 --- a/integrations/omnidreams/omnidreams/demo/webrtc.py +++ /dev/null @@ -1,178 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""OmniDreams WebRTC hooks for the shared demo API.""" - -from __future__ import annotations - -from typing import Any, cast - -from aiohttp import web -from omnidreams.webrtc.session import ( - OmnidreamsRuntimeConfig, - OmnidreamsRuntimeError, - OmnidreamsSessionInput, - _validate_requested_postprocess_preset, -) - -from flashdreams.plugins.registry import resolve_postprocess_preset -from flashdreams.runtime.demo import DemoSpec -from flashdreams.runtime.demo.webrtc import SharedDemoWebRTCSessionManager -from flashdreams.serving.webrtc.controls import WSAD_SUPPORTED_KEYS -from flashdreams.serving.webrtc.manager import DEFAULT_CLIENT_LIVENESS_TIMEOUT_S -from flashdreams.serving.webrtc.server import ( - SESSION_MANAGER_KEY, - SessionBusyError, - create_packaged_webrtc_app, -) -from flashdreams.serving.webrtc.server import ( - close_package_resources as _close_package_resources, -) - - -class OmnidreamsDemoWebRTCSessionManager(SharedDemoWebRTCSessionManager): - """Shared WebRTC manager customized for OmniDreams session semantics.""" - - _busy_message = "An Omnidreams session is already active." - _warmup_label = "Omnidreams WebRTC" - _runtime_error_types = (OmnidreamsRuntimeError,) - _close_session_on_generation_error = True - _resampler_supported_keys = WSAD_SUPPORTED_KEYS - - runtime_config: OmnidreamsRuntimeConfig - _runtime: Any - - def __init__( - self, - *, - runtime: Any, - runtime_config: OmnidreamsRuntimeConfig, - fps: int, - client_liveness_timeout_s: float = DEFAULT_CLIENT_LIVENESS_TIMEOUT_S, - ) -> None: - super().__init__( - model_name=runtime_config.pipeline_config_name, - runtime=runtime, - runtime_config=runtime_config, - fps=fps, - client_liveness_timeout_s=client_liveness_timeout_s, - ) - self._pending_session_input: OmnidreamsSessionInput | None = None - - def _model_name(self) -> str: - return self.runtime_config.pipeline_config_name - - def _chunk_done_extra(self) -> dict[str, Any]: - return { - "stream": "hdmap" if self.runtime_config.debug_serve_hdmaps else "rgb", - "postprocess_preset": self._runtime.postprocess_preset, - } - - def _peek_pending_session_input(self) -> OmnidreamsSessionInput | None: - return self._pending_session_input - - def _clear_pending_session_input(self) -> None: - self._pending_session_input = None - - async def _reset_runtime_for_session( - self, session_input: OmnidreamsSessionInput | None - ) -> None: - await self._runtime.reset_for_new_session(session_input=session_input) - - def set_pending_session_input(self, session_input: OmnidreamsSessionInput) -> None: - if self.has_active_session(): - raise SessionBusyError(self._busy_message) - preset = session_input.postprocess_preset - if preset: - _validate_requested_postprocess_preset( - requested_preset=preset, - configured_preset=self.runtime_config.postprocess.preset, - ) - self._pending_session_input = session_input - - -async def postprocess_options(request: web.Request) -> web.StreamResponse: - """Return the postprocess preset selected at server launch.""" - manager = _get_omnidreams_manager(request.app) - configured_preset = manager.runtime_config.postprocess.preset - presets = [configured_preset] if configured_preset else [] - return web.json_response( - { - "default_preset": configured_preset, - "presets": presets, - } - ) - - -async def session_input(request: web.Request) -> web.StreamResponse: - """Apply browser-selected settings to the next WebRTC rollout.""" - try: - payload = await request.json() - except Exception as exc: - raise web.HTTPBadRequest(reason="Expected JSON session input.") from exc - if not isinstance(payload, dict): - raise web.HTTPBadRequest(reason="Session input must be a JSON object.") - preset = payload.get("postprocess_preset") - if not isinstance(preset, str): - raise web.HTTPBadRequest( - reason="Session input must include string 'postprocess_preset'." - ) - - manager = _get_omnidreams_manager(request.app) - try: - manager.set_pending_session_input( - OmnidreamsSessionInput(postprocess_preset=preset) - ) - except SessionBusyError as exc: - raise web.HTTPConflict(reason=str(exc)) from exc - except ValueError as exc: - raise web.HTTPBadRequest(reason=str(exc)) from exc - return web.json_response({"postprocess_preset": preset}) - - -def configure_omnidreams_webrtc_app(app: web.Application) -> None: - """Register OmniDreams browser support routes on a shared WebRTC app.""" - app.router.add_get("/api/postprocess/options", postprocess_options) - app.router.add_post("/api/session/input", session_input) - - -def create_omnidreams_webrtc_app( - *, - spec: DemoSpec, - session_manager: Any, - request_session_url: str, -) -> web.Application: - """Create the packaged OmniDreams browser app through shared serving glue.""" - from importlib.resources import as_file, files - - output_preload_name = getattr(spec.output, "preload_name", None) - preload_name = output_preload_name if isinstance(output_preload_name, str) else "" - return create_packaged_webrtc_app( - web_resource=files("omnidreams.webrtc").joinpath("web"), - session_manager=session_manager, - preload_name=preload_name or "Omnidreams", - request_session_url=request_session_url, - configure_app=configure_omnidreams_webrtc_app, - as_file_fn=as_file, - cleanup_callback=_close_package_resources, - ) - - -def validate_postprocess_preset(preset: str) -> None: - """Validate a configured preset without enabling the output system broadly.""" - if preset: - resolve_postprocess_preset(preset) - - -def _get_omnidreams_manager(app: web.Application) -> OmnidreamsDemoWebRTCSessionManager: - return cast(OmnidreamsDemoWebRTCSessionManager, app[SESSION_MANAGER_KEY]) - - -__all__ = [ - "OmnidreamsDemoWebRTCSessionManager", - "configure_omnidreams_webrtc_app", - "create_omnidreams_webrtc_app", - "postprocess_options", - "session_input", - "validate_postprocess_preset", -] From 3c910f1246241dc9927ec3bae33d62438fce0d06 Mon Sep 17 00:00:00 2001 From: Fangjun Zhou Date: Thu, 6 Aug 2026 17:15:17 -0700 Subject: [PATCH 18/30] Add inference_session and inference_runtime --- THIRD-PARTY-NOTICES | 1 + .../flashdreams/runtime/application.py | 0 .../flashdreams/runtime/inference_runtime.py | 106 ++++++++ .../flashdreams/runtime/inference_session.py | 105 ++++++++ flashdreams/pyproject.toml | 1 + .../tests/runtime/test_inference_runtime.py | 173 +++++++++++++ .../tests/runtime/test_inference_session.py | 229 ++++++++++++++++++ pyproject.toml | 1 + uv.lock | 40 ++- 9 files changed, 650 insertions(+), 6 deletions(-) create mode 100644 flashdreams/flashdreams/runtime/application.py create mode 100644 flashdreams/flashdreams/runtime/inference_runtime.py create mode 100644 flashdreams/flashdreams/runtime/inference_session.py create mode 100644 flashdreams/tests/runtime/test_inference_runtime.py create mode 100644 flashdreams/tests/runtime/test_inference_session.py diff --git a/THIRD-PARTY-NOTICES b/THIRD-PARTY-NOTICES index 21c389ba9..9a99c3442 100644 --- a/THIRD-PARTY-NOTICES +++ b/THIRD-PARTY-NOTICES @@ -33,6 +33,7 @@ huggingface-hub Apache-2.0 https://github.com/huggingface/huggingface_h loguru MIT https://github.com/Delgan/loguru numpy BSD-3-Clause https://github.com/numpy/numpy nvidia-ml-py BSD-3-Clause https://pypi.org/project/nvidia-ml-py/ +pydantic MIT https://github.com/pydantic/pydantic safetensors Apache-2.0 https://github.com/huggingface/safetensors torch BSD-3-Clause https://pytorch.org torchvision BSD-3-Clause https://github.com/pytorch/vision diff --git a/flashdreams/flashdreams/runtime/application.py b/flashdreams/flashdreams/runtime/application.py new file mode 100644 index 000000000..e69de29bb diff --git a/flashdreams/flashdreams/runtime/inference_runtime.py b/flashdreams/flashdreams/runtime/inference_runtime.py new file mode 100644 index 000000000..d5ac4d120 --- /dev/null +++ b/flashdreams/flashdreams/runtime/inference_runtime.py @@ -0,0 +1,106 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Distributed inference runtime with shared pipeline ownership.""" + +import os +from abc import ABC, abstractmethod +from typing import Generic, TypeVar + +import torch +from flashdreams.core.distributed import init as init_distributed +from flashdreams.infra.pipeline import StreamInferencePipelineConfig +from flashdreams.runtime.inference_session import InferenceSession, PipelineT + + +def _is_torchrun_env() -> bool: + """Return whether ``torchrun`` set the distributed rendezvous variables.""" + return "RANK" in os.environ and "WORLD_SIZE" in os.environ + + +SessionT = TypeVar("SessionT", bound=InferenceSession) +"""Session type parameter for :class:`InferenceRuntime`.""" + + +class InferenceRuntime(ABC, Generic[PipelineT, SessionT]): + """Shared pipeline runtime for distributed inference sessions. + + Construction initializes PyTorch distributed when launched by ``torchrun``, + records rank metadata, and constructs one pipeline shared by every session. + Subclasses implement :meth:`warmup` for integration-specific model execution. + """ + + # ---------------- PyTorch Distributed State ---------------- # + + local_rank: int + """Process-local rank; ``0`` outside distributed runs.""" + + global_rank: int + """Global process rank; ``0`` outside distributed runs.""" + + world_size: int + """Number of distributed processes; ``1`` outside distributed runs.""" + + is_rank_zero: bool + """Whether this process is the global rank-zero process.""" + + pipeline: PipelineT + """Pipeline constructed once and shared by all sessions.""" + + session_type: type[SessionT] + """Concrete session type created by :meth:`create_session`.""" + + def __init__( + self, + pipeline_config: StreamInferencePipelineConfig, + session_type: type[SessionT], + ) -> None: + """Initialize distributed state and construct the shared pipeline. + + Args: + pipeline_config: Pipeline configuration to instantiate. + session_type: Concrete session type to create. + """ + # Initialize before pipeline construction so context-parallel components + # observe torchrun's world size while allocating their runtime state. + if _is_torchrun_env() and not torch.distributed.is_initialized(): + init_distributed() + + # Snapshot launch metadata for rank-gated runtime work while preserving + # stable single-process defaults for ordinary Python processes. + if torch.distributed.is_initialized(): + self.local_rank = int(os.environ.get("LOCAL_RANK", "0")) + self.global_rank = torch.distributed.get_rank() + self.world_size = torch.distributed.get_world_size() + else: + self.local_rank = 0 + self.global_rank = 0 + self.world_size = 1 + self.is_rank_zero = self.global_rank == 0 + + self.pipeline = pipeline_config.setup() + self.session_type = session_type + + def create_session(self) -> SessionT: + """Create a session backed by the shared pipeline. + + Returns: + Fresh session with its own pipeline cache. + """ + return self.session_type(self.pipeline) + + @abstractmethod + def warmup(self) -> None: + """Warm up the pipeline for inference.""" diff --git a/flashdreams/flashdreams/runtime/inference_session.py b/flashdreams/flashdreams/runtime/inference_session.py new file mode 100644 index 000000000..248136da6 --- /dev/null +++ b/flashdreams/flashdreams/runtime/inference_session.py @@ -0,0 +1,105 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Inference session contracts with pipeline and cache ownership.""" + +from abc import ABC, abstractmethod +from typing import Generic, TypeVar + +from flashdreams.infra.pipeline import ( + StreamInferencePipeline, + StreamInferencePipelineCache, +) +from pydantic import ConfigDict, validate_call, with_config +from typing_extensions import NotRequired, TypedDict + + +@with_config(ConfigDict(arbitrary_types_allowed=True, extra="forbid")) +class InferenceUserCondition(TypedDict): + """Base typed dictionary for per-step user conditions.""" + + +@with_config(ConfigDict(arbitrary_types_allowed=True, extra="forbid")) +class InferenceGlobalCondition(TypedDict): + """Base typed dictionary for rollout-wide conditions.""" + + +UserConditionT = TypeVar("UserConditionT", bound=InferenceUserCondition) +"""User-condition type parameter for :class:`InferenceInput`.""" + +GlobalConditionT = TypeVar("GlobalConditionT", bound=InferenceGlobalCondition) +"""Global-condition type parameter for :class:`InferenceInput`.""" + + +@with_config(ConfigDict(arbitrary_types_allowed=True, extra="forbid")) +class InferenceInput(TypedDict, Generic[UserConditionT, GlobalConditionT]): + """Validated conditions consumed by one inference step.""" + + user_condition: UserConditionT + """Required per-step user condition.""" + + global_condition: NotRequired[GlobalConditionT | None] + """Optional rollout-wide condition.""" + + +@with_config(ConfigDict(arbitrary_types_allowed=True, extra="forbid")) +class InferenceOutput(TypedDict): + """Base typed dictionary for outputs produced by one inference step.""" + + +# TODO: Replace StreamInferencePipeline with the flashdreams.pipeline module. +PipelineT = TypeVar("PipelineT", bound=StreamInferencePipeline) +"""Pipeline type parameter for :class:`InferenceSession`.""" + + +class InferenceSession(ABC, Generic[PipelineT]): + """Stateful interface around an inference pipeline and session cache. + + Subclasses implement :meth:`step` for integration-specific rollout I/O. + """ + + pipeline: PipelineT + """Pipeline owned and driven by the inference session.""" + + cache: StreamInferencePipelineCache + """Current per-session cache initialized by the pipeline.""" + + def __init__(self, pipeline: PipelineT) -> None: + """Initialize the session and reset its pipeline cache. + + Args: + pipeline: Pipeline to drive. + """ + self.pipeline = pipeline + self.reset() + + def reset(self) -> None: + """Reset the session with a fresh pipeline cache.""" + self.cache = self.pipeline.initialize_cache() + + @abstractmethod + @validate_call + def step(self, inference_input: InferenceInput) -> InferenceOutput: + """Run one inference step. + + Args: + inference_input: Input for the next inference step. + + Returns: + Output produced by the inference step. + + Raises: + ValidationError: ``inference_input`` fails Pydantic validation. + """ diff --git a/flashdreams/pyproject.toml b/flashdreams/pyproject.toml index 06278038c..cd5ef01dc 100644 --- a/flashdreams/pyproject.toml +++ b/flashdreams/pyproject.toml @@ -36,6 +36,7 @@ dependencies = [ # non-subclassable TypeAliasType in NumPy 2.5 (notably on Python 3.13). "numpy>=1.24,<2.5", "nvidia-ml-py>=12.0", + "pydantic>=2,<3", "safetensors>=0.4", "tqdm>=4.60", "transformers>=5.0,<6", diff --git a/flashdreams/tests/runtime/test_inference_runtime.py b/flashdreams/tests/runtime/test_inference_runtime.py new file mode 100644 index 000000000..11b47f2ad --- /dev/null +++ b/flashdreams/tests/runtime/test_inference_runtime.py @@ -0,0 +1,173 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""CPU tests for inference runtime pipeline and session ownership.""" + +from __future__ import annotations + +import pytest +import torch +from flashdreams.infra.pipeline import ( + StreamInferencePipeline, + StreamInferencePipelineCache, + StreamInferencePipelineConfig, +) +from flashdreams.runtime.inference_runtime import InferenceRuntime +from flashdreams.runtime.inference_session import ( + InferenceInput, + InferenceOutput, + InferenceSession, +) +from torch import nn + +pytestmark = pytest.mark.ci_cpu + + +# ---------------- Mock Pipeline and Sessions ---------------- # + + +class _MockStreamInferencePipelineCache(StreamInferencePipelineCache): + """Pipeline cache mock without model-specific state.""" + + def __init__(self) -> None: + """Initialize an empty cache.""" + + +class _MockStreamInferencePipeline(StreamInferencePipeline): + """Pipeline mock that records per-session cache initialization.""" + + initialize_cache_calls: int + """Number of caches initialized for created sessions.""" + + def __init__(self) -> None: + nn.Module.__init__(self) + self.initialize_cache_calls = 0 + + def initialize_cache( + self, + transformer_context: object | None = None, + encoder_context: object | None = None, + decoder_context: object | None = None, + ) -> _MockStreamInferencePipelineCache: + """Create and record a fresh mock session cache.""" + del transformer_context, encoder_context, decoder_context + self.initialize_cache_calls += 1 + return _MockStreamInferencePipelineCache() + + +class _MockStreamInferencePipelineConfig(StreamInferencePipelineConfig): + """Pipeline config mock that returns a preconstructed pipeline.""" + + pipeline: _MockStreamInferencePipeline + """Pipeline returned by the setup method.""" + + setup_calls: int + """Number of times the setup method has been called.""" + + def __init__(self, pipeline: _MockStreamInferencePipeline) -> None: + self.pipeline = pipeline + self.setup_calls = 0 + + def setup(self) -> _MockStreamInferencePipeline: + """Return the configured mock pipeline and record the setup call.""" + self.setup_calls += 1 + return self.pipeline + + +class _MockInferenceSession(InferenceSession[_MockStreamInferencePipeline]): + """Session mock that uses the runtime-owned pipeline.""" + + def step(self, inference_input: InferenceInput) -> InferenceOutput: + """Return an empty output without running the mock pipeline.""" + del inference_input + return InferenceOutput() + + +class _MockInferenceRuntime( + InferenceRuntime[_MockStreamInferencePipeline, _MockInferenceSession] +): + """Concrete runtime mock with a no-op warmup.""" + + def warmup(self) -> None: + """Complete warmup without running model computation.""" + + +# ---------------------- Test Fixtures ---------------------- # + + +@pytest.fixture +def runtime_bundle( + monkeypatch: pytest.MonkeyPatch, +) -> tuple[ + _MockInferenceRuntime, + _MockStreamInferencePipelineConfig, + _MockStreamInferencePipeline, +]: + """Build a single-process runtime with mocked pipeline setup.""" + monkeypatch.delenv("RANK", raising=False) + monkeypatch.delenv("WORLD_SIZE", raising=False) + monkeypatch.setattr(torch.distributed, "is_initialized", lambda: False) + + pipeline = _MockStreamInferencePipeline() + pipeline_config = _MockStreamInferencePipelineConfig(pipeline) + runtime = _MockInferenceRuntime(pipeline_config, _MockInferenceSession) + return runtime, pipeline_config, pipeline + + +# ------------------------------------------------------------ # +# PyTest Test Cases # +# ------------------------------------------------------------ # + + +def test_runtime_sets_up_and_holds_pipeline( + runtime_bundle: tuple[ + _MockInferenceRuntime, + _MockStreamInferencePipelineConfig, + _MockStreamInferencePipeline, + ], +) -> None: + """Verify runtime construction sets up and retains one pipeline.""" + runtime, pipeline_config, pipeline = runtime_bundle + + assert pipeline_config.setup_calls == 1 + assert runtime.pipeline is pipeline + assert runtime.session_type is _MockInferenceSession + assert runtime.local_rank == 0 + assert runtime.global_rank == 0 + assert runtime.world_size == 1 + assert runtime.is_rank_zero + + +def test_create_session_shares_pipeline_and_initializes_fresh_cache( + runtime_bundle: tuple[ + _MockInferenceRuntime, + _MockStreamInferencePipelineConfig, + _MockStreamInferencePipeline, + ], +) -> None: + """Verify created sessions share the pipeline but own separate caches.""" + runtime, pipeline_config, pipeline = runtime_bundle + + first_session = runtime.create_session() + second_session = runtime.create_session() + + assert pipeline_config.setup_calls == 1 + assert first_session is not second_session + assert first_session.pipeline is pipeline + assert second_session.pipeline is pipeline + assert isinstance(first_session.cache, _MockStreamInferencePipelineCache) + assert isinstance(second_session.cache, _MockStreamInferencePipelineCache) + assert first_session.cache is not second_session.cache + assert pipeline.initialize_cache_calls == 2 diff --git a/flashdreams/tests/runtime/test_inference_session.py b/flashdreams/tests/runtime/test_inference_session.py new file mode 100644 index 000000000..f523674bf --- /dev/null +++ b/flashdreams/tests/runtime/test_inference_session.py @@ -0,0 +1,229 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Pydantic validation tests for inference sessions.""" + +from __future__ import annotations + +from typing import Any, TypeAlias + +import pytest +import torch +from flashdreams.infra.pipeline import ( + StreamInferencePipeline, + StreamInferencePipelineCache, +) +from flashdreams.runtime.inference_session import ( + InferenceGlobalCondition, + InferenceInput, + InferenceOutput, + InferenceSession, + InferenceUserCondition, +) +from pydantic import ValidationError, validate_call +from torch import Tensor, nn + +pytestmark = pytest.mark.ci_cpu + + +# ---------------------- Mock Pipeline ---------------------- # + + +class _MockStreamInferencePipelineCache(StreamInferencePipelineCache): + """In-memory cache mock without model-specific state.""" + + def __init__(self) -> None: + """Initialize a cache without model-specific state.""" + + +class _MockStreamInferencePipeline(StreamInferencePipeline): + """Pipeline mock that creates an in-memory cache without model setup.""" + + def __init__(self) -> None: + nn.Module.__init__(self) + + def initialize_cache( + self, + transformer_context: object | None = None, + encoder_context: object | None = None, + decoder_context: object | None = None, + ) -> _MockStreamInferencePipelineCache: + """Return a fresh cache without constructing model components.""" + del transformer_context, encoder_context, decoder_context + return _MockStreamInferencePipelineCache() + + +# ------------------ Mock Inference Session ------------------ # + + +class _MockUserCondition(InferenceUserCondition): + """User-provided controls for the mock inference step.""" + + movement: Tensor + """Embedded latent tensor describing character movement.""" + + camera: Tensor + """Embedded latent tensor describing camera rotation.""" + + +class _MockGlobalCondition(InferenceGlobalCondition): + """Session-wide controls for the mock inference step.""" + + frame: Tensor + """Embedded latent tensor describing the global conditioning frame.""" + + prompt: Tensor + """Embedded latent tensor describing prompt conditioning.""" + + +# Specialize both nested dictionaries so ``validate_call`` sees their fields. +_MockInferenceInput: TypeAlias = InferenceInput[ + _MockUserCondition, _MockGlobalCondition +] + + +class _MockInferenceOutput(InferenceOutput): + """Output returned by the mock inference session.""" + + frame_chunk: Tensor + """Fully decoded frame chunk from the model latent; for WAN, its shape is + ``[4, H, W, 3]``.""" + + +class _MockInferenceSession(InferenceSession[_MockStreamInferencePipeline]): + """Inference session with concrete condition dictionaries.""" + + @validate_call + def step(self, inference_input: _MockInferenceInput) -> _MockInferenceOutput: + """Return a frame chunk from the validated inference input.""" + global_condition = inference_input.get("global_condition") + frame_chunk = ( + global_condition["frame"] + if global_condition is not None + else inference_input["user_condition"]["camera"] + ) + return _MockInferenceOutput(frame_chunk=frame_chunk) + + +# ---------------------- Test Fixtures ---------------------- # + + +@pytest.fixture +def session() -> _MockInferenceSession: + return _MockInferenceSession(_MockStreamInferencePipeline()) + + +def _user_condition() -> _MockUserCondition: + return _MockUserCondition( + movement=torch.tensor([1.0, 0.0, -1.0]), + camera=torch.eye(4), + ) + + +def _global_condition() -> _MockGlobalCondition: + return _MockGlobalCondition( + frame=torch.zeros(3, 8, 8), + prompt=torch.ones(4, 16), + ) + + +# ------------------------------------------------------------ # +# PyTest Test Cases # +# ------------------------------------------------------------ # + + +def test_step_validates_nested_conditions(session: _MockInferenceSession) -> None: + """Verify complete nested conditions pass step validation.""" + user_condition = _user_condition() + global_condition = _global_condition() + inference_input: Any = { + "user_condition": user_condition, + "global_condition": global_condition, + } + + # Pass a raw mapping so ``step`` performs Pydantic validation and conversion. + output = session.step(inference_input) + + assert torch.equal(output["frame_chunk"], global_condition["frame"]) + + +def test_step_accepts_missing_optional_global_condition( + session: _MockInferenceSession, +) -> None: + """Verify step accepts an omitted optional global condition.""" + user_condition = _user_condition() + inference_input: Any = {"user_condition": user_condition} + + output = session.step(inference_input) + + assert torch.equal(output["frame_chunk"], user_condition["camera"]) + + +def test_step_rejects_missing_user_condition( + session: _MockInferenceSession, +) -> None: + """Verify step rejects an omitted required user condition.""" + inference_input: Any = {"global_condition": _global_condition()} + + with pytest.raises(ValidationError) as exc_info: + session.step(inference_input) + + assert any( + error["loc"][-1:] == ("user_condition",) for error in exc_info.value.errors() + ) + + +@pytest.mark.parametrize("missing_field", ["movement", "camera"]) +def test_step_rejects_missing_user_field( + session: _MockInferenceSession, + missing_field: str, +) -> None: + """Verify step rejects a user condition missing a required tensor field.""" + user_condition = dict(_user_condition()) + del user_condition[missing_field] + inference_input: Any = { + "user_condition": user_condition, + "global_condition": _global_condition(), + } + + with pytest.raises(ValidationError) as exc_info: + session.step(inference_input) + + assert any( + error["loc"][-2:] == ("user_condition", missing_field) + for error in exc_info.value.errors() + ) + + +@pytest.mark.parametrize("missing_field", ["frame", "prompt"]) +def test_step_rejects_missing_global_field( + session: _MockInferenceSession, + missing_field: str, +) -> None: + """Verify step rejects a global condition missing a required tensor field.""" + global_condition = dict(_global_condition()) + del global_condition[missing_field] + inference_input: Any = { + "user_condition": _user_condition(), + "global_condition": global_condition, + } + + with pytest.raises(ValidationError) as exc_info: + session.step(inference_input) + + assert any( + error["loc"][-2:] == ("global_condition", missing_field) + for error in exc_info.value.errors() + ) diff --git a/pyproject.toml b/pyproject.toml index 49b04c628..6ec3932d0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -135,6 +135,7 @@ test = [ ] lint = [ "pre-commit>=4.3.0", + "ruff>=0.16.1", "sphinx>=7.0", "ty>=0.0.39", {include-group = "test"}, diff --git a/uv.lock b/uv.lock index 4d0a32ba5..dac4e769b 100644 --- a/uv.lock +++ b/uv.lock @@ -65,6 +65,7 @@ lint = [ { name = "pytest", specifier = ">=8.0" }, { name = "pytest-asyncio", specifier = ">=0.23" }, { name = "pytest-manual-marker", specifier = ">=2.0" }, + { name = "ruff", specifier = ">=0.16.1" }, { name = "sphinx", specifier = ">=7.0" }, { name = "tomli", specifier = ">=2.0" }, { name = "ty", specifier = ">=0.0.39" }, @@ -971,6 +972,7 @@ dependencies = [ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'extra-11-flashdreams-dev' and extra == 'group-11-flashdreams-cuda12') or (extra == 'group-11-flashdreams-cuda12' and extra == 'group-11-flashdreams-cuda13')" }, { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-11-flashdreams-dev' and extra == 'group-11-flashdreams-cuda12') or (extra == 'group-11-flashdreams-cuda12' and extra == 'group-11-flashdreams-cuda13')" }, { name = "nvidia-ml-py" }, + { name = "pydantic" }, { name = "safetensors" }, { name = "torch", version = "2.11.0+cu128", source = { registry = "https://download.pytorch.org/whl/cu128" }, marker = "(sys_platform != 'win32' and extra == 'group-11-flashdreams-cuda12') or (extra == 'extra-11-flashdreams-dev' and extra == 'group-11-flashdreams-cuda12') or (extra == 'group-11-flashdreams-cuda12' and extra == 'group-11-flashdreams-cuda13')" }, { name = "torch", version = "2.12.1", source = { registry = "https://pypi.org/simple" }, marker = "(sys_platform != 'win32' and extra == 'extra-11-flashdreams-dev') or (sys_platform != 'win32' and extra != 'group-11-flashdreams-cuda12') or (extra == 'extra-11-flashdreams-dev' and extra == 'group-11-flashdreams-cuda12') or (extra == 'group-11-flashdreams-cuda12' and extra == 'group-11-flashdreams-cuda13')" }, @@ -1045,6 +1047,7 @@ requires-dist = [ { name = "nvidia-vfx", marker = "extra == 'rtx-postprocess'", specifier = "==0.1.0.1" }, { name = "opencv-python-headless", marker = "extra == 'examples'", specifier = ">=4.5" }, { name = "opencv-python-headless", marker = "extra == 'runners'", specifier = ">=4.5" }, + { name = "pydantic", specifier = ">=2,<3" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, { name = "pytest-manual-marker", marker = "extra == 'dev'", specifier = ">=2.0" }, { name = "safetensors", specifier = ">=0.4" }, @@ -3488,10 +3491,10 @@ name = "pydantic" version = "2.13.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "annotated-types", marker = "sys_platform != 'win32'" }, - { name = "pydantic-core", marker = "sys_platform != 'win32'" }, - { name = "typing-extensions", marker = "sys_platform != 'win32'" }, - { name = "typing-inspection", marker = "sys_platform != 'win32'" }, + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, ] sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } wheels = [ @@ -3503,7 +3506,7 @@ name = "pydantic-core" version = "2.46.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "sys_platform != 'win32'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } wheels = [ @@ -3903,6 +3906,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl", hash = "sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7", size = 7676, upload-time = "2025-12-17T18:25:33.098Z" }, ] +[[package]] +name = "ruff" +version = "0.16.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/70/25/7113f6d5498888c5fb7db34081cba7d5971c4cb1bfb26819966eee68f003/ruff-0.16.1.tar.gz", hash = "sha256:fedad7c801dabd3fb9741d76aca39246e6ddd9ca446a015875207bf19f1e6bc7", size = 4877500, upload-time = "2026-07-30T19:37:01.379Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/bd/694da69368e0973de65df2ddc73ab18d43c469d5963d9b150911de6bc513/ruff-0.16.1-py3-none-linux_armv6l.whl", hash = "sha256:58edb313b88f0c5460a26adf5f39a37a3be789494a15e3e411e35fa78b89f9a0", size = 10839126, upload-time = "2026-07-30T19:36:13.697Z" }, + { url = "https://files.pythonhosted.org/packages/3f/f0/b626e5d5bd0dd9576263658ef12885e2288afd1029a48e26ffed65ec1ac1/ruff-0.16.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fde5a99e2f97479af66edd6622c6d5a2a7592c77cf4153d9e4428f5eeb55b60c", size = 11070253, upload-time = "2026-07-30T19:36:17.14Z" }, + { url = "https://files.pythonhosted.org/packages/83/63/f40acfb6b35b88623e71684942b552c3edd96035f5d98f313815f7b277de/ruff-0.16.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e0d4c20532fca4f7fa609369161d968dd28f65d83dabbd61d8e9c7edbf7001f6", size = 10561425, upload-time = "2026-07-30T19:36:20.04Z" }, + { url = "https://files.pythonhosted.org/packages/aa/dd/14ec0e9c2b4d315547dd38765004b4863e354e1b52cb308272215d9f6f6d/ruff-0.16.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30affbcedf59ad5703d9c91f82266e02b47739f797e1a7b6e158e5526a6dae38", size = 10948879, upload-time = "2026-07-30T19:36:22.476Z" }, + { url = "https://files.pythonhosted.org/packages/33/e9/9d870cbae575030fdef595f04b4b97573c525b5497cce4f4498cf2f85446/ruff-0.16.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:24e9c631573cbca9d20f1283f8f479b2afa4a8503504822bd71a293889f16743", size = 10643691, upload-time = "2026-07-30T19:36:24.914Z" }, + { url = "https://files.pythonhosted.org/packages/c4/09/12743d544e2173f53ecd27217c65f90d2bc0f8424a66a60339e56bbc0457/ruff-0.16.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b41bdd48fb420987a9b5212e4957c26ad4abce401fa9ea9d4d85843727945f4f", size = 11435354, upload-time = "2026-07-30T19:36:28.447Z" }, + { url = "https://files.pythonhosted.org/packages/7f/89/a1652b2daee52083c9554a6333b678a8b01d0400f976827bb87857f9449a/ruff-0.16.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b0d1e1393b7648079e13669de1c1f4fde06d4583e84d8fd5c1551e0a77a2aa75", size = 12259033, upload-time = "2026-07-30T19:36:31.326Z" }, + { url = "https://files.pythonhosted.org/packages/16/96/ecdcb8c54ee7b123b487f807eb014e6e019155a0b81dfb669acd52f28ce3/ruff-0.16.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:07bf434b1c95f4e093be4532068ef4fcf00924eb2ade8796075980902d6fd54a", size = 11667981, upload-time = "2026-07-30T19:36:34.394Z" }, + { url = "https://files.pythonhosted.org/packages/cd/90/c52e12e0d862e9572f2a33aa227409143520abe53111e9a6babbac7b4af8/ruff-0.16.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39897739f112253ee4fdd2e8aa9a4f9ded99fb2be367d5f31dfa4ded6025584c", size = 11468183, upload-time = "2026-07-30T19:36:37.339Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6b/4ffb7ad1d83eb16cf8cbb3c8815d3f11c88460fd162d4b372a2059be1c2a/ruff-0.16.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:82ae3c0c0d74daf17b968a10b7b3bb3ef297ab7de0c1f749646b25e690ccb150", size = 11470071, upload-time = "2026-07-30T19:36:39.91Z" }, + { url = "https://files.pythonhosted.org/packages/9c/72/32ae7db4c0b5e32ab611787caa19d1546800676d79f7483b7100a3561bf4/ruff-0.16.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4d5f2ed10f8242d83fc08d521301089364e3375375705356f20c0e31606ef3ef", size = 10919503, upload-time = "2026-07-30T19:36:42.65Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ca/3d901ba6ad6fc38da39c3448fc6c59ac945679293a17c3ceb6d6c1cba13e/ruff-0.16.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a4665b309891f83f3e3c25447935f1213e9abbd4b5640af7a1f2def9f8d413c1", size = 10649861, upload-time = "2026-07-30T19:36:45.18Z" }, + { url = "https://files.pythonhosted.org/packages/92/79/894ef1ced26552d5f8c9cf6d85b0687840e1128c55aeab7b9c2d54a0d880/ruff-0.16.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:26e9ca5c9bc3971f20d3cf18a957f52ffd6a5f6564ff15c4912a144dcac22494", size = 11148137, upload-time = "2026-07-30T19:36:47.936Z" }, + { url = "https://files.pythonhosted.org/packages/2d/69/3609a09fa1cb46cc28b762363e440a354204e5dff01bd0c8d7437874d6b9/ruff-0.16.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:67e1e1e3fa4f0c82f0e36d4cd61e661f6e7a6196cb1aa92fe0828fa7b8f257cd", size = 11559211, upload-time = "2026-07-30T19:36:50.448Z" }, + { url = "https://files.pythonhosted.org/packages/fc/8a/fb22af2fd78a736e241fabf67e30ce1799a64244026377a49e133af90762/ruff-0.16.1-py3-none-win32.whl", hash = "sha256:d31765e131295b8445caf301e3e8a85b34d1b9b211b4109b7ba457888b051806", size = 10838258, upload-time = "2026-07-30T19:36:53.298Z" }, + { url = "https://files.pythonhosted.org/packages/d4/35/e57fd9fb5d423961df087a00b12d42c0a830288dc2f3b45ecca299158b4f/ruff-0.16.1-py3-none-win_amd64.whl", hash = "sha256:09b05e8b90c2cb06ad63464350e7a45e8e44a2dfe52072ebfba6666ca8d3f596", size = 11961111, upload-time = "2026-07-30T19:36:56.107Z" }, + { url = "https://files.pythonhosted.org/packages/cb/46/240ea004bf6dc4feb40e9832f2205a476a47dd5b8a3f8211a5fc5f95e20e/ruff-0.16.1-py3-none-win_arm64.whl", hash = "sha256:dbaadaac38c70239f056d306b7476f246b0bf000fa6b3876402acbf5b227eaf8", size = 11309414, upload-time = "2026-07-30T19:36:58.79Z" }, +] + [[package]] name = "s3transfer" version = "0.19.0" @@ -4910,7 +4938,7 @@ name = "typing-inspection" version = "0.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "sys_platform != 'win32'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } wheels = [ From c832e561934cb83dea7507082bdd576a96a26c11 Mon Sep 17 00:00:00 2001 From: Fangjun Zhou Date: Thu, 6 Aug 2026 18:01:56 -0700 Subject: [PATCH 19/30] Update inference_runtime --- .../flashdreams/runtime/inference_runtime.py | 13 +++++++++---- flashdreams/tests/runtime/test_inference_runtime.py | 4 +--- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/flashdreams/flashdreams/runtime/inference_runtime.py b/flashdreams/flashdreams/runtime/inference_runtime.py index d5ac4d120..5423c1f3c 100644 --- a/flashdreams/flashdreams/runtime/inference_runtime.py +++ b/flashdreams/flashdreams/runtime/inference_runtime.py @@ -21,8 +21,11 @@ import torch from flashdreams.core.distributed import init as init_distributed -from flashdreams.infra.pipeline import StreamInferencePipelineConfig -from flashdreams.runtime.inference_session import InferenceSession, PipelineT +from flashdreams.infra.pipeline import ( + StreamInferencePipeline, + StreamInferencePipelineConfig, +) +from flashdreams.runtime.inference_session import InferenceSession def _is_torchrun_env() -> bool: @@ -34,11 +37,13 @@ def _is_torchrun_env() -> bool: """Session type parameter for :class:`InferenceRuntime`.""" -class InferenceRuntime(ABC, Generic[PipelineT, SessionT]): +class InferenceRuntime(ABC, Generic[SessionT]): """Shared pipeline runtime for distributed inference sessions. Construction initializes PyTorch distributed when launched by ``torchrun``, records rank metadata, and constructs one pipeline shared by every session. + The concrete session type associates the runtime with its pipeline type, so + callers only parameterize the runtime with ``SessionT``. Subclasses implement :meth:`warmup` for integration-specific model execution. """ @@ -56,7 +61,7 @@ class InferenceRuntime(ABC, Generic[PipelineT, SessionT]): is_rank_zero: bool """Whether this process is the global rank-zero process.""" - pipeline: PipelineT + pipeline: StreamInferencePipeline """Pipeline constructed once and shared by all sessions.""" session_type: type[SessionT] diff --git a/flashdreams/tests/runtime/test_inference_runtime.py b/flashdreams/tests/runtime/test_inference_runtime.py index 11b47f2ad..886fd69c2 100644 --- a/flashdreams/tests/runtime/test_inference_runtime.py +++ b/flashdreams/tests/runtime/test_inference_runtime.py @@ -95,9 +95,7 @@ def step(self, inference_input: InferenceInput) -> InferenceOutput: return InferenceOutput() -class _MockInferenceRuntime( - InferenceRuntime[_MockStreamInferencePipeline, _MockInferenceSession] -): +class _MockInferenceRuntime(InferenceRuntime[_MockInferenceSession]): """Concrete runtime mock with a no-op warmup.""" def warmup(self) -> None: From 80dcb56f4172236f0ab40f641c2b447d8847306a Mon Sep 17 00:00:00 2001 From: Fangjun Zhou Date: Fri, 7 Aug 2026 08:17:37 -0700 Subject: [PATCH 20/30] Add omnidreams inference session --- .../omnidreams/runtime/inference_session.py | 312 +++++++++ .../tests/runtime/test_inference_session.py | 610 ++++++++++++++++++ 2 files changed, 922 insertions(+) create mode 100644 integrations/omnidreams/omnidreams/runtime/inference_session.py create mode 100644 integrations/omnidreams/tests/runtime/test_inference_session.py diff --git a/integrations/omnidreams/omnidreams/runtime/inference_session.py b/integrations/omnidreams/omnidreams/runtime/inference_session.py new file mode 100644 index 000000000..793795f7c --- /dev/null +++ b/integrations/omnidreams/omnidreams/runtime/inference_session.py @@ -0,0 +1,312 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""OmniDreams inference session with embedding and HDMap conditions.""" + +from typing import Annotated, TypeAlias, cast + +from flashdreams.infra.decoder import StreamingVideoDecoder +from flashdreams.runtime.inference_session import ( + InferenceGlobalCondition as BaseInferenceGlobalCondition, +) +from flashdreams.runtime.inference_session import InferenceInput as BaseInferenceInput +from flashdreams.runtime.inference_session import ( + InferenceOutput as BaseInferenceOutput, +) +from flashdreams.runtime.inference_session import ( + InferenceSession as BaseInferenceSession, +) +from flashdreams.runtime.inference_session import ( + InferenceUserCondition as BaseInferenceUserCondition, +) +from omnidreams.pipeline import OmnidreamsPipeline, OmnidreamsPipelineCache +from pydantic import AfterValidator, TypeAdapter, ValidationInfo +from torch import Tensor +from typing_extensions import NotRequired, TypedDict + + +def _validate_tensor_shape( + tensor: Tensor, + expected_shape: tuple[int | None, ...], + shape_description: str, +) -> Tensor: + """Validate a tensor's rank, fixed axes, and non-empty dimensions.""" + expected_rank = len(expected_shape) + if tensor.ndim != expected_rank: + raise ValueError( + f"expected a rank-{expected_rank} tensor; got rank-{tensor.ndim} " + f"with shape {tuple(tensor.shape)}" + ) + + if any( + expected_size is not None and tensor.shape[axis] != expected_size + for axis, expected_size in enumerate(expected_shape) + ): + raise ValueError( + f"expected tensor shape {shape_description}; got {tuple(tensor.shape)}" + ) + + if any(size <= 0 for size in tensor.shape): + raise ValueError( + f"expected every axis in tensor shape {shape_description} to be positive; " + f"got {tuple(tensor.shape)}" + ) + return tensor + + +def _validate_hdmap(tensor: Tensor) -> Tensor: + return _validate_tensor_shape( + tensor, + (None, None, None, 3, None, None), + "[B, V, T, 3, H, W]", + ) + + +def _validate_text_embeddings(tensor: Tensor) -> Tensor: + return _validate_tensor_shape( + tensor, + (None, None, None, None), + "[B, V, L, D]", + ) + + +def _validate_image_embeddings(tensor: Tensor) -> Tensor: + return _validate_tensor_shape( + tensor, + (None, None, 1, None, None, None), + "[B, V, 1, Cl, Hl, Wl]", + ) + + +_HDMapTensor: TypeAlias = Annotated[Tensor, AfterValidator(_validate_hdmap)] +_TextEmbeddingsTensor: TypeAlias = Annotated[ + Tensor, AfterValidator(_validate_text_embeddings) +] +_ImageEmbeddingsTensor: TypeAlias = Annotated[ + Tensor, AfterValidator(_validate_image_embeddings) +] + + +class InferenceUserCondition(BaseInferenceUserCondition): + """Per-step HDMap condition for OmniDreams inference.""" + + hdmap: _HDMapTensor + """HDMap pixels ``[B, V, T, 3, H, W]`` for the next video chunk.""" + + +class InferenceGlobalCondition(BaseInferenceGlobalCondition): + """Rollout-wide embedding conditions for OmniDreams inference.""" + + text_embeddings: _TextEmbeddingsTensor + """Text embeddings ``[B, V, L, D]`` for the rollout prompts.""" + + negative_text_embeddings: NotRequired[_TextEmbeddingsTensor | None] + """Optional negative-prompt embeddings ``[B, V, L, D]`` used for CFG.""" + + image_embeddings: _ImageEmbeddingsTensor + """First-frame image embeddings ``[B, V, 1, Cl, Hl, Wl]``.""" + + +InferenceInput: TypeAlias = BaseInferenceInput[ + InferenceUserCondition, InferenceGlobalCondition +] +"""OmniDreams conditions consumed by one inference step.""" + + +class _InferenceValidationContext(TypedDict): + """Pipeline-dependent state supplied to Pydantic input validation.""" + + pipeline: OmnidreamsPipeline + """Pipeline whose shape contracts apply to the input.""" + + autoregressive_index: int + """Index of the step being validated.""" + + rollout_resolution: tuple[int, int] | None + """Pixel resolution established by the active rollout, if any.""" + + +def _validate_condition_shapes( + inference_input: InferenceInput, + validation_info: ValidationInfo, +) -> InferenceInput: + """Validate shape relationships between per-step and rollout conditions.""" + global_condition = inference_input.get("global_condition") + hdmap = inference_input["user_condition"]["hdmap"] + + if global_condition is not None: + text_embeddings = global_condition["text_embeddings"] + image_embeddings = global_condition["image_embeddings"] + batch_view_shapes = { + "hdmap": tuple(hdmap.shape[:2]), + "text_embeddings": tuple(text_embeddings.shape[:2]), + "image_embeddings": tuple(image_embeddings.shape[:2]), + } + if len(set(batch_view_shapes.values())) != 1: + raise ValueError( + "expected hdmap, text_embeddings, and image_embeddings to share " + f"[B, V] dimensions; got {batch_view_shapes}" + ) + + negative_text_embeddings = global_condition.get("negative_text_embeddings") + if ( + negative_text_embeddings is not None + and negative_text_embeddings.shape != text_embeddings.shape + ): + raise ValueError( + "expected negative_text_embeddings shape to match text_embeddings; " + f"got {tuple(negative_text_embeddings.shape)} and " + f"{tuple(text_embeddings.shape)}" + ) + + if validation_info.context is None: + return inference_input + + context = cast(_InferenceValidationContext, validation_info.context) + pipeline = context["pipeline"] + autoregressive_index = context["autoregressive_index"] + rollout_resolution = context["rollout_resolution"] + + pipeline._validate_image_resolution(hdmap) + + actual_frames = int(hdmap.shape[2]) + expected_frames = pipeline.get_num_frames(autoregressive_index) + if actual_frames != expected_frames: + raise ValueError( + f"expected hdmap T={expected_frames} at autoregressive index " + f"{autoregressive_index}; got T={actual_frames}" + ) + + hdmap_resolution = (int(hdmap.shape[-2]), int(hdmap.shape[-1])) + if rollout_resolution is not None and hdmap_resolution != rollout_resolution: + raise ValueError( + f"expected hdmap resolution {rollout_resolution} for the active rollout; " + f"got {hdmap_resolution}" + ) + + if global_condition is not None: + decoder = pipeline.decoder + assert isinstance(decoder, StreamingVideoDecoder) + compression = decoder.spatial_compression_ratio + expected_latent_resolution = ( + hdmap_resolution[0] // compression, + hdmap_resolution[1] // compression, + ) + image_embeddings = global_condition["image_embeddings"] + image_latent_resolution = ( + int(image_embeddings.shape[-2]), + int(image_embeddings.shape[-1]), + ) + if image_latent_resolution != expected_latent_resolution: + raise ValueError( + "expected image_embeddings latent resolution " + f"{expected_latent_resolution} for hdmap resolution " + f"{hdmap_resolution}; got {image_latent_resolution}" + ) + return inference_input + + +_ValidatedInferenceInput: TypeAlias = Annotated[ + InferenceInput, AfterValidator(_validate_condition_shapes) +] + +_INFERENCE_INPUT_ADAPTER = TypeAdapter(_ValidatedInferenceInput) + + +class InferenceOutput(BaseInferenceOutput): + """Output produced by one OmniDreams inference step.""" + + video: Tensor + """Decoded video chunk produced for the step.""" + + +class InferenceSession(BaseInferenceSession): + """Stateful OmniDreams inference session backed by a per-rollout cache.""" + + pipeline: OmnidreamsPipeline + """OmniDreams pipeline shared with the inference runtime.""" + + cache: OmnidreamsPipelineCache | None + """Per-rollout cache; ``None`` until global conditions initialize it.""" + + autoregressive_index: int + """Zero-based index assigned to the next inference step.""" + + _rollout_resolution: tuple[int, int] | None + """HDMap pixel resolution fixed by the first successful rollout step.""" + + def reset(self) -> None: + """Reset the session to await rollout-wide embedding conditions.""" + self.cache = None + self.autoregressive_index = 0 + self._rollout_resolution = None + + def step(self, inference_input: InferenceInput) -> InferenceOutput: + """Generate one video chunk from validated OmniDreams conditions. + + Args: + inference_input: Per-step HDMap and optional first-step embeddings. + + Returns: + Decoded video chunk for the current autoregressive step. + + Raises: + ValueError: Global conditions are missing on the first step or are + supplied after the rollout cache has been initialized. + ValidationError: ``inference_input`` fails Pydantic validation. + """ + inference_input = _INFERENCE_INPUT_ADAPTER.validate_python( + inference_input, + context=_InferenceValidationContext( + pipeline=self.pipeline, + autoregressive_index=self.autoregressive_index, + rollout_resolution=self._rollout_resolution, + ), + ) + global_condition = inference_input.get("global_condition") + if self.cache is None: + if global_condition is None: + raise ValueError( + "global_condition is required on the first step after reset()." + ) + self.cache = self.pipeline.initialize_cache_from_embeddings( + text_embeddings=global_condition["text_embeddings"], + image_embeddings=global_condition["image_embeddings"], + negative_text_embeddings=global_condition.get( + "negative_text_embeddings" + ), + ) + hdmap = inference_input["user_condition"]["hdmap"] + self._rollout_resolution = ( + int(hdmap.shape[-2]), + int(hdmap.shape[-1]), + ) + elif global_condition is not None: + # TODO: Support in rollout global condition modification. + raise ValueError( + "global_condition can only be supplied on the first step after reset()." + ) + + video = self.pipeline.generate( + autoregressive_index=self.autoregressive_index, + cache=self.cache, + hdmap=inference_input["user_condition"]["hdmap"], + ) + self.pipeline.finalize( + autoregressive_index=self.autoregressive_index, + cache=self.cache, + ) + self.autoregressive_index += 1 + return InferenceOutput(video=video) diff --git a/integrations/omnidreams/tests/runtime/test_inference_session.py b/integrations/omnidreams/tests/runtime/test_inference_session.py new file mode 100644 index 000000000..ea108aecc --- /dev/null +++ b/integrations/omnidreams/tests/runtime/test_inference_session.py @@ -0,0 +1,610 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""CPU lifecycle tests for the OmniDreams inference session.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +import pytest +import torch +from flashdreams.infra.decoder import StreamingVideoDecoder +from flashdreams.infra.diffusion.model import DiffusionModelConfig +from flashdreams.infra.diffusion.scheduler.fm_euler import ( + FlowMatchEulerDiscreteSchedulerConfig, +) +from flashdreams.recipes.taehv import TeahvVAEDecoder, TeahvVAEDecoderConfig +from flashdreams.recipes.taehv.impl import TAEHVCache +from omnidreams.encoder.pixel_shuffle import ( + PixelShuffleVAEEncoderCache, + PixelShuffleVAEEncoderConfig, +) +from omnidreams.pipeline import OmnidreamsPipeline, OmnidreamsPipelineConfig +from omnidreams.runtime.inference_session import ( + InferenceGlobalCondition, + InferenceInput, + InferenceSession, + InferenceUserCondition, +) +from omnidreams.transformer import CosmosTransformerConfig +from omnidreams.transformer.impl.network import CosmosDiTNetworkConfig +from pydantic import ValidationError +from torch import Tensor + +pytestmark = pytest.mark.ci_cpu + + +# ---------------------- Mock Pipeline ---------------------- # + +# OmnidreamsPipeline requires a Wan or TAEHV decoder. This lightweight TAEHV +# subclass preserves that concrete contract without downloading decoder weights; +# the pipeline, HDMap encoder, transformer, scheduler, and caches remain real. + + +@dataclass(kw_only=True) +class _CPUDecoderConfig(TeahvVAEDecoderConfig): + """Configure the checkpoint-free decoder used by the CPU pipeline fixture.""" + + _target: type[_CPUDecoder] = field(default_factory=lambda: _CPUDecoder) + + +class _CPUDecoder(TeahvVAEDecoder): + """Preserve the Taehv pipeline contract without loading decoder weights.""" + + def __init__(self, config: TeahvVAEDecoderConfig) -> None: + """Initialize only the streaming decoder interface.""" + StreamingVideoDecoder.__init__(self, config) + + def initialize_autoregressive_cache(self) -> TAEHVCache: + """Return an empty Taehv-compatible cache.""" + return TAEHVCache() + + def forward( + self, + input: Tensor, + autoregressive_index: int = 0, + cache: TAEHVCache | None = None, + ) -> Tensor: + """Expose three latent channels as a cheap decoded video.""" + del autoregressive_index, cache + # Decoded pixel quality is outside this session test; retaining RGB channels + # keeps output assertions representative without running a pretrained VAE. + return input[..., :3, :, :] + + +@pytest.fixture +def pipeline() -> OmnidreamsPipeline: + """Set up the actual OmniDreams pipeline with tiny CPU components.""" + config = OmnidreamsPipelineConfig( + name="test-omnidreams-inference-session", + # Conditions arrive as precomputed embeddings, so one-shot text/image + # encoders are unnecessary. PixelShuffle remains the real per-step path. + text_encoder=None, + image_encoder=None, + encoder=PixelShuffleVAEEncoderConfig(), + decoder=_CPUDecoderConfig(), + diffusion_model=DiffusionModelConfig( + transformer=CosmosTransformerConfig( + network=CosmosDiTNetworkConfig( + # An 8x8 RGB HDMap becomes a 192-channel 1x1 control latent. + # Zero blocks retain patching, conditioning, and final-layer + # execution while keeping the CPU fixture small. + in_channels=16, + out_channels=16, + patch_spatial=1, + patch_temporal=1, + model_channels=12, + num_blocks=0, + num_heads=1, + mlp_ratio=1.0, + concat_padding_mask=False, + use_adaln_lora=False, + use_crossattn_projection=False, + crossattn_emb_channels=4, + additional_concat_ch=192, + ), + # Keep ci_cpu on eager, random-init code paths with no downloads. + dtype=torch.float32, + checkpoint_path=None, + batch_shape=(1,), + num_views=1, + len_t=1, + h_extrapolation_ratio=1.0, + w_extrapolation_ratio=1.0, + window_size_t=1, + sink_size_t=0, + compile_network=False, + use_cuda_graph=False, + skip_finalize_kv_cache=True, + ), + # One Euler step is enough to exercise generation orchestration. + scheduler=FlowMatchEulerDiscreteSchedulerConfig( + num_inference_steps=1, + fixed_timesteps=(1000.0, 0.0), + ), + seed=0, + ), + ) + + pipeline = config.setup() + assert type(pipeline) is OmnidreamsPipeline + return pipeline + + +@pytest.fixture +def session(pipeline: OmnidreamsPipeline) -> InferenceSession: + """Construct an inference session from the actual pipeline.""" + return InferenceSession(pipeline) + + +# ------------------- Condition Factories ------------------- # + + +def _user_condition( + value: float, + *, + num_frames: int = 1, + height: int = 8, + width: int = 8, +) -> InferenceUserCondition: + return InferenceUserCondition( + hdmap=torch.full((1, 1, num_frames, 3, height, width), value) + ) + + +def _global_condition( + value: float, + *, + include_negative: bool = False, + latent_height: int = 1, + latent_width: int = 1, +) -> InferenceGlobalCondition: + condition = InferenceGlobalCondition( + text_embeddings=torch.full((1, 1, 2, 4), value), + image_embeddings=torch.full( + (1, 1, 1, 16, latent_height, latent_width), value + 1 + ), + ) + if include_negative: + condition["negative_text_embeddings"] = torch.full((1, 1, 2, 4), value + 2) + return condition + + +# ------------------- Session Conditioning ------------------- # + + +def test_step_runs_actual_pipeline_with_global_conditions( + session: InferenceSession, + pipeline: OmnidreamsPipeline, +) -> None: + """Verify the first step initializes and runs the actual pipeline.""" + user_condition = _user_condition(2.0) + global_condition = _global_condition(3.0, include_negative=True) + + output = session.step( + InferenceInput( + user_condition=user_condition, + global_condition=global_condition, + ) + ) + + # Exact type equality prevents a test double from silently replacing the + # integration pipeline while preserving isinstance compatibility. + assert type(pipeline) is OmnidreamsPipeline + assert session.cache is not None + # Pipeline caches record the last generated index; the session index points + # to the next step that will be generated. + assert session.cache.autoregressive_index == 0 + assert isinstance(session.cache.encoder_cache, PixelShuffleVAEEncoderCache) + assert session.cache.encoder_cache.autoregressive_index == 0 + assert session.autoregressive_index == 1 + assert output["video"].shape == (1, 1, 1, 3, 1, 1) + assert torch.isfinite(output["video"]).all() + + +def test_step_reuses_actual_pipeline_cache_with_different_user_conditions( + session: InferenceSession, +) -> None: + """Verify later steps use new HDMaps while retaining rollout state.""" + first_output = session.step( + InferenceInput( + user_condition=_user_condition(1.0), + global_condition=_global_condition(2.0), + ) + ) + cache = session.cache + second_output = session.step( + InferenceInput(user_condition=_user_condition(7.0, num_frames=4)) + ) + + # The second user condition advances the same rollout cache rather than + # rebuilding global text/image conditioning. + assert session.cache is cache + assert cache is not None + assert cache.autoregressive_index == 1 + assert isinstance(cache.encoder_cache, PixelShuffleVAEEncoderCache) + assert cache.encoder_cache.autoregressive_index == 1 + assert session.autoregressive_index == 2 + assert first_output["video"].shape == second_output["video"].shape + assert torch.isfinite(first_output["video"]).all() + assert torch.isfinite(second_output["video"]).all() + + +def test_step_uses_different_global_conditions_after_reset( + session: InferenceSession, +) -> None: + """Verify reset creates an actual pipeline cache from new embeddings.""" + first_output = session.step( + InferenceInput( + user_condition=_user_condition(2.0), + global_condition=_global_condition(1.0), + ) + ) + first_cache = session.cache + assert first_cache is not None + first_image = first_cache.transformer_cache.image.clone() + + # Reset releases both the cache and its fixed pixel resolution, so a new + # rollout may use a different aligned HDMap/image-latent size. + session.reset() + second_output = session.step( + InferenceInput( + user_condition=_user_condition(8.0, height=16), + global_condition=_global_condition( + 9.0, + include_negative=True, + latent_height=2, + ), + ) + ) + + second_cache = session.cache + assert second_cache is not None + assert second_cache is not first_cache + assert not torch.equal(second_cache.transformer_cache.image, first_image) + assert not torch.equal(second_output["video"], first_output["video"]) + assert session.autoregressive_index == 1 + + +def test_step_requires_global_conditions_for_new_rollout( + session: InferenceSession, +) -> None: + """Verify a new rollout rejects an HDMap without embedding conditions.""" + with pytest.raises(ValueError, match="global_condition is required"): + session.step(InferenceInput(user_condition=_user_condition(1.0))) + + +def test_step_rejects_global_conditions_during_active_rollout( + session: InferenceSession, +) -> None: + """Verify an active rollout rejects replacement embedding conditions.""" + session.step( + InferenceInput( + user_condition=_user_condition(1.0), + global_condition=_global_condition(2.0), + ) + ) + cache = session.cache + + with pytest.raises(ValueError, match="can only be supplied on the first step"): + session.step( + InferenceInput( + user_condition=_user_condition(3.0, num_frames=4), + global_condition=_global_condition(4.0), + ) + ) + + # Rejection happens before pipeline generation and leaves both indices intact. + assert session.cache is cache + assert cache is not None + assert cache.autoregressive_index == 0 + assert session.autoregressive_index == 1 + + +# ---------------- Pydantic Schema Validation ---------------- # + + +@pytest.mark.parametrize( + "missing_field", + ["hdmap", "text_embeddings", "image_embeddings"], +) +def test_step_validates_omnidreams_condition_fields( + session: InferenceSession, + missing_field: str, +) -> None: + """Verify Pydantic rejects missing required OmniDreams conditions.""" + inference_input: Any = { + "user_condition": {"hdmap": torch.zeros(1)}, + "global_condition": { + "text_embeddings": torch.zeros(1), + "image_embeddings": torch.zeros(1), + }, + } + container = ( + inference_input["user_condition"] + if missing_field == "hdmap" + else inference_input["global_condition"] + ) + del container[missing_field] + + with pytest.raises(ValidationError) as exc_info: + session.step(inference_input) + + assert any( + error["loc"][-1:] == (missing_field,) for error in exc_info.value.errors() + ) + + +@pytest.mark.parametrize( + ("field_name", "expected_rank"), + [ + ("hdmap", 6), + ("text_embeddings", 4), + ("negative_text_embeddings", 4), + ("image_embeddings", 6), + ], +) +def test_step_validates_omnidreams_condition_tensor_ranks( + session: InferenceSession, + field_name: str, + expected_rank: int, +) -> None: + """Verify Pydantic rejects condition tensors with the wrong rank.""" + # Begin with a fully valid input and replace one field so the reported + # Pydantic location identifies only the dimension under test. + user_condition = dict(_user_condition(1.0)) + global_condition = dict(_global_condition(2.0, include_negative=True)) + condition = user_condition if field_name == "hdmap" else global_condition + condition[field_name] = torch.zeros((1,) * (expected_rank - 1)) + inference_input: Any = { + "user_condition": user_condition, + "global_condition": global_condition, + } + + with pytest.raises(ValidationError) as exc_info: + session.step(inference_input) + + matching_errors = [ + error for error in exc_info.value.errors() if error["loc"][-1:] == (field_name,) + ] + assert len(matching_errors) == 1 + assert f"rank-{expected_rank}" in matching_errors[0]["msg"] + assert session.cache is None + assert session.autoregressive_index == 0 + + +@pytest.mark.parametrize( + ("field_name", "invalid_tensor", "expected_message"), + [ + ( + "hdmap", + torch.zeros(1, 1, 1, 4, 8, 8), + "[B, V, T, 3, H, W]", + ), + ( + "hdmap", + torch.zeros(1, 1, 0, 3, 8, 8), + "every axis", + ), + ( + "text_embeddings", + torch.zeros(1, 1, 0, 4), + "every axis", + ), + ( + "negative_text_embeddings", + torch.zeros(1, 1, 2, 0), + "every axis", + ), + ( + "image_embeddings", + torch.zeros(1, 1, 2, 16, 1, 1), + "[B, V, 1, Cl, Hl, Wl]", + ), + ( + "image_embeddings", + torch.zeros(1, 1, 1, 16, 0, 1), + "every axis", + ), + ], +) +def test_step_validates_omnidreams_condition_tensor_shapes( + session: InferenceSession, + field_name: str, + invalid_tensor: Tensor, + expected_message: str, +) -> None: + """Verify Pydantic rejects fixed-axis and empty condition shapes.""" + # Keep every other field valid to isolate fixed-axis and empty-axis checks. + user_condition = dict(_user_condition(1.0)) + global_condition = dict(_global_condition(2.0, include_negative=True)) + condition = user_condition if field_name == "hdmap" else global_condition + condition[field_name] = invalid_tensor + inference_input: Any = { + "user_condition": user_condition, + "global_condition": global_condition, + } + + with pytest.raises(ValidationError) as exc_info: + session.step(inference_input) + + matching_errors = [ + error for error in exc_info.value.errors() if error["loc"][-1:] == (field_name,) + ] + assert len(matching_errors) == 1 + assert expected_message in matching_errors[0]["msg"] + assert session.cache is None + assert session.autoregressive_index == 0 + + +@pytest.mark.parametrize( + ("field_name", "invalid_tensor", "expected_message"), + [ + ( + "hdmap", + torch.zeros(1, 2, 1, 3, 8, 8), + "share [B, V] dimensions", + ), + ( + "text_embeddings", + torch.zeros(2, 1, 2, 4), + "share [B, V] dimensions", + ), + ( + "image_embeddings", + torch.zeros(1, 2, 1, 16, 1, 1), + "share [B, V] dimensions", + ), + ( + "negative_text_embeddings", + torch.zeros(1, 1, 3, 4), + "shape to match text_embeddings", + ), + ], +) +def test_step_validates_condition_shape_relationships( + session: InferenceSession, + field_name: str, + invalid_tensor: Tensor, + expected_message: str, +) -> None: + """Verify Pydantic validates shapes shared by multiple conditions.""" + # These tensors are individually valid; only their shared dimensions differ. + user_condition = dict(_user_condition(1.0)) + global_condition = dict(_global_condition(2.0, include_negative=True)) + condition = user_condition if field_name == "hdmap" else global_condition + condition[field_name] = invalid_tensor + inference_input: Any = { + "user_condition": user_condition, + "global_condition": global_condition, + } + + with pytest.raises(ValidationError) as exc_info: + session.step(inference_input) + + assert expected_message in str(exc_info.value) + assert session.cache is None + assert session.autoregressive_index == 0 + + +# ------------ Pipeline-aware Pydantic Validation ------------ # + + +def test_step_validates_hdmap_resolution_alignment_with_pipeline( + session: InferenceSession, +) -> None: + """Verify Pydantic applies the pipeline's pixel-alignment check.""" + # Width 9 violates the fixture's 8x VAE alignment while retaining rank/layout. + inference_input = InferenceInput( + user_condition=_user_condition(1.0, width=9), + global_condition=_global_condition(2.0), + ) + + with pytest.raises(ValidationError) as exc_info: + session.step(inference_input) + + assert "must be divisible by 8" in str(exc_info.value) + assert session.cache is None + assert session.autoregressive_index == 0 + + +def test_step_validates_image_embedding_resolution_against_hdmap( + session: InferenceSession, +) -> None: + """Verify Pydantic relates image latent and HDMap pixel resolutions.""" + # A 16x8 HDMap requires a 2x1 latent, but the default global condition is 1x1. + inference_input = InferenceInput( + user_condition=_user_condition(1.0, height=16), + global_condition=_global_condition(2.0), + ) + + with pytest.raises(ValidationError) as exc_info: + session.step(inference_input) + + assert "expected image_embeddings latent resolution (2, 1)" in str(exc_info.value) + assert session.cache is None + assert session.autoregressive_index == 0 + + +def test_step_validates_first_hdmap_frame_count_with_pipeline( + session: InferenceSession, +) -> None: + """Verify Pydantic checks the first AR step's HDMap frame count.""" + # len_t=1 produces one pixel frame at AR 0 despite the steady-state 4x ratio. + inference_input = InferenceInput( + user_condition=_user_condition(1.0, num_frames=4), + global_condition=_global_condition(2.0), + ) + + with pytest.raises(ValidationError) as exc_info: + session.step(inference_input) + + assert "expected hdmap T=1 at autoregressive index 0; got T=4" in str( + exc_info.value + ) + assert session.cache is None + assert session.autoregressive_index == 0 + + +def test_step_validates_later_hdmap_frame_count_with_pipeline( + session: InferenceSession, +) -> None: + """Verify Pydantic checks later AR steps using the pipeline index.""" + session.step( + InferenceInput( + user_condition=_user_condition(1.0), + global_condition=_global_condition(2.0), + ) + ) + cache = session.cache + + # Steady-state PixelShuffle/TAEHV geometry requires four input frames. + with pytest.raises(ValidationError) as exc_info: + session.step(InferenceInput(user_condition=_user_condition(3.0))) + + assert "expected hdmap T=4 at autoregressive index 1; got T=1" in str( + exc_info.value + ) + assert session.cache is cache + assert cache is not None + assert cache.autoregressive_index == 0 + assert session.autoregressive_index == 1 + + +def test_step_validates_hdmap_resolution_is_stable_during_rollout( + session: InferenceSession, +) -> None: + """Verify Pydantic rejects aligned resolution changes within a rollout.""" + session.step( + InferenceInput( + user_condition=_user_condition(1.0), + global_condition=_global_condition(2.0), + ) + ) + cache = session.cache + + # 16x8 is independently aligned; only changing the active rollout size is invalid. + with pytest.raises(ValidationError) as exc_info: + session.step( + InferenceInput(user_condition=_user_condition(3.0, num_frames=4, height=16)) + ) + + assert "expected hdmap resolution (8, 8)" in str(exc_info.value) + assert session.cache is cache + assert cache is not None + assert cache.autoregressive_index == 0 + assert session.autoregressive_index == 1 From 3af81e5a98b6a2119e63fb07440f1ff43d6a6e25 Mon Sep 17 00:00:00 2001 From: Fangjun Zhou Date: Fri, 7 Aug 2026 09:50:50 -0700 Subject: [PATCH 21/30] Add input/output to flashdreams.runtime --- .../builtin/inference_output/frame_chunk.py | 40 +++++++++ .../runtime/builtin/user_input/keyboard.py | 57 ++++++++++++ .../runtime/builtin/user_input/mouse.py | 45 ++++++++++ .../flashdreams/runtime/input_system.py | 49 +++++++++++ .../flashdreams/runtime/output_system.py | 38 ++++++++ .../tests/runtime/test_frame_chunk_output.py | 77 ++++++++++++++++ .../tests/runtime/test_inference_runtime.py | 9 +- .../tests/runtime/test_inference_session.py | 20 +++-- .../tests/runtime/test_keyboard_input.py | 87 +++++++++++++++++++ flashdreams/tests/runtime/test_mouse_input.py | 78 +++++++++++++++++ .../omnidreams/runtime/inference_session.py | 26 +++--- .../tests/runtime/test_inference_session.py | 21 +++-- 12 files changed, 517 insertions(+), 30 deletions(-) create mode 100644 flashdreams/flashdreams/runtime/builtin/inference_output/frame_chunk.py create mode 100644 flashdreams/flashdreams/runtime/builtin/user_input/keyboard.py create mode 100644 flashdreams/flashdreams/runtime/builtin/user_input/mouse.py create mode 100644 flashdreams/flashdreams/runtime/input_system.py create mode 100644 flashdreams/flashdreams/runtime/output_system.py create mode 100644 flashdreams/tests/runtime/test_frame_chunk_output.py create mode 100644 flashdreams/tests/runtime/test_keyboard_input.py create mode 100644 flashdreams/tests/runtime/test_mouse_input.py diff --git a/flashdreams/flashdreams/runtime/builtin/inference_output/frame_chunk.py b/flashdreams/flashdreams/runtime/builtin/inference_output/frame_chunk.py new file mode 100644 index 000000000..0fddb55e3 --- /dev/null +++ b/flashdreams/flashdreams/runtime/builtin/inference_output/frame_chunk.py @@ -0,0 +1,40 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tensor frame-chunk output contract for the builtin runtime.""" + +from typing import Annotated + +from flashdreams.runtime.inference_session import InferenceOutput +from pydantic import Field +from torch import Tensor + + +class FrameChunkOutput(InferenceOutput): + """Output containing a generated frame chunk.""" + + value: Tensor + """Generated frame chunk with integration-specific tensor layout.""" + + start_timestamp: Annotated[float, Field(ge=0, allow_inf_nan=False)] + """Timestamp of the first frame in seconds on the presentation timeline.""" + + fps: Annotated[float, Field(gt=0, allow_inf_nan=False)] + """Frame rate used to present the chunk.""" + + @property + def frame_present_time(self) -> float: + """Return the presentation duration of one frame in seconds.""" + return 1.0 / self.fps diff --git a/flashdreams/flashdreams/runtime/builtin/user_input/keyboard.py b/flashdreams/flashdreams/runtime/builtin/user_input/keyboard.py new file mode 100644 index 000000000..fe91f4cc6 --- /dev/null +++ b/flashdreams/flashdreams/runtime/builtin/user_input/keyboard.py @@ -0,0 +1,57 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Raw keyboard-event input contract for the builtin runtime.""" + +from enum import Enum + +from flashdreams.runtime.input_system import RawUserInput + + +class KeyboardEvent(str, Enum): + """Keyboard edge types reported by raw input sources.""" + + KEY_DOWN = "keydown" + KEY_UP = "keyup" + + +class KeyboardKey(str, Enum): + """Keyboard key identifiers supported by builtin input handlers.""" + + W = "w" + A = "a" + S = "s" + D = "d" + Q = "q" + E = "e" + I = "i" + J = "j" + K = "k" + L = "l" + UP = "up" + DOWN = "down" + LEFT = "left" + RIGHT = "right" + SPACE = "space" + + +class RawUserKeyboardEvent(RawUserInput): + """Timestamped raw keyboard edge received from an input source.""" + + event: KeyboardEvent + """Keyboard edge reported by the input source.""" + + key: KeyboardKey + """Supported keyboard key associated with the edge.""" diff --git a/flashdreams/flashdreams/runtime/builtin/user_input/mouse.py b/flashdreams/flashdreams/runtime/builtin/user_input/mouse.py new file mode 100644 index 000000000..1236742e3 --- /dev/null +++ b/flashdreams/flashdreams/runtime/builtin/user_input/mouse.py @@ -0,0 +1,45 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Raw mouse-button event contract for the builtin runtime.""" + +from enum import Enum + +from flashdreams.runtime.input_system import RawUserInput + + +class MouseEvent(str, Enum): + """Mouse-button edge types reported by raw input sources.""" + + BUTTON_DOWN = "mousedown" + BUTTON_UP = "mouseup" + + +class MouseButton(str, Enum): + """Mouse buttons supported by builtin input handlers.""" + + LEFT = "left" + MIDDLE = "middle" + RIGHT = "right" + + +class RawUserMouseEvent(RawUserInput): + """Timestamped raw mouse-button edge received from an input source.""" + + event: MouseEvent + """Mouse-button edge reported by the input source.""" + + button: MouseButton + """Mouse button associated with the edge.""" diff --git a/flashdreams/flashdreams/runtime/input_system.py b/flashdreams/flashdreams/runtime/input_system.py new file mode 100644 index 000000000..2e8152eef --- /dev/null +++ b/flashdreams/flashdreams/runtime/input_system.py @@ -0,0 +1,49 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""User-input contracts and handler interface for inference conditioning.""" + +from abc import ABC, abstractmethod +from typing import Annotated + +from flashdreams.runtime.inference_session import InferenceUserCondition +from pydantic import ConfigDict, Field, validate_call, with_config +from typing_extensions import TypedDict + + +@with_config(ConfigDict(arbitrary_types_allowed=True, extra="forbid")) +class RawUserInput(TypedDict): + """Base typed dictionary for device-specific user input.""" + + timestamp: Annotated[float, Field(ge=0, allow_inf_nan=False)] + """Event timestamp in seconds on the input source's monotonic clock.""" + + +@with_config(ConfigDict(arbitrary_types_allowed=True, extra="forbid")) +class CanonicalizedUserInput(TypedDict): + """Base typed dictionary for device-independent user intent.""" + + +class UserInputHandler(ABC): + """Interface for producing inference conditioning from user input.""" + + @abstractmethod + @validate_call + def __call__(self) -> InferenceUserCondition: + """Return a model-ready per-step condition. + + Returns: + Model-ready condition for one inference step. + """ diff --git a/flashdreams/flashdreams/runtime/output_system.py b/flashdreams/flashdreams/runtime/output_system.py new file mode 100644 index 000000000..b0fec716c --- /dev/null +++ b/flashdreams/flashdreams/runtime/output_system.py @@ -0,0 +1,38 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Inference-output handler interface for application-specific results.""" + +from abc import ABC, abstractmethod +from typing import Any + +from flashdreams.runtime.inference_session import InferenceOutput +from pydantic import validate_call + + +class InferenceOutputHandler(ABC): + """Interface for consuming output from an inference step.""" + + @abstractmethod + @validate_call + def __call__(self, inference_output: InferenceOutput) -> Any: + """Convert inference output into an application-specific result. + + Args: + inference_output: Output produced by one inference step. + + Returns: + Handler-specific result. + """ diff --git a/flashdreams/tests/runtime/test_frame_chunk_output.py b/flashdreams/tests/runtime/test_frame_chunk_output.py new file mode 100644 index 000000000..98162ccab --- /dev/null +++ b/flashdreams/tests/runtime/test_frame_chunk_output.py @@ -0,0 +1,77 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Pydantic validation tests for builtin frame-chunk outputs.""" + +from typing import Any + +import pytest +import torch +from flashdreams.runtime.builtin.inference_output.frame_chunk import ( + FrameChunkOutput, +) +from pydantic import TypeAdapter, ValidationError + +pytestmark = pytest.mark.ci_cpu + +_FRAME_CHUNK_OUTPUT_ADAPTER = TypeAdapter(FrameChunkOutput) + + +## Valid frame chunks + + +def test_frame_chunk_output_accepts_valid_timing_metadata() -> None: + """Verify Pydantic retains valid frame data and presentation timing.""" + frame_chunk = torch.zeros(1, 1, 4, 3, 8, 8) + + output = _FRAME_CHUNK_OUTPUT_ADAPTER.validate_python( + {"value": frame_chunk, "start_timestamp": 1.25, "fps": 30.0} + ) + + assert output.value is frame_chunk + assert output.start_timestamp == pytest.approx(1.25) + assert output.fps == pytest.approx(30.0) + assert output.frame_present_time == pytest.approx(1.0 / 30.0) + + +## Invalid frame chunks + + +# Each payload isolates a missing field, invalid value, or unsupported extra field. +@pytest.mark.parametrize( + "inference_output", + [ + {}, + {"value": torch.zeros(1), "start_timestamp": 0.0}, + {"value": torch.zeros(1), "fps": 30.0}, + {"value": "not-a-tensor", "start_timestamp": 0.0, "fps": 30.0}, + {"value": torch.zeros(1), "start_timestamp": -1.0, "fps": 30.0}, + {"value": torch.zeros(1), "start_timestamp": float("nan"), "fps": 30.0}, + {"value": torch.zeros(1), "start_timestamp": 0.0, "fps": 0.0}, + {"value": torch.zeros(1), "start_timestamp": 0.0, "fps": float("inf")}, + { + "value": torch.zeros(1), + "start_timestamp": 0.0, + "fps": 30.0, + "extra": True, + }, + ], +) +def test_frame_chunk_output_rejects_invalid_payloads( + inference_output: Any, +) -> None: + """Verify Pydantic rejects invalid frame data and timing metadata.""" + with pytest.raises(ValidationError): + _FRAME_CHUNK_OUTPUT_ADAPTER.validate_python(inference_output) diff --git a/flashdreams/tests/runtime/test_inference_runtime.py b/flashdreams/tests/runtime/test_inference_runtime.py index 886fd69c2..89e20db55 100644 --- a/flashdreams/tests/runtime/test_inference_runtime.py +++ b/flashdreams/tests/runtime/test_inference_runtime.py @@ -35,7 +35,7 @@ pytestmark = pytest.mark.ci_cpu -# ---------------- Mock Pipeline and Sessions ---------------- # +## Runtime test doubles class _MockStreamInferencePipelineCache(StreamInferencePipelineCache): @@ -102,7 +102,7 @@ def warmup(self) -> None: """Complete warmup without running model computation.""" -# ---------------------- Test Fixtures ---------------------- # +## Fixtures @pytest.fixture @@ -114,6 +114,7 @@ def runtime_bundle( _MockStreamInferencePipeline, ]: """Build a single-process runtime with mocked pipeline setup.""" + # Keep the fixture on the deterministic non-distributed initialization path. monkeypatch.delenv("RANK", raising=False) monkeypatch.delenv("WORLD_SIZE", raising=False) monkeypatch.setattr(torch.distributed, "is_initialized", lambda: False) @@ -124,9 +125,7 @@ def runtime_bundle( return runtime, pipeline_config, pipeline -# ------------------------------------------------------------ # -# PyTest Test Cases # -# ------------------------------------------------------------ # +## Runtime ownership behavior def test_runtime_sets_up_and_holds_pipeline( diff --git a/flashdreams/tests/runtime/test_inference_session.py b/flashdreams/tests/runtime/test_inference_session.py index f523674bf..8937ad763 100644 --- a/flashdreams/tests/runtime/test_inference_session.py +++ b/flashdreams/tests/runtime/test_inference_session.py @@ -38,7 +38,7 @@ pytestmark = pytest.mark.ci_cpu -# ---------------------- Mock Pipeline ---------------------- # +## Pipeline test doubles class _MockStreamInferencePipelineCache(StreamInferencePipelineCache): @@ -65,7 +65,7 @@ def initialize_cache( return _MockStreamInferencePipelineCache() -# ------------------ Mock Inference Session ------------------ # +## Session condition and output contracts class _MockUserCondition(InferenceUserCondition): @@ -117,15 +117,17 @@ def step(self, inference_input: _MockInferenceInput) -> _MockInferenceOutput: return _MockInferenceOutput(frame_chunk=frame_chunk) -# ---------------------- Test Fixtures ---------------------- # +## Fixtures and condition factories @pytest.fixture def session() -> _MockInferenceSession: + """Create a session backed by the lightweight pipeline double.""" return _MockInferenceSession(_MockStreamInferencePipeline()) def _user_condition() -> _MockUserCondition: + """Build a complete per-step condition for validation tests.""" return _MockUserCondition( movement=torch.tensor([1.0, 0.0, -1.0]), camera=torch.eye(4), @@ -133,15 +135,14 @@ def _user_condition() -> _MockUserCondition: def _global_condition() -> _MockGlobalCondition: + """Build a complete rollout-wide condition for validation tests.""" return _MockGlobalCondition( frame=torch.zeros(3, 8, 8), prompt=torch.ones(4, 16), ) -# ------------------------------------------------------------ # -# PyTest Test Cases # -# ------------------------------------------------------------ # +## Accepted session inputs def test_step_validates_nested_conditions(session: _MockInferenceSession) -> None: @@ -156,7 +157,7 @@ def test_step_validates_nested_conditions(session: _MockInferenceSession) -> Non # Pass a raw mapping so ``step`` performs Pydantic validation and conversion. output = session.step(inference_input) - assert torch.equal(output["frame_chunk"], global_condition["frame"]) + assert torch.equal(output.frame_chunk, global_condition["frame"]) def test_step_accepts_missing_optional_global_condition( @@ -168,7 +169,10 @@ def test_step_accepts_missing_optional_global_condition( output = session.step(inference_input) - assert torch.equal(output["frame_chunk"], user_condition["camera"]) + assert torch.equal(output.frame_chunk, user_condition["camera"]) + + +## Rejected session inputs def test_step_rejects_missing_user_condition( diff --git a/flashdreams/tests/runtime/test_keyboard_input.py b/flashdreams/tests/runtime/test_keyboard_input.py new file mode 100644 index 000000000..5fa4483ac --- /dev/null +++ b/flashdreams/tests/runtime/test_keyboard_input.py @@ -0,0 +1,87 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Pydantic validation tests for builtin keyboard input.""" + +from typing import Any + +import pytest +from flashdreams.runtime.builtin.user_input.keyboard import ( + KeyboardEvent, + KeyboardKey, + RawUserKeyboardEvent, +) +from pydantic import TypeAdapter, ValidationError + +pytestmark = pytest.mark.ci_cpu + +_RAW_KEYBOARD_EVENT_ADAPTER = TypeAdapter(RawUserKeyboardEvent) + + +## Keyboard event validation + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("keydown", KeyboardEvent.KEY_DOWN), + ("keyup", KeyboardEvent.KEY_UP), + ], +) +def test_keyboard_event_parses_enum_values( + value: str, + expected: KeyboardEvent, +) -> None: + """Verify Pydantic parses wire values into keyboard event members.""" + event = _RAW_KEYBOARD_EVENT_ADAPTER.validate_python( + {"timestamp": 1.0, "event": value, "key": "w"} + ) + + assert event["event"] is expected + + +@pytest.mark.parametrize("value", ["keypress", 1, None]) +def test_keyboard_event_rejects_invalid_enum_values(value: Any) -> None: + """Verify Pydantic rejects values outside the keyboard event enum.""" + with pytest.raises(ValidationError): + _RAW_KEYBOARD_EVENT_ADAPTER.validate_python( + {"timestamp": 1.0, "event": value, "key": "w"} + ) + + +## Keyboard key validation + + +@pytest.mark.parametrize("expected", list(KeyboardKey)) +def test_keyboard_key_parses_enum_values(expected: KeyboardKey) -> None: + """Verify Pydantic parses supported key strings into enum members.""" + event = _RAW_KEYBOARD_EVENT_ADAPTER.validate_python( + { + "timestamp": 1.0, + "event": KeyboardEvent.KEY_DOWN, + "key": expected.value, + } + ) + + assert event["key"] is expected + + +@pytest.mark.parametrize("value", ["enter", "", 1, None]) +def test_keyboard_key_rejects_invalid_enum_values(value: Any) -> None: + """Verify Pydantic rejects values outside the keyboard key enum.""" + with pytest.raises(ValidationError): + _RAW_KEYBOARD_EVENT_ADAPTER.validate_python( + {"timestamp": 1.0, "event": "keydown", "key": value} + ) diff --git a/flashdreams/tests/runtime/test_mouse_input.py b/flashdreams/tests/runtime/test_mouse_input.py new file mode 100644 index 000000000..143f0f7ed --- /dev/null +++ b/flashdreams/tests/runtime/test_mouse_input.py @@ -0,0 +1,78 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Pydantic validation tests for builtin mouse input.""" + +from typing import Any + +import pytest +from flashdreams.runtime.builtin.user_input.mouse import ( + MouseButton, + MouseEvent, + RawUserMouseEvent, +) +from pydantic import TypeAdapter, ValidationError + +pytestmark = pytest.mark.ci_cpu + +_RAW_MOUSE_EVENT_ADAPTER = TypeAdapter(RawUserMouseEvent) + + +## Mouse event validation + + +@pytest.mark.parametrize("expected", list(MouseEvent)) +def test_mouse_event_parses_enum_values(expected: MouseEvent) -> None: + """Verify Pydantic parses mouse edge strings into enum members.""" + event = _RAW_MOUSE_EVENT_ADAPTER.validate_python( + {"timestamp": 1.0, "event": expected.value, "button": "left"} + ) + + assert event["event"] is expected + + +## Mouse button validation + + +@pytest.mark.parametrize("expected", list(MouseButton)) +def test_mouse_button_parses_enum_values(expected: MouseButton) -> None: + """Verify Pydantic parses mouse button strings into enum members.""" + event = _RAW_MOUSE_EVENT_ADAPTER.validate_python( + { + "timestamp": 1.0, + "event": MouseEvent.BUTTON_DOWN, + "button": expected.value, + } + ) + + assert event["button"] is expected + + +@pytest.mark.parametrize("value", ["mousemove", "click", 1, None]) +def test_mouse_event_rejects_invalid_enum_values(value: Any) -> None: + """Verify Pydantic rejects values outside the mouse event enum.""" + with pytest.raises(ValidationError): + _RAW_MOUSE_EVENT_ADAPTER.validate_python( + {"timestamp": 1.0, "event": value, "button": "left"} + ) + + +@pytest.mark.parametrize("value", ["back", "", 1, None]) +def test_mouse_button_rejects_invalid_enum_values(value: Any) -> None: + """Verify Pydantic rejects values outside the mouse button enum.""" + with pytest.raises(ValidationError): + _RAW_MOUSE_EVENT_ADAPTER.validate_python( + {"timestamp": 1.0, "event": "mousedown", "button": value} + ) diff --git a/integrations/omnidreams/omnidreams/runtime/inference_session.py b/integrations/omnidreams/omnidreams/runtime/inference_session.py index 793795f7c..5bb7d1b60 100644 --- a/integrations/omnidreams/omnidreams/runtime/inference_session.py +++ b/integrations/omnidreams/omnidreams/runtime/inference_session.py @@ -18,13 +18,11 @@ from typing import Annotated, TypeAlias, cast from flashdreams.infra.decoder import StreamingVideoDecoder +from flashdreams.runtime.builtin.inference_output.frame_chunk import FrameChunkOutput from flashdreams.runtime.inference_session import ( InferenceGlobalCondition as BaseInferenceGlobalCondition, ) from flashdreams.runtime.inference_session import InferenceInput as BaseInferenceInput -from flashdreams.runtime.inference_session import ( - InferenceOutput as BaseInferenceOutput, -) from flashdreams.runtime.inference_session import ( InferenceSession as BaseInferenceSession, ) @@ -224,12 +222,8 @@ def _validate_condition_shapes( _INFERENCE_INPUT_ADAPTER = TypeAdapter(_ValidatedInferenceInput) - -class InferenceOutput(BaseInferenceOutput): - """Output produced by one OmniDreams inference step.""" - - video: Tensor - """Decoded video chunk produced for the step.""" +_PRESENTATION_FPS = 30.0 +"""Presentation rate used by the 30 FPS OmniDreams model.""" class InferenceSession(BaseInferenceSession): @@ -247,13 +241,17 @@ class InferenceSession(BaseInferenceSession): _rollout_resolution: tuple[int, int] | None """HDMap pixel resolution fixed by the first successful rollout step.""" + _presented_frame_count: int + """Number of frames emitted on the current presentation timeline.""" + def reset(self) -> None: """Reset the session to await rollout-wide embedding conditions.""" self.cache = None self.autoregressive_index = 0 self._rollout_resolution = None + self._presented_frame_count = 0 - def step(self, inference_input: InferenceInput) -> InferenceOutput: + def step(self, inference_input: InferenceInput) -> FrameChunkOutput: """Generate one video chunk from validated OmniDreams conditions. Args: @@ -308,5 +306,11 @@ def step(self, inference_input: InferenceInput) -> InferenceOutput: autoregressive_index=self.autoregressive_index, cache=self.cache, ) + start_timestamp = self._presented_frame_count / _PRESENTATION_FPS + self._presented_frame_count += int(video.shape[2]) self.autoregressive_index += 1 - return InferenceOutput(video=video) + return FrameChunkOutput( + value=video, + start_timestamp=start_timestamp, + fps=_PRESENTATION_FPS, + ) diff --git a/integrations/omnidreams/tests/runtime/test_inference_session.py b/integrations/omnidreams/tests/runtime/test_inference_session.py index ea108aecc..afc9de273 100644 --- a/integrations/omnidreams/tests/runtime/test_inference_session.py +++ b/integrations/omnidreams/tests/runtime/test_inference_session.py @@ -212,8 +212,11 @@ def test_step_runs_actual_pipeline_with_global_conditions( assert isinstance(session.cache.encoder_cache, PixelShuffleVAEEncoderCache) assert session.cache.encoder_cache.autoregressive_index == 0 assert session.autoregressive_index == 1 - assert output["video"].shape == (1, 1, 1, 3, 1, 1) - assert torch.isfinite(output["video"]).all() + assert output.value.shape == (1, 1, 1, 3, 1, 1) + assert torch.isfinite(output.value).all() + assert output.start_timestamp == pytest.approx(0.0) + assert output.fps == pytest.approx(30.0) + assert output.frame_present_time == pytest.approx(1.0 / 30.0) def test_step_reuses_actual_pipeline_cache_with_different_user_conditions( @@ -239,9 +242,14 @@ def test_step_reuses_actual_pipeline_cache_with_different_user_conditions( assert isinstance(cache.encoder_cache, PixelShuffleVAEEncoderCache) assert cache.encoder_cache.autoregressive_index == 1 assert session.autoregressive_index == 2 - assert first_output["video"].shape == second_output["video"].shape - assert torch.isfinite(first_output["video"]).all() - assert torch.isfinite(second_output["video"]).all() + assert first_output.value.shape == second_output.value.shape + assert torch.isfinite(first_output.value).all() + assert torch.isfinite(second_output.value).all() + assert first_output.start_timestamp == pytest.approx(0.0) + assert second_output.start_timestamp == pytest.approx( + first_output.value.shape[2] * first_output.frame_present_time + ) + assert second_output.fps == first_output.fps def test_step_uses_different_global_conditions_after_reset( @@ -276,7 +284,8 @@ def test_step_uses_different_global_conditions_after_reset( assert second_cache is not None assert second_cache is not first_cache assert not torch.equal(second_cache.transformer_cache.image, first_image) - assert not torch.equal(second_output["video"], first_output["video"]) + assert not torch.equal(second_output.value, first_output.value) + assert second_output.start_timestamp == pytest.approx(0.0) assert session.autoregressive_index == 1 From 8080d888cd333675ec87c67c97f37c5c442bb2b5 Mon Sep 17 00:00:00 2001 From: Fangjun Zhou Date: Fri, 7 Aug 2026 09:51:25 -0700 Subject: [PATCH 22/30] Changed InferenceOutput to BaseModel instead of TypedDict --- flashdreams/flashdreams/runtime/inference_session.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/flashdreams/flashdreams/runtime/inference_session.py b/flashdreams/flashdreams/runtime/inference_session.py index 248136da6..59808ca9a 100644 --- a/flashdreams/flashdreams/runtime/inference_session.py +++ b/flashdreams/flashdreams/runtime/inference_session.py @@ -22,7 +22,7 @@ StreamInferencePipeline, StreamInferencePipelineCache, ) -from pydantic import ConfigDict, validate_call, with_config +from pydantic import BaseModel, ConfigDict, validate_call, with_config from typing_extensions import NotRequired, TypedDict @@ -54,9 +54,10 @@ class InferenceInput(TypedDict, Generic[UserConditionT, GlobalConditionT]): """Optional rollout-wide condition.""" -@with_config(ConfigDict(arbitrary_types_allowed=True, extra="forbid")) -class InferenceOutput(TypedDict): - """Base typed dictionary for outputs produced by one inference step.""" +class InferenceOutput(BaseModel): + """Base model for outputs produced by one inference step.""" + + model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid") # TODO: Replace StreamInferencePipeline with the flashdreams.pipeline module. From 5251ea3d1a9889a6c9c3a30b138c9b8e871919a0 Mon Sep 17 00:00:00 2001 From: Fangjun Zhou Date: Fri, 7 Aug 2026 10:20:45 -0700 Subject: [PATCH 23/30] Changed inference input/output, user/global condition to Pydantic BaseModel --- .../flashdreams/runtime/inference_session.py | 24 +++---- .../tests/runtime/test_inference_session.py | 16 ++--- .../omnidreams/runtime/inference_session.py | 63 ++++++++++++------- .../tests/runtime/test_inference_session.py | 63 ++++++++++++++----- 4 files changed, 111 insertions(+), 55 deletions(-) diff --git a/flashdreams/flashdreams/runtime/inference_session.py b/flashdreams/flashdreams/runtime/inference_session.py index 59808ca9a..da072ba2a 100644 --- a/flashdreams/flashdreams/runtime/inference_session.py +++ b/flashdreams/flashdreams/runtime/inference_session.py @@ -22,18 +22,19 @@ StreamInferencePipeline, StreamInferencePipelineCache, ) -from pydantic import BaseModel, ConfigDict, validate_call, with_config -from typing_extensions import NotRequired, TypedDict +from pydantic import BaseModel, ConfigDict, validate_call -@with_config(ConfigDict(arbitrary_types_allowed=True, extra="forbid")) -class InferenceUserCondition(TypedDict): - """Base typed dictionary for per-step user conditions.""" +class InferenceUserCondition(BaseModel): + """Base model for per-step user conditions.""" + model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid") + + +class InferenceGlobalCondition(BaseModel): + """Base model for rollout-wide conditions.""" -@with_config(ConfigDict(arbitrary_types_allowed=True, extra="forbid")) -class InferenceGlobalCondition(TypedDict): - """Base typed dictionary for rollout-wide conditions.""" + model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid") UserConditionT = TypeVar("UserConditionT", bound=InferenceUserCondition) @@ -43,14 +44,15 @@ class InferenceGlobalCondition(TypedDict): """Global-condition type parameter for :class:`InferenceInput`.""" -@with_config(ConfigDict(arbitrary_types_allowed=True, extra="forbid")) -class InferenceInput(TypedDict, Generic[UserConditionT, GlobalConditionT]): +class InferenceInput(BaseModel, Generic[UserConditionT, GlobalConditionT]): """Validated conditions consumed by one inference step.""" + model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid") + user_condition: UserConditionT """Required per-step user condition.""" - global_condition: NotRequired[GlobalConditionT | None] + global_condition: GlobalConditionT | None = None """Optional rollout-wide condition.""" diff --git a/flashdreams/tests/runtime/test_inference_session.py b/flashdreams/tests/runtime/test_inference_session.py index 8937ad763..468645b42 100644 --- a/flashdreams/tests/runtime/test_inference_session.py +++ b/flashdreams/tests/runtime/test_inference_session.py @@ -88,7 +88,7 @@ class _MockGlobalCondition(InferenceGlobalCondition): """Embedded latent tensor describing prompt conditioning.""" -# Specialize both nested dictionaries so ``validate_call`` sees their fields. +# Specialize both nested models so ``validate_call`` sees their fields. _MockInferenceInput: TypeAlias = InferenceInput[ _MockUserCondition, _MockGlobalCondition ] @@ -108,11 +108,11 @@ class _MockInferenceSession(InferenceSession[_MockStreamInferencePipeline]): @validate_call def step(self, inference_input: _MockInferenceInput) -> _MockInferenceOutput: """Return a frame chunk from the validated inference input.""" - global_condition = inference_input.get("global_condition") + global_condition = inference_input.global_condition frame_chunk = ( - global_condition["frame"] + global_condition.frame if global_condition is not None - else inference_input["user_condition"]["camera"] + else inference_input.user_condition.camera ) return _MockInferenceOutput(frame_chunk=frame_chunk) @@ -157,7 +157,7 @@ def test_step_validates_nested_conditions(session: _MockInferenceSession) -> Non # Pass a raw mapping so ``step`` performs Pydantic validation and conversion. output = session.step(inference_input) - assert torch.equal(output.frame_chunk, global_condition["frame"]) + assert torch.equal(output.frame_chunk, global_condition.frame) def test_step_accepts_missing_optional_global_condition( @@ -169,7 +169,7 @@ def test_step_accepts_missing_optional_global_condition( output = session.step(inference_input) - assert torch.equal(output.frame_chunk, user_condition["camera"]) + assert torch.equal(output.frame_chunk, user_condition.camera) ## Rejected session inputs @@ -195,7 +195,7 @@ def test_step_rejects_missing_user_field( missing_field: str, ) -> None: """Verify step rejects a user condition missing a required tensor field.""" - user_condition = dict(_user_condition()) + user_condition = _user_condition().model_dump() del user_condition[missing_field] inference_input: Any = { "user_condition": user_condition, @@ -217,7 +217,7 @@ def test_step_rejects_missing_global_field( missing_field: str, ) -> None: """Verify step rejects a global condition missing a required tensor field.""" - global_condition = dict(_global_condition()) + global_condition = _global_condition().model_dump() del global_condition[missing_field] inference_input: Any = { "user_condition": _user_condition(), diff --git a/integrations/omnidreams/omnidreams/runtime/inference_session.py b/integrations/omnidreams/omnidreams/runtime/inference_session.py index 5bb7d1b60..aada9c0a1 100644 --- a/integrations/omnidreams/omnidreams/runtime/inference_session.py +++ b/integrations/omnidreams/omnidreams/runtime/inference_session.py @@ -30,9 +30,9 @@ InferenceUserCondition as BaseInferenceUserCondition, ) from omnidreams.pipeline import OmnidreamsPipeline, OmnidreamsPipelineCache -from pydantic import AfterValidator, TypeAdapter, ValidationInfo +from pydantic import AfterValidator, Field, TypeAdapter, ValidationInfo from torch import Tensor -from typing_extensions import NotRequired, TypedDict +from typing_extensions import TypedDict def _validate_tensor_shape( @@ -110,7 +110,7 @@ class InferenceGlobalCondition(BaseInferenceGlobalCondition): text_embeddings: _TextEmbeddingsTensor """Text embeddings ``[B, V, L, D]`` for the rollout prompts.""" - negative_text_embeddings: NotRequired[_TextEmbeddingsTensor | None] + negative_text_embeddings: _TextEmbeddingsTensor | None = None """Optional negative-prompt embeddings ``[B, V, L, D]`` used for CFG.""" image_embeddings: _ImageEmbeddingsTensor @@ -141,12 +141,12 @@ def _validate_condition_shapes( validation_info: ValidationInfo, ) -> InferenceInput: """Validate shape relationships between per-step and rollout conditions.""" - global_condition = inference_input.get("global_condition") - hdmap = inference_input["user_condition"]["hdmap"] + global_condition = inference_input.global_condition + hdmap = inference_input.user_condition.hdmap if global_condition is not None: - text_embeddings = global_condition["text_embeddings"] - image_embeddings = global_condition["image_embeddings"] + text_embeddings = global_condition.text_embeddings + image_embeddings = global_condition.image_embeddings batch_view_shapes = { "hdmap": tuple(hdmap.shape[:2]), "text_embeddings": tuple(text_embeddings.shape[:2]), @@ -158,7 +158,7 @@ def _validate_condition_shapes( f"[B, V] dimensions; got {batch_view_shapes}" ) - negative_text_embeddings = global_condition.get("negative_text_embeddings") + negative_text_embeddings = global_condition.negative_text_embeddings if ( negative_text_embeddings is not None and negative_text_embeddings.shape != text_embeddings.shape @@ -202,7 +202,7 @@ def _validate_condition_shapes( hdmap_resolution[0] // compression, hdmap_resolution[1] // compression, ) - image_embeddings = global_condition["image_embeddings"] + image_embeddings = global_condition.image_embeddings image_latent_resolution = ( int(image_embeddings.shape[-2]), int(image_embeddings.shape[-1]), @@ -222,8 +222,8 @@ def _validate_condition_shapes( _INFERENCE_INPUT_ADAPTER = TypeAdapter(_ValidatedInferenceInput) -_PRESENTATION_FPS = 30.0 -"""Presentation rate used by the 30 FPS OmniDreams model.""" +_PresentationFps: TypeAlias = Annotated[float, Field(gt=0, allow_inf_nan=False)] +_PRESENTATION_FPS_ADAPTER = TypeAdapter(_PresentationFps) class InferenceSession(BaseInferenceSession): @@ -238,12 +238,35 @@ class InferenceSession(BaseInferenceSession): autoregressive_index: int """Zero-based index assigned to the next inference step.""" + presentation_fps: _PresentationFps + """Frame rate used for output presentation timestamps.""" + _rollout_resolution: tuple[int, int] | None """HDMap pixel resolution fixed by the first successful rollout step.""" _presented_frame_count: int """Number of frames emitted on the current presentation timeline.""" + def __init__( + self, + pipeline: OmnidreamsPipeline, + *, + presentation_fps: _PresentationFps = 30.0, + ) -> None: + """Initialize the session with a presentation frame rate. + + Args: + pipeline: OmniDreams pipeline to drive. + presentation_fps: Frame rate for output presentation timestamps. + + Raises: + ValidationError: ``presentation_fps`` is not positive and finite. + """ + self.presentation_fps = _PRESENTATION_FPS_ADAPTER.validate_python( + presentation_fps + ) + super().__init__(pipeline) + def reset(self) -> None: """Reset the session to await rollout-wide embedding conditions.""" self.cache = None @@ -273,20 +296,18 @@ def step(self, inference_input: InferenceInput) -> FrameChunkOutput: rollout_resolution=self._rollout_resolution, ), ) - global_condition = inference_input.get("global_condition") + global_condition = inference_input.global_condition if self.cache is None: if global_condition is None: raise ValueError( "global_condition is required on the first step after reset()." ) self.cache = self.pipeline.initialize_cache_from_embeddings( - text_embeddings=global_condition["text_embeddings"], - image_embeddings=global_condition["image_embeddings"], - negative_text_embeddings=global_condition.get( - "negative_text_embeddings" - ), + text_embeddings=global_condition.text_embeddings, + image_embeddings=global_condition.image_embeddings, + negative_text_embeddings=global_condition.negative_text_embeddings, ) - hdmap = inference_input["user_condition"]["hdmap"] + hdmap = inference_input.user_condition.hdmap self._rollout_resolution = ( int(hdmap.shape[-2]), int(hdmap.shape[-1]), @@ -300,17 +321,17 @@ def step(self, inference_input: InferenceInput) -> FrameChunkOutput: video = self.pipeline.generate( autoregressive_index=self.autoregressive_index, cache=self.cache, - hdmap=inference_input["user_condition"]["hdmap"], + hdmap=inference_input.user_condition.hdmap, ) self.pipeline.finalize( autoregressive_index=self.autoregressive_index, cache=self.cache, ) - start_timestamp = self._presented_frame_count / _PRESENTATION_FPS + start_timestamp = self._presented_frame_count / self.presentation_fps self._presented_frame_count += int(video.shape[2]) self.autoregressive_index += 1 return FrameChunkOutput( value=video, start_timestamp=start_timestamp, - fps=_PRESENTATION_FPS, + fps=self.presentation_fps, ) diff --git a/integrations/omnidreams/tests/runtime/test_inference_session.py b/integrations/omnidreams/tests/runtime/test_inference_session.py index afc9de273..b8cb47d38 100644 --- a/integrations/omnidreams/tests/runtime/test_inference_session.py +++ b/integrations/omnidreams/tests/runtime/test_inference_session.py @@ -48,7 +48,7 @@ pytestmark = pytest.mark.ci_cpu -# ---------------------- Mock Pipeline ---------------------- # +## Mock Pipeline # OmnidreamsPipeline requires a Wan or TAEHV decoder. This lightweight TAEHV # subclass preserves that concrete contract without downloading decoder weights; @@ -151,7 +151,7 @@ def session(pipeline: OmnidreamsPipeline) -> InferenceSession: return InferenceSession(pipeline) -# ------------------- Condition Factories ------------------- # +## Condition Factories def _user_condition( @@ -173,18 +173,19 @@ def _global_condition( latent_height: int = 1, latent_width: int = 1, ) -> InferenceGlobalCondition: - condition = InferenceGlobalCondition( + negative_text_embeddings = ( + torch.full((1, 1, 2, 4), value + 2) if include_negative else None + ) + return InferenceGlobalCondition( text_embeddings=torch.full((1, 1, 2, 4), value), + negative_text_embeddings=negative_text_embeddings, image_embeddings=torch.full( (1, 1, 1, 16, latent_height, latent_width), value + 1 ), ) - if include_negative: - condition["negative_text_embeddings"] = torch.full((1, 1, 2, 4), value + 2) - return condition -# ------------------- Session Conditioning ------------------- # +## Session Conditioning def test_step_runs_actual_pipeline_with_global_conditions( @@ -216,9 +217,41 @@ def test_step_runs_actual_pipeline_with_global_conditions( assert torch.isfinite(output.value).all() assert output.start_timestamp == pytest.approx(0.0) assert output.fps == pytest.approx(30.0) + assert output.fps == session.presentation_fps assert output.frame_present_time == pytest.approx(1.0 / 30.0) +def test_step_uses_session_presentation_fps( + pipeline: OmnidreamsPipeline, +) -> None: + """Verify output timing uses the session-specific presentation rate.""" + session = InferenceSession(pipeline, presentation_fps=24.0) + + output = session.step( + InferenceInput( + user_condition=_user_condition(2.0), + global_condition=_global_condition(3.0), + ) + ) + + assert session.presentation_fps == pytest.approx(24.0) + assert output.fps == pytest.approx(24.0) + assert output.frame_present_time == pytest.approx(1.0 / 24.0) + + +@pytest.mark.parametrize( + "presentation_fps", + [0.0, -1.0, float("nan"), float("inf")], +) +def test_session_rejects_invalid_presentation_fps( + pipeline: OmnidreamsPipeline, + presentation_fps: float, +) -> None: + """Verify sessions reject non-positive and non-finite presentation rates.""" + with pytest.raises(ValidationError): + InferenceSession(pipeline, presentation_fps=presentation_fps) + + def test_step_reuses_actual_pipeline_cache_with_different_user_conditions( session: InferenceSession, ) -> None: @@ -324,7 +357,7 @@ def test_step_rejects_global_conditions_during_active_rollout( assert session.autoregressive_index == 1 -# ---------------- Pydantic Schema Validation ---------------- # +## Pydantic Schema Validation @pytest.mark.parametrize( @@ -375,8 +408,8 @@ def test_step_validates_omnidreams_condition_tensor_ranks( """Verify Pydantic rejects condition tensors with the wrong rank.""" # Begin with a fully valid input and replace one field so the reported # Pydantic location identifies only the dimension under test. - user_condition = dict(_user_condition(1.0)) - global_condition = dict(_global_condition(2.0, include_negative=True)) + user_condition = _user_condition(1.0).model_dump() + global_condition = _global_condition(2.0, include_negative=True).model_dump() condition = user_condition if field_name == "hdmap" else global_condition condition[field_name] = torch.zeros((1,) * (expected_rank - 1)) inference_input: Any = { @@ -439,8 +472,8 @@ def test_step_validates_omnidreams_condition_tensor_shapes( ) -> None: """Verify Pydantic rejects fixed-axis and empty condition shapes.""" # Keep every other field valid to isolate fixed-axis and empty-axis checks. - user_condition = dict(_user_condition(1.0)) - global_condition = dict(_global_condition(2.0, include_negative=True)) + user_condition = _user_condition(1.0).model_dump() + global_condition = _global_condition(2.0, include_negative=True).model_dump() condition = user_condition if field_name == "hdmap" else global_condition condition[field_name] = invalid_tensor inference_input: Any = { @@ -493,8 +526,8 @@ def test_step_validates_condition_shape_relationships( ) -> None: """Verify Pydantic validates shapes shared by multiple conditions.""" # These tensors are individually valid; only their shared dimensions differ. - user_condition = dict(_user_condition(1.0)) - global_condition = dict(_global_condition(2.0, include_negative=True)) + user_condition = _user_condition(1.0).model_dump() + global_condition = _global_condition(2.0, include_negative=True).model_dump() condition = user_condition if field_name == "hdmap" else global_condition condition[field_name] = invalid_tensor inference_input: Any = { @@ -510,7 +543,7 @@ def test_step_validates_condition_shape_relationships( assert session.autoregressive_index == 0 -# ------------ Pipeline-aware Pydantic Validation ------------ # +## Pipeline-aware Pydantic Validation def test_step_validates_hdmap_resolution_alignment_with_pipeline( From 4950f55af46291f76be6dd054e8f70cc4a610c46 Mon Sep 17 00:00:00 2001 From: Fangjun Zhou Date: Fri, 7 Aug 2026 11:11:16 -0700 Subject: [PATCH 24/30] Add video output handler --- .../handler/video_output_handler.py | 175 ++++++++++++++++++ .../runtime/test_video_output_handler.py | 173 +++++++++++++++++ 2 files changed, 348 insertions(+) create mode 100644 flashdreams/flashdreams/runtime/builtin/inference_output/handler/video_output_handler.py create mode 100644 flashdreams/tests/runtime/test_video_output_handler.py diff --git a/flashdreams/flashdreams/runtime/builtin/inference_output/handler/video_output_handler.py b/flashdreams/flashdreams/runtime/builtin/inference_output/handler/video_output_handler.py new file mode 100644 index 000000000..db703215c --- /dev/null +++ b/flashdreams/flashdreams/runtime/builtin/inference_output/handler/video_output_handler.py @@ -0,0 +1,175 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Video artifact handler for generated frame chunks.""" + +from __future__ import annotations + +import math +from pathlib import Path + +import torch +from flashdreams.infra.runner_io import ( + DEFAULT_RUNNER_INSTALL_HINT, + write_video_tensor, +) +from flashdreams.runtime.builtin.inference_output.frame_chunk import ( + FrameChunkOutput, +) +from flashdreams.runtime.output_system import InferenceOutputHandler +from pydantic import validate_call +from torch import Tensor + +_TIMESTAMP_ABS_TOLERANCE_SECONDS = 1e-6 +"""Tolerance for accumulated floating-point presentation timestamps.""" + + +class VideoOutputHandler(InferenceOutputHandler): + """Collect frame chunks and write a horizontally tiled video artifact.""" + + artifact_path: Path + """Path written when :meth:`finish` is called.""" + + _chunks: list[Tensor] + """CPU frame chunks awaiting artifact creation.""" + + _chunk_shape: tuple[int, int, int, int, int] | None + """Stable non-temporal ``[B, V, C, H, W]`` shape, if established.""" + + _fps: float | None + """Presentation frame rate established by the first chunk.""" + + _next_timestamp: float | None + """Expected presentation timestamp for the next chunk.""" + + _finished: bool + """Whether the artifact has been written successfully.""" + + def __init__(self, artifact_path: str | Path) -> None: + """Initialize an empty video artifact handler. + + Args: + artifact_path: Destination passed to the shared FFmpeg video writer. + """ + self.artifact_path = Path(artifact_path) + self._chunks = [] + self._chunk_shape = None + self._fps = None + self._next_timestamp = None + self._finished = False + + @validate_call + def __call__(self, inference_output: FrameChunkOutput) -> None: + """Collect one frame chunk for the output artifact. + + Args: + inference_output: RGB frames in ``[B, V, T, C, H, W]`` layout. + + Raises: + RuntimeError: :meth:`finish` has already written the artifact. + ValueError: The chunk shape, frame rate, or timestamp is inconsistent + with the output stream. + """ + if self._finished: + raise RuntimeError("cannot receive frame chunks after finish()") + + chunk = inference_output.value + if chunk.ndim != 6: + raise ValueError( + "expected a rank-6 frame chunk in [B, V, T, C, H, W] layout; " + f"got rank {chunk.ndim} with shape {tuple(chunk.shape)}" + ) + batch, views, frames, channels, height, width = map(int, chunk.shape) + if batch != 1 or channels != 3: + raise ValueError( + "expected frame chunk shape [B=1, V, T, C=3, H, W]; " + f"got {tuple(chunk.shape)}" + ) + if any(size <= 0 for size in (views, frames, height, width)): + raise ValueError( + "expected every frame chunk axis to be positive; " + f"got {tuple(chunk.shape)}" + ) + + chunk_shape = (batch, views, channels, height, width) + if self._chunk_shape is not None and chunk_shape != self._chunk_shape: + raise ValueError( + "expected frame chunks to share [B, V, C, H, W] dimensions; " + f"expected {self._chunk_shape}, got {chunk_shape}" + ) + + fps = inference_output.fps + if self._fps is not None and not math.isclose( + fps, + self._fps, + rel_tol=1e-9, + abs_tol=0.0, + ): + raise ValueError(f"expected frame chunk fps {self._fps}; got {fps}") + if self._next_timestamp is not None and not math.isclose( + inference_output.start_timestamp, + self._next_timestamp, + rel_tol=0.0, + abs_tol=_TIMESTAMP_ABS_TOLERANCE_SECONDS, + ): + raise ValueError( + "expected contiguous frame chunk timestamp " + f"{self._next_timestamp}; got {inference_output.start_timestamp}" + ) + + self._chunks.append(chunk.detach().cpu()) + self._chunk_shape = chunk_shape + self._fps = fps + self._next_timestamp = inference_output.start_timestamp + frames / fps + + def finish(self) -> Path: + """Finish receiving chunks and write the video artifact. + + Returns: + Path written by the shared video writer. Repeated calls return the + same path without writing the artifact again. + + Raises: + ValueError: No frame chunks have been received. + RuntimeError: Internal stream metadata is incomplete. + """ + if self._finished: + return self.artifact_path + if not self._chunks: + raise ValueError("cannot finish a video output without frame chunks") + if self._fps is None: + raise RuntimeError("video output frame rate was not initialized") + + video = torch.cat(self._chunks, dim=2) + _, views, frames, channels, height, width = map(int, video.shape) + canvas = ( + video[0] + .permute(1, 3, 0, 4, 2) + .reshape(frames, height, views * width, channels) + ) + artifact_path = write_video_tensor( + canvas, + self.artifact_path, + fps=self._fps, + layout="thwc", + install_hint=DEFAULT_RUNNER_INSTALL_HINT, + ) + self.artifact_path = artifact_path + self._chunks.clear() + self._finished = True + return artifact_path + + +__all__ = ["VideoOutputHandler"] diff --git a/flashdreams/tests/runtime/test_video_output_handler.py b/flashdreams/tests/runtime/test_video_output_handler.py new file mode 100644 index 000000000..0552af2d4 --- /dev/null +++ b/flashdreams/tests/runtime/test_video_output_handler.py @@ -0,0 +1,173 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""CPU tests for the builtin video output handler.""" + +from pathlib import Path + +import pytest +import torch +from flashdreams.runtime.builtin.inference_output.frame_chunk import ( + FrameChunkOutput, +) +from flashdreams.runtime.builtin.inference_output.handler import ( + video_output_handler, +) +from flashdreams.runtime.builtin.inference_output.handler.video_output_handler import ( + VideoOutputHandler, +) +from torch import Tensor + +pytestmark = pytest.mark.ci_cpu + + +def _frame_chunk( + value: Tensor, + *, + start_timestamp: float = 0.0, + fps: float = 24.0, +) -> FrameChunkOutput: + """Build a frame chunk with presentation metadata.""" + return FrameChunkOutput( + value=value, + start_timestamp=start_timestamp, + fps=fps, + ) + + +## Artifact writing + + +def test_video_output_handler_collects_tiles_and_writes_chunks( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Verify finish concatenates time and tiles views like OmniDreams.""" + written_videos: list[Tensor] = [] + written_paths: list[Path] = [] + written_options: list[tuple[float, str, str]] = [] + + def write_video_tensor( + video: Tensor, + path: str | Path, + *, + fps: float, + layout: str, + install_hint: str, + ) -> Path: + written_videos.append(video) + written_paths.append(Path(path)) + written_options.append((float(fps), layout, install_hint)) + return Path(path) + + monkeypatch.setattr( + video_output_handler, + "write_video_tensor", + write_video_tensor, + ) + first = torch.empty(1, 2, 2, 3, 2, 3) + first[:, 0, 0].fill_(-1.0) + first[:, 1, 0].fill_(-0.5) + first[:, 0, 1].fill_(0.0) + first[:, 1, 1].fill_(0.5) + second = torch.empty(1, 2, 1, 3, 2, 3) + second[:, 0, 0].fill_(0.75) + second[:, 1, 0].fill_(1.0) + artifact_path = tmp_path / "nested" / "artifact.mp4" + handler = VideoOutputHandler(artifact_path) + + assert handler(_frame_chunk(first)) is None + assert handler(_frame_chunk(second, start_timestamp=2.0 / 24.0)) is None + assert handler.finish() == artifact_path + assert handler.finish() == artifact_path + + # One write contains all temporal chunks. Each frame places view zero to the + # left of view one, matching the OmniDreams runner's THWC canvas. + assert written_paths == [artifact_path] + assert len(written_videos) == 1 + canvas = written_videos[0] + assert canvas.shape == (3, 2, 6, 3) + torch.testing.assert_close(canvas[0, :, :3], torch.full((2, 3, 3), -1.0)) + torch.testing.assert_close(canvas[0, :, 3:], torch.full((2, 3, 3), -0.5)) + torch.testing.assert_close(canvas[1, :, :3], torch.zeros(2, 3, 3)) + torch.testing.assert_close(canvas[1, :, 3:], torch.full((2, 3, 3), 0.5)) + torch.testing.assert_close(canvas[2, :, :3], torch.full((2, 3, 3), 0.75)) + torch.testing.assert_close(canvas[2, :, 3:], torch.ones(2, 3, 3)) + assert written_options[0][:2] == (24.0, "thwc") + assert written_options[0][2] + + +## Stream validation and lifecycle + + +def test_video_output_handler_rejects_empty_finish(tmp_path: Path) -> None: + """Verify finish requires at least one received frame chunk.""" + handler = VideoOutputHandler(tmp_path / "empty.mp4") + + with pytest.raises(ValueError, match="without frame chunks"): + handler.finish() + + +def test_video_output_handler_rejects_invalid_or_inconsistent_chunks( + tmp_path: Path, +) -> None: + """Verify stream shape, frame rate, and timestamp invariants.""" + handler = VideoOutputHandler(tmp_path / "invalid.mp4") + + with pytest.raises(ValueError, match="rank-6"): + handler(_frame_chunk(torch.zeros(1, 1, 3, 2, 2))) + + handler(_frame_chunk(torch.zeros(1, 1, 2, 3, 2, 2))) + with pytest.raises(ValueError, match="share.*dimensions"): + handler( + _frame_chunk( + torch.zeros(1, 1, 1, 3, 3, 2), + start_timestamp=2.0 / 24.0, + ) + ) + with pytest.raises(ValueError, match="fps 24.0"): + handler( + _frame_chunk( + torch.zeros(1, 1, 1, 3, 2, 2), + start_timestamp=2.0 / 24.0, + fps=30.0, + ) + ) + with pytest.raises(ValueError, match="contiguous.*timestamp"): + handler( + _frame_chunk( + torch.zeros(1, 1, 1, 3, 2, 2), + start_timestamp=1.0, + ) + ) + + +def test_video_output_handler_rejects_chunks_after_finish( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Verify a successfully finished handler cannot receive more chunks.""" + monkeypatch.setattr( + video_output_handler, + "write_video_tensor", + lambda _video, path, **_kwargs: Path(path), + ) + handler = VideoOutputHandler(tmp_path / "finished.mp4") + chunk = _frame_chunk(torch.zeros(1, 1, 1, 3, 2, 2)) + handler(chunk) + handler.finish() + + with pytest.raises(RuntimeError, match="after finish"): + handler(chunk) From fac23bcb0d532a9dbdd1d46d2f4a74403bd66c92 Mon Sep 17 00:00:00 2001 From: Fangjun Zhou Date: Fri, 7 Aug 2026 11:17:26 -0700 Subject: [PATCH 25/30] Add hdmap input handler --- .../runtime/user_input/hdmap_input_handler.py | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 integrations/omnidreams/omnidreams/runtime/user_input/hdmap_input_handler.py diff --git a/integrations/omnidreams/omnidreams/runtime/user_input/hdmap_input_handler.py b/integrations/omnidreams/omnidreams/runtime/user_input/hdmap_input_handler.py new file mode 100644 index 000000000..794485a5c --- /dev/null +++ b/integrations/omnidreams/omnidreams/runtime/user_input/hdmap_input_handler.py @@ -0,0 +1,131 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""HDMap video input handler for OmniDreams inference.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + +import torch +from flashdreams.infra.runner_io import ( + DEFAULT_RUNNER_INSTALL_HINT, + read_video_rgb, + rgb_video_to_normalized_tensor, +) +from flashdreams.runtime.input_system import UserInputHandler +from omnidreams.runtime.inference_session import InferenceUserCondition + + +class HDMapInputHandler(UserInputHandler): + """Iterate over an HDMap video as model-ready inference conditions. + + Args: + hdmap_video_path: Path to an RGB HDMap video. + get_num_frames: Optional function mapping an autoregressive step index to + its required number of pixel frames. Pass the pipeline's + get_num_frames method when feeding an OmniDreams inference session. + When omitted, each call returns one frame. + device: Device on which returned HDMap tensors are stored. + dtype: Floating-point dtype used for normalized HDMap pixels. + + Raises: + TypeError: If dtype is not a floating-point dtype. + ValueError: If the decoded video is empty or malformed. + """ + + def __init__( + self, + hdmap_video_path: str | Path, + *, + get_num_frames: Callable[[int], int] | None = None, + device: torch.device | str = "cpu", + dtype: torch.dtype = torch.float32, + ) -> None: + """Load and normalize the HDMap video for iterative consumption.""" + if not dtype.is_floating_point: + raise TypeError(f"dtype must be floating point; got {dtype}") + + self.hdmap_video_path = Path(hdmap_video_path) + video = read_video_rgb( + self.hdmap_video_path, + install_hint=DEFAULT_RUNNER_INSTALL_HINT, + ) + if video.ndim != 4 or video.shape[-1] != 3: + raise ValueError( + "expected an RGB HDMap video with shape [T, H, W, 3]; " + f"got {tuple(video.shape)}" + ) + if any(size <= 0 for size in video.shape): + raise ValueError( + f"HDMap video must have non-empty dimensions: {self.hdmap_video_path}" + ) + + hdmap = rgb_video_to_normalized_tensor( + video, + device=torch.device(device), + dtype=dtype, + ) + # A path represents one rollout and one camera view. Each call slices the + # temporal axis while retaining the condition's [B, V, T, C, H, W] layout. + self._hdmap = hdmap.unsqueeze(0).unsqueeze(0) + self._get_num_frames = get_num_frames or _one_frame_per_condition + self._autoregressive_index = 0 + self._next_frame_index = 0 + + def __call__(self) -> InferenceUserCondition: + """Return the next complete HDMap condition. + + Returns: + Normalized HDMap pixels for the next inference step. + + Raises: + TypeError: If the frame-count provider does not return an integer. + ValueError: If the frame-count provider returns a non-positive count. + StopIteration: If no complete condition remains in the video. + """ + num_frames = self._get_num_frames(self._autoregressive_index) + if isinstance(num_frames, bool) or not isinstance(num_frames, int): + raise TypeError( + "get_num_frames must return an integer; " + f"got {num_frames!r} at autoregressive index " + f"{self._autoregressive_index}" + ) + if num_frames <= 0: + raise ValueError( + "get_num_frames must return a positive value; " + f"got {num_frames} at autoregressive index " + f"{self._autoregressive_index}" + ) + + end_frame_index = self._next_frame_index + num_frames + if end_frame_index > self._hdmap.shape[2]: + raise StopIteration + + condition = InferenceUserCondition( + hdmap=self._hdmap[:, :, self._next_frame_index : end_frame_index] + ) + self._next_frame_index = end_frame_index + self._autoregressive_index += 1 + return condition + + +def _one_frame_per_condition(_autoregressive_index: int) -> int: + """Return the path-only handler's single-frame chunk size.""" + return 1 + + +__all__ = ["HDMapInputHandler"] From 859bbed20f065b41d2866c032a2b60b2a1f33467 Mon Sep 17 00:00:00 2001 From: Fangjun Zhou Date: Fri, 7 Aug 2026 16:26:00 -0700 Subject: [PATCH 26/30] Rename private fields --- .../flashdreams/runtime/inference_runtime.py | 34 ++++++++-------- .../flashdreams/runtime/inference_session.py | 8 ++-- .../tests/runtime/test_inference_runtime.py | 26 ++++++------ .../omnidreams/runtime/inference_session.py | 20 +++++----- .../tests/runtime/test_inference_session.py | 40 +++++++++---------- 5 files changed, 64 insertions(+), 64 deletions(-) diff --git a/flashdreams/flashdreams/runtime/inference_runtime.py b/flashdreams/flashdreams/runtime/inference_runtime.py index 5423c1f3c..dc9ae7c15 100644 --- a/flashdreams/flashdreams/runtime/inference_runtime.py +++ b/flashdreams/flashdreams/runtime/inference_runtime.py @@ -47,24 +47,24 @@ class InferenceRuntime(ABC, Generic[SessionT]): Subclasses implement :meth:`warmup` for integration-specific model execution. """ - # ---------------- PyTorch Distributed State ---------------- # + ## Distributed state - local_rank: int + _local_rank: int """Process-local rank; ``0`` outside distributed runs.""" - global_rank: int + _global_rank: int """Global process rank; ``0`` outside distributed runs.""" - world_size: int + _world_size: int """Number of distributed processes; ``1`` outside distributed runs.""" - is_rank_zero: bool + _is_rank_zero: bool """Whether this process is the global rank-zero process.""" - pipeline: StreamInferencePipeline + _pipeline: StreamInferencePipeline """Pipeline constructed once and shared by all sessions.""" - session_type: type[SessionT] + _session_type: type[SessionT] """Concrete session type created by :meth:`create_session`.""" def __init__( @@ -86,17 +86,17 @@ def __init__( # Snapshot launch metadata for rank-gated runtime work while preserving # stable single-process defaults for ordinary Python processes. if torch.distributed.is_initialized(): - self.local_rank = int(os.environ.get("LOCAL_RANK", "0")) - self.global_rank = torch.distributed.get_rank() - self.world_size = torch.distributed.get_world_size() + self._local_rank = int(os.environ.get("LOCAL_RANK", "0")) + self._global_rank = torch.distributed.get_rank() + self._world_size = torch.distributed.get_world_size() else: - self.local_rank = 0 - self.global_rank = 0 - self.world_size = 1 - self.is_rank_zero = self.global_rank == 0 + self._local_rank = 0 + self._global_rank = 0 + self._world_size = 1 + self._is_rank_zero = self._global_rank == 0 - self.pipeline = pipeline_config.setup() - self.session_type = session_type + self._pipeline = pipeline_config.setup() + self._session_type = session_type def create_session(self) -> SessionT: """Create a session backed by the shared pipeline. @@ -104,7 +104,7 @@ def create_session(self) -> SessionT: Returns: Fresh session with its own pipeline cache. """ - return self.session_type(self.pipeline) + return self._session_type(self._pipeline) @abstractmethod def warmup(self) -> None: diff --git a/flashdreams/flashdreams/runtime/inference_session.py b/flashdreams/flashdreams/runtime/inference_session.py index da072ba2a..57fad5a79 100644 --- a/flashdreams/flashdreams/runtime/inference_session.py +++ b/flashdreams/flashdreams/runtime/inference_session.py @@ -73,10 +73,10 @@ class InferenceSession(ABC, Generic[PipelineT]): Subclasses implement :meth:`step` for integration-specific rollout I/O. """ - pipeline: PipelineT + _pipeline: PipelineT """Pipeline owned and driven by the inference session.""" - cache: StreamInferencePipelineCache + _cache: StreamInferencePipelineCache """Current per-session cache initialized by the pipeline.""" def __init__(self, pipeline: PipelineT) -> None: @@ -85,12 +85,12 @@ def __init__(self, pipeline: PipelineT) -> None: Args: pipeline: Pipeline to drive. """ - self.pipeline = pipeline + self._pipeline = pipeline self.reset() def reset(self) -> None: """Reset the session with a fresh pipeline cache.""" - self.cache = self.pipeline.initialize_cache() + self._cache = self._pipeline.initialize_cache() @abstractmethod @validate_call diff --git a/flashdreams/tests/runtime/test_inference_runtime.py b/flashdreams/tests/runtime/test_inference_runtime.py index 89e20db55..1e9533e7c 100644 --- a/flashdreams/tests/runtime/test_inference_runtime.py +++ b/flashdreams/tests/runtime/test_inference_runtime.py @@ -128,7 +128,7 @@ def runtime_bundle( ## Runtime ownership behavior -def test_runtime_sets_up_and_holds_pipeline( +def test_runtime_sets_up_and_privately_holds_pipeline( runtime_bundle: tuple[ _MockInferenceRuntime, _MockStreamInferencePipelineConfig, @@ -139,15 +139,15 @@ def test_runtime_sets_up_and_holds_pipeline( runtime, pipeline_config, pipeline = runtime_bundle assert pipeline_config.setup_calls == 1 - assert runtime.pipeline is pipeline - assert runtime.session_type is _MockInferenceSession - assert runtime.local_rank == 0 - assert runtime.global_rank == 0 - assert runtime.world_size == 1 - assert runtime.is_rank_zero + assert runtime._pipeline is pipeline + assert runtime._session_type is _MockInferenceSession + assert runtime._local_rank == 0 + assert runtime._global_rank == 0 + assert runtime._world_size == 1 + assert runtime._is_rank_zero -def test_create_session_shares_pipeline_and_initializes_fresh_cache( +def test_create_session_privately_shares_pipeline_and_initializes_fresh_cache( runtime_bundle: tuple[ _MockInferenceRuntime, _MockStreamInferencePipelineConfig, @@ -162,9 +162,9 @@ def test_create_session_shares_pipeline_and_initializes_fresh_cache( assert pipeline_config.setup_calls == 1 assert first_session is not second_session - assert first_session.pipeline is pipeline - assert second_session.pipeline is pipeline - assert isinstance(first_session.cache, _MockStreamInferencePipelineCache) - assert isinstance(second_session.cache, _MockStreamInferencePipelineCache) - assert first_session.cache is not second_session.cache + assert first_session._pipeline is pipeline + assert second_session._pipeline is pipeline + assert isinstance(first_session._cache, _MockStreamInferencePipelineCache) + assert isinstance(second_session._cache, _MockStreamInferencePipelineCache) + assert first_session._cache is not second_session._cache assert pipeline.initialize_cache_calls == 2 diff --git a/integrations/omnidreams/omnidreams/runtime/inference_session.py b/integrations/omnidreams/omnidreams/runtime/inference_session.py index aada9c0a1..5195f8346 100644 --- a/integrations/omnidreams/omnidreams/runtime/inference_session.py +++ b/integrations/omnidreams/omnidreams/runtime/inference_session.py @@ -229,10 +229,10 @@ def _validate_condition_shapes( class InferenceSession(BaseInferenceSession): """Stateful OmniDreams inference session backed by a per-rollout cache.""" - pipeline: OmnidreamsPipeline + _pipeline: OmnidreamsPipeline """OmniDreams pipeline shared with the inference runtime.""" - cache: OmnidreamsPipelineCache | None + _cache: OmnidreamsPipelineCache | None """Per-rollout cache; ``None`` until global conditions initialize it.""" autoregressive_index: int @@ -269,7 +269,7 @@ def __init__( def reset(self) -> None: """Reset the session to await rollout-wide embedding conditions.""" - self.cache = None + self._cache = None self.autoregressive_index = 0 self._rollout_resolution = None self._presented_frame_count = 0 @@ -291,18 +291,18 @@ def step(self, inference_input: InferenceInput) -> FrameChunkOutput: inference_input = _INFERENCE_INPUT_ADAPTER.validate_python( inference_input, context=_InferenceValidationContext( - pipeline=self.pipeline, + pipeline=self._pipeline, autoregressive_index=self.autoregressive_index, rollout_resolution=self._rollout_resolution, ), ) global_condition = inference_input.global_condition - if self.cache is None: + if self._cache is None: if global_condition is None: raise ValueError( "global_condition is required on the first step after reset()." ) - self.cache = self.pipeline.initialize_cache_from_embeddings( + self._cache = self._pipeline.initialize_cache_from_embeddings( text_embeddings=global_condition.text_embeddings, image_embeddings=global_condition.image_embeddings, negative_text_embeddings=global_condition.negative_text_embeddings, @@ -318,14 +318,14 @@ def step(self, inference_input: InferenceInput) -> FrameChunkOutput: "global_condition can only be supplied on the first step after reset()." ) - video = self.pipeline.generate( + video = self._pipeline.generate( autoregressive_index=self.autoregressive_index, - cache=self.cache, + cache=self._cache, hdmap=inference_input.user_condition.hdmap, ) - self.pipeline.finalize( + self._pipeline.finalize( autoregressive_index=self.autoregressive_index, - cache=self.cache, + cache=self._cache, ) start_timestamp = self._presented_frame_count / self.presentation_fps self._presented_frame_count += int(video.shape[2]) diff --git a/integrations/omnidreams/tests/runtime/test_inference_session.py b/integrations/omnidreams/tests/runtime/test_inference_session.py index b8cb47d38..e1f2666dc 100644 --- a/integrations/omnidreams/tests/runtime/test_inference_session.py +++ b/integrations/omnidreams/tests/runtime/test_inference_session.py @@ -206,12 +206,12 @@ def test_step_runs_actual_pipeline_with_global_conditions( # Exact type equality prevents a test double from silently replacing the # integration pipeline while preserving isinstance compatibility. assert type(pipeline) is OmnidreamsPipeline - assert session.cache is not None + assert session._cache is not None # Pipeline caches record the last generated index; the session index points # to the next step that will be generated. - assert session.cache.autoregressive_index == 0 - assert isinstance(session.cache.encoder_cache, PixelShuffleVAEEncoderCache) - assert session.cache.encoder_cache.autoregressive_index == 0 + assert session._cache.autoregressive_index == 0 + assert isinstance(session._cache.encoder_cache, PixelShuffleVAEEncoderCache) + assert session._cache.encoder_cache.autoregressive_index == 0 assert session.autoregressive_index == 1 assert output.value.shape == (1, 1, 1, 3, 1, 1) assert torch.isfinite(output.value).all() @@ -262,14 +262,14 @@ def test_step_reuses_actual_pipeline_cache_with_different_user_conditions( global_condition=_global_condition(2.0), ) ) - cache = session.cache + cache = session._cache second_output = session.step( InferenceInput(user_condition=_user_condition(7.0, num_frames=4)) ) # The second user condition advances the same rollout cache rather than # rebuilding global text/image conditioning. - assert session.cache is cache + assert session._cache is cache assert cache is not None assert cache.autoregressive_index == 1 assert isinstance(cache.encoder_cache, PixelShuffleVAEEncoderCache) @@ -295,7 +295,7 @@ def test_step_uses_different_global_conditions_after_reset( global_condition=_global_condition(1.0), ) ) - first_cache = session.cache + first_cache = session._cache assert first_cache is not None first_image = first_cache.transformer_cache.image.clone() @@ -313,7 +313,7 @@ def test_step_uses_different_global_conditions_after_reset( ) ) - second_cache = session.cache + second_cache = session._cache assert second_cache is not None assert second_cache is not first_cache assert not torch.equal(second_cache.transformer_cache.image, first_image) @@ -340,7 +340,7 @@ def test_step_rejects_global_conditions_during_active_rollout( global_condition=_global_condition(2.0), ) ) - cache = session.cache + cache = session._cache with pytest.raises(ValueError, match="can only be supplied on the first step"): session.step( @@ -351,7 +351,7 @@ def test_step_rejects_global_conditions_during_active_rollout( ) # Rejection happens before pipeline generation and leaves both indices intact. - assert session.cache is cache + assert session._cache is cache assert cache is not None assert cache.autoregressive_index == 0 assert session.autoregressive_index == 1 @@ -425,7 +425,7 @@ def test_step_validates_omnidreams_condition_tensor_ranks( ] assert len(matching_errors) == 1 assert f"rank-{expected_rank}" in matching_errors[0]["msg"] - assert session.cache is None + assert session._cache is None assert session.autoregressive_index == 0 @@ -489,7 +489,7 @@ def test_step_validates_omnidreams_condition_tensor_shapes( ] assert len(matching_errors) == 1 assert expected_message in matching_errors[0]["msg"] - assert session.cache is None + assert session._cache is None assert session.autoregressive_index == 0 @@ -539,7 +539,7 @@ def test_step_validates_condition_shape_relationships( session.step(inference_input) assert expected_message in str(exc_info.value) - assert session.cache is None + assert session._cache is None assert session.autoregressive_index == 0 @@ -560,7 +560,7 @@ def test_step_validates_hdmap_resolution_alignment_with_pipeline( session.step(inference_input) assert "must be divisible by 8" in str(exc_info.value) - assert session.cache is None + assert session._cache is None assert session.autoregressive_index == 0 @@ -578,7 +578,7 @@ def test_step_validates_image_embedding_resolution_against_hdmap( session.step(inference_input) assert "expected image_embeddings latent resolution (2, 1)" in str(exc_info.value) - assert session.cache is None + assert session._cache is None assert session.autoregressive_index == 0 @@ -598,7 +598,7 @@ def test_step_validates_first_hdmap_frame_count_with_pipeline( assert "expected hdmap T=1 at autoregressive index 0; got T=4" in str( exc_info.value ) - assert session.cache is None + assert session._cache is None assert session.autoregressive_index == 0 @@ -612,7 +612,7 @@ def test_step_validates_later_hdmap_frame_count_with_pipeline( global_condition=_global_condition(2.0), ) ) - cache = session.cache + cache = session._cache # Steady-state PixelShuffle/TAEHV geometry requires four input frames. with pytest.raises(ValidationError) as exc_info: @@ -621,7 +621,7 @@ def test_step_validates_later_hdmap_frame_count_with_pipeline( assert "expected hdmap T=4 at autoregressive index 1; got T=1" in str( exc_info.value ) - assert session.cache is cache + assert session._cache is cache assert cache is not None assert cache.autoregressive_index == 0 assert session.autoregressive_index == 1 @@ -637,7 +637,7 @@ def test_step_validates_hdmap_resolution_is_stable_during_rollout( global_condition=_global_condition(2.0), ) ) - cache = session.cache + cache = session._cache # 16x8 is independently aligned; only changing the active rollout size is invalid. with pytest.raises(ValidationError) as exc_info: @@ -646,7 +646,7 @@ def test_step_validates_hdmap_resolution_is_stable_during_rollout( ) assert "expected hdmap resolution (8, 8)" in str(exc_info.value) - assert session.cache is cache + assert session._cache is cache assert cache is not None assert cache.autoregressive_index == 0 assert session.autoregressive_index == 1 From e0c63a891c2810df311d23f936348f452df2a279 Mon Sep 17 00:00:00 2001 From: Fangjun Zhou Date: Fri, 7 Aug 2026 17:32:05 -0700 Subject: [PATCH 27/30] Update test mock --- flashdreams/tests/runtime/mocks.py | 166 ++++++++++++++++++ .../tests/runtime/test_inference_runtime.py | 107 +++-------- .../tests/runtime/test_inference_session.py | 122 ++----------- 3 files changed, 212 insertions(+), 183 deletions(-) create mode 100644 flashdreams/tests/runtime/mocks.py diff --git a/flashdreams/tests/runtime/mocks.py b/flashdreams/tests/runtime/mocks.py new file mode 100644 index 000000000..2931ecac7 --- /dev/null +++ b/flashdreams/tests/runtime/mocks.py @@ -0,0 +1,166 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Shared inference pipeline and session test doubles.""" + +from __future__ import annotations + +from typing import TypeAlias + +from flashdreams.infra.pipeline import ( + StreamInferencePipeline, + StreamInferencePipelineCache, + StreamInferencePipelineConfig, +) +from flashdreams.runtime.inference_session import ( + InferenceGlobalCondition, + InferenceInput, + InferenceOutput, + InferenceSession, + InferenceUserCondition, +) +from pydantic import validate_call +from torch import Tensor, nn + + +class MockStreamInferencePipelineCache(StreamInferencePipelineCache): + """In-memory pipeline cache without model-specific state.""" + + def __init__(self) -> None: + """Initialize an empty cache.""" + + +class MockStreamInferencePipeline(StreamInferencePipeline): + """Pipeline test double that records cache initialization.""" + + initialize_cache_calls: int + """Number of caches initialized for inference sessions.""" + + def __init__(self) -> None: + """Initialize the pipeline without model components.""" + nn.Module.__init__(self) + self.initialize_cache_calls = 0 + + def initialize_cache( + self, + transformer_context: object | None = None, + encoder_context: object | None = None, + decoder_context: object | None = None, + ) -> MockStreamInferencePipelineCache: + """Create and record a fresh inference-session cache.""" + del transformer_context, encoder_context, decoder_context + self.initialize_cache_calls += 1 + return MockStreamInferencePipelineCache() + + +class MockStreamInferencePipelineConfig(StreamInferencePipelineConfig): + """Pipeline config test double that returns a configured pipeline.""" + + pipeline: MockStreamInferencePipeline + """Pipeline returned by :meth:`setup`.""" + + setup_calls: int + """Number of times :meth:`setup` has been called.""" + + def __init__(self, pipeline: MockStreamInferencePipeline) -> None: + """Initialize with the pipeline returned by :meth:`setup`.""" + self.pipeline = pipeline + self.setup_calls = 0 + + def setup(self) -> MockStreamInferencePipeline: + """Return the configured pipeline and record the setup call.""" + self.setup_calls += 1 + return self.pipeline + + +class MockInferenceSession(InferenceSession[MockStreamInferencePipeline]): + """Inference session test double that records inputs and outputs.""" + + def __init__( + self, + pipeline: MockStreamInferencePipeline | None = None, + ) -> None: + """Initialize with a supplied or fresh mock pipeline.""" + self.inputs: list[InferenceInput] = [] + self.outputs: list[InferenceOutput] = [] + super().__init__( + pipeline if pipeline is not None else MockStreamInferencePipeline() + ) + + def step(self, inference_input: InferenceInput) -> InferenceOutput: + """Record an input and return a unique empty output.""" + inference_output = InferenceOutput() + self.inputs.append(inference_input) + self.outputs.append(inference_output) + return inference_output + + +class MockUserCondition(InferenceUserCondition): + """User-provided controls for validated inference steps.""" + + movement: Tensor + """Embedded latent tensor describing character movement.""" + + camera: Tensor + """Embedded latent tensor describing camera rotation.""" + + +class MockGlobalCondition(InferenceGlobalCondition): + """Session-wide controls for validated inference steps.""" + + frame: Tensor + """Embedded latent tensor describing the global conditioning frame.""" + + prompt: Tensor + """Embedded latent tensor describing prompt conditioning.""" + + +MockInferenceInput: TypeAlias = InferenceInput[MockUserCondition, MockGlobalCondition] +"""Inference input with fully specialized nested condition models.""" + + +class MockInferenceOutput(InferenceOutput): + """Output returned by the validated inference session.""" + + frame_chunk: Tensor + """Fully decoded frame chunk from the model latent.""" + + +class ValidatedInferenceSession(InferenceSession[MockStreamInferencePipeline]): + """Inference session with Pydantic-validated condition models.""" + + @validate_call + def step(self, inference_input: MockInferenceInput) -> MockInferenceOutput: + """Return a frame chunk from the validated inference input.""" + global_condition = inference_input.global_condition + frame_chunk = ( + global_condition.frame + if global_condition is not None + else inference_input.user_condition.camera + ) + return MockInferenceOutput(frame_chunk=frame_chunk) + + +__all__ = [ + "MockGlobalCondition", + "MockInferenceInput", + "MockInferenceOutput", + "MockInferenceSession", + "MockStreamInferencePipeline", + "MockStreamInferencePipelineCache", + "MockStreamInferencePipelineConfig", + "MockUserCondition", + "ValidatedInferenceSession", +] diff --git a/flashdreams/tests/runtime/test_inference_runtime.py b/flashdreams/tests/runtime/test_inference_runtime.py index 1e9533e7c..f35a60f9b 100644 --- a/flashdreams/tests/runtime/test_inference_runtime.py +++ b/flashdreams/tests/runtime/test_inference_runtime.py @@ -19,18 +19,17 @@ import pytest import torch -from flashdreams.infra.pipeline import ( - StreamInferencePipeline, - StreamInferencePipelineCache, - StreamInferencePipelineConfig, +from flashdreams.runtime.inference_runtime import ( + InferenceRuntime, + InferenceRuntimeConfig, ) -from flashdreams.runtime.inference_runtime import InferenceRuntime -from flashdreams.runtime.inference_session import ( - InferenceInput, - InferenceOutput, - InferenceSession, + +from .mocks import ( + MockInferenceSession, + MockStreamInferencePipeline, + MockStreamInferencePipelineCache, + MockStreamInferencePipelineConfig, ) -from torch import nn pytestmark = pytest.mark.ci_cpu @@ -38,64 +37,7 @@ ## Runtime test doubles -class _MockStreamInferencePipelineCache(StreamInferencePipelineCache): - """Pipeline cache mock without model-specific state.""" - - def __init__(self) -> None: - """Initialize an empty cache.""" - - -class _MockStreamInferencePipeline(StreamInferencePipeline): - """Pipeline mock that records per-session cache initialization.""" - - initialize_cache_calls: int - """Number of caches initialized for created sessions.""" - - def __init__(self) -> None: - nn.Module.__init__(self) - self.initialize_cache_calls = 0 - - def initialize_cache( - self, - transformer_context: object | None = None, - encoder_context: object | None = None, - decoder_context: object | None = None, - ) -> _MockStreamInferencePipelineCache: - """Create and record a fresh mock session cache.""" - del transformer_context, encoder_context, decoder_context - self.initialize_cache_calls += 1 - return _MockStreamInferencePipelineCache() - - -class _MockStreamInferencePipelineConfig(StreamInferencePipelineConfig): - """Pipeline config mock that returns a preconstructed pipeline.""" - - pipeline: _MockStreamInferencePipeline - """Pipeline returned by the setup method.""" - - setup_calls: int - """Number of times the setup method has been called.""" - - def __init__(self, pipeline: _MockStreamInferencePipeline) -> None: - self.pipeline = pipeline - self.setup_calls = 0 - - def setup(self) -> _MockStreamInferencePipeline: - """Return the configured mock pipeline and record the setup call.""" - self.setup_calls += 1 - return self.pipeline - - -class _MockInferenceSession(InferenceSession[_MockStreamInferencePipeline]): - """Session mock that uses the runtime-owned pipeline.""" - - def step(self, inference_input: InferenceInput) -> InferenceOutput: - """Return an empty output without running the mock pipeline.""" - del inference_input - return InferenceOutput() - - -class _MockInferenceRuntime(InferenceRuntime[_MockInferenceSession]): +class _MockInferenceRuntime(InferenceRuntime[MockInferenceSession]): """Concrete runtime mock with a no-op warmup.""" def warmup(self) -> None: @@ -110,8 +52,8 @@ def runtime_bundle( monkeypatch: pytest.MonkeyPatch, ) -> tuple[ _MockInferenceRuntime, - _MockStreamInferencePipelineConfig, - _MockStreamInferencePipeline, + MockStreamInferencePipelineConfig, + MockStreamInferencePipeline, ]: """Build a single-process runtime with mocked pipeline setup.""" # Keep the fixture on the deterministic non-distributed initialization path. @@ -119,9 +61,14 @@ def runtime_bundle( monkeypatch.delenv("WORLD_SIZE", raising=False) monkeypatch.setattr(torch.distributed, "is_initialized", lambda: False) - pipeline = _MockStreamInferencePipeline() - pipeline_config = _MockStreamInferencePipelineConfig(pipeline) - runtime = _MockInferenceRuntime(pipeline_config, _MockInferenceSession) + pipeline = MockStreamInferencePipeline() + pipeline_config = MockStreamInferencePipelineConfig(pipeline) + runtime_config = InferenceRuntimeConfig( + _target=_MockInferenceRuntime, + pipeline=pipeline_config, + session_type=MockInferenceSession, + ) + runtime = runtime_config.setup() return runtime, pipeline_config, pipeline @@ -131,8 +78,8 @@ def runtime_bundle( def test_runtime_sets_up_and_privately_holds_pipeline( runtime_bundle: tuple[ _MockInferenceRuntime, - _MockStreamInferencePipelineConfig, - _MockStreamInferencePipeline, + MockStreamInferencePipelineConfig, + MockStreamInferencePipeline, ], ) -> None: """Verify runtime construction sets up and retains one pipeline.""" @@ -140,7 +87,7 @@ def test_runtime_sets_up_and_privately_holds_pipeline( assert pipeline_config.setup_calls == 1 assert runtime._pipeline is pipeline - assert runtime._session_type is _MockInferenceSession + assert runtime._session_type is MockInferenceSession assert runtime._local_rank == 0 assert runtime._global_rank == 0 assert runtime._world_size == 1 @@ -150,8 +97,8 @@ def test_runtime_sets_up_and_privately_holds_pipeline( def test_create_session_privately_shares_pipeline_and_initializes_fresh_cache( runtime_bundle: tuple[ _MockInferenceRuntime, - _MockStreamInferencePipelineConfig, - _MockStreamInferencePipeline, + MockStreamInferencePipelineConfig, + MockStreamInferencePipeline, ], ) -> None: """Verify created sessions share the pipeline but own separate caches.""" @@ -164,7 +111,7 @@ def test_create_session_privately_shares_pipeline_and_initializes_fresh_cache( assert first_session is not second_session assert first_session._pipeline is pipeline assert second_session._pipeline is pipeline - assert isinstance(first_session._cache, _MockStreamInferencePipelineCache) - assert isinstance(second_session._cache, _MockStreamInferencePipelineCache) + assert isinstance(first_session._cache, MockStreamInferencePipelineCache) + assert isinstance(second_session._cache, MockStreamInferencePipelineCache) assert first_session._cache is not second_session._cache assert pipeline.initialize_cache_calls == 2 diff --git a/flashdreams/tests/runtime/test_inference_session.py b/flashdreams/tests/runtime/test_inference_session.py index 468645b42..268959375 100644 --- a/flashdreams/tests/runtime/test_inference_session.py +++ b/flashdreams/tests/runtime/test_inference_session.py @@ -17,126 +17,42 @@ from __future__ import annotations -from typing import Any, TypeAlias +from typing import Any import pytest import torch -from flashdreams.infra.pipeline import ( - StreamInferencePipeline, - StreamInferencePipelineCache, -) -from flashdreams.runtime.inference_session import ( - InferenceGlobalCondition, - InferenceInput, - InferenceOutput, - InferenceSession, - InferenceUserCondition, +from pydantic import ValidationError + +from .mocks import ( + MockGlobalCondition, + MockStreamInferencePipeline, + MockUserCondition, + ValidatedInferenceSession, ) -from pydantic import ValidationError, validate_call -from torch import Tensor, nn pytestmark = pytest.mark.ci_cpu -## Pipeline test doubles - - -class _MockStreamInferencePipelineCache(StreamInferencePipelineCache): - """In-memory cache mock without model-specific state.""" - - def __init__(self) -> None: - """Initialize a cache without model-specific state.""" - - -class _MockStreamInferencePipeline(StreamInferencePipeline): - """Pipeline mock that creates an in-memory cache without model setup.""" - - def __init__(self) -> None: - nn.Module.__init__(self) - - def initialize_cache( - self, - transformer_context: object | None = None, - encoder_context: object | None = None, - decoder_context: object | None = None, - ) -> _MockStreamInferencePipelineCache: - """Return a fresh cache without constructing model components.""" - del transformer_context, encoder_context, decoder_context - return _MockStreamInferencePipelineCache() - - -## Session condition and output contracts - - -class _MockUserCondition(InferenceUserCondition): - """User-provided controls for the mock inference step.""" - - movement: Tensor - """Embedded latent tensor describing character movement.""" - - camera: Tensor - """Embedded latent tensor describing camera rotation.""" - - -class _MockGlobalCondition(InferenceGlobalCondition): - """Session-wide controls for the mock inference step.""" - - frame: Tensor - """Embedded latent tensor describing the global conditioning frame.""" - - prompt: Tensor - """Embedded latent tensor describing prompt conditioning.""" - - -# Specialize both nested models so ``validate_call`` sees their fields. -_MockInferenceInput: TypeAlias = InferenceInput[ - _MockUserCondition, _MockGlobalCondition -] - - -class _MockInferenceOutput(InferenceOutput): - """Output returned by the mock inference session.""" - - frame_chunk: Tensor - """Fully decoded frame chunk from the model latent; for WAN, its shape is - ``[4, H, W, 3]``.""" - - -class _MockInferenceSession(InferenceSession[_MockStreamInferencePipeline]): - """Inference session with concrete condition dictionaries.""" - - @validate_call - def step(self, inference_input: _MockInferenceInput) -> _MockInferenceOutput: - """Return a frame chunk from the validated inference input.""" - global_condition = inference_input.global_condition - frame_chunk = ( - global_condition.frame - if global_condition is not None - else inference_input.user_condition.camera - ) - return _MockInferenceOutput(frame_chunk=frame_chunk) - - ## Fixtures and condition factories @pytest.fixture -def session() -> _MockInferenceSession: +def session() -> ValidatedInferenceSession: """Create a session backed by the lightweight pipeline double.""" - return _MockInferenceSession(_MockStreamInferencePipeline()) + return ValidatedInferenceSession(MockStreamInferencePipeline()) -def _user_condition() -> _MockUserCondition: +def _user_condition() -> MockUserCondition: """Build a complete per-step condition for validation tests.""" - return _MockUserCondition( + return MockUserCondition( movement=torch.tensor([1.0, 0.0, -1.0]), camera=torch.eye(4), ) -def _global_condition() -> _MockGlobalCondition: +def _global_condition() -> MockGlobalCondition: """Build a complete rollout-wide condition for validation tests.""" - return _MockGlobalCondition( + return MockGlobalCondition( frame=torch.zeros(3, 8, 8), prompt=torch.ones(4, 16), ) @@ -145,7 +61,7 @@ def _global_condition() -> _MockGlobalCondition: ## Accepted session inputs -def test_step_validates_nested_conditions(session: _MockInferenceSession) -> None: +def test_step_validates_nested_conditions(session: ValidatedInferenceSession) -> None: """Verify complete nested conditions pass step validation.""" user_condition = _user_condition() global_condition = _global_condition() @@ -161,7 +77,7 @@ def test_step_validates_nested_conditions(session: _MockInferenceSession) -> Non def test_step_accepts_missing_optional_global_condition( - session: _MockInferenceSession, + session: ValidatedInferenceSession, ) -> None: """Verify step accepts an omitted optional global condition.""" user_condition = _user_condition() @@ -176,7 +92,7 @@ def test_step_accepts_missing_optional_global_condition( def test_step_rejects_missing_user_condition( - session: _MockInferenceSession, + session: ValidatedInferenceSession, ) -> None: """Verify step rejects an omitted required user condition.""" inference_input: Any = {"global_condition": _global_condition()} @@ -191,7 +107,7 @@ def test_step_rejects_missing_user_condition( @pytest.mark.parametrize("missing_field", ["movement", "camera"]) def test_step_rejects_missing_user_field( - session: _MockInferenceSession, + session: ValidatedInferenceSession, missing_field: str, ) -> None: """Verify step rejects a user condition missing a required tensor field.""" @@ -213,7 +129,7 @@ def test_step_rejects_missing_user_field( @pytest.mark.parametrize("missing_field", ["frame", "prompt"]) def test_step_rejects_missing_global_field( - session: _MockInferenceSession, + session: ValidatedInferenceSession, missing_field: str, ) -> None: """Verify step rejects a global condition missing a required tensor field.""" From c7d5a0604c256e0b2017372bd88cd29dbc77ac0e Mon Sep 17 00:00:00 2001 From: Fangjun Zhou Date: Fri, 7 Aug 2026 17:32:57 -0700 Subject: [PATCH 28/30] Add Application base class --- .../flashdreams/runtime/application.py | 205 ++++++++++ .../flashdreams/runtime/global_condition.py | 48 +++ .../flashdreams/runtime/inference_runtime.py | 41 +- flashdreams/tests/runtime/test_application.py | 375 ++++++++++++++++++ .../tests/runtime/test_global_condition.py | 70 ++++ 5 files changed, 732 insertions(+), 7 deletions(-) create mode 100644 flashdreams/flashdreams/runtime/global_condition.py create mode 100644 flashdreams/tests/runtime/test_application.py create mode 100644 flashdreams/tests/runtime/test_global_condition.py diff --git a/flashdreams/flashdreams/runtime/application.py b/flashdreams/flashdreams/runtime/application.py index e69de29bb..7d73eac61 100644 --- a/flashdreams/flashdreams/runtime/application.py +++ b/flashdreams/flashdreams/runtime/application.py @@ -0,0 +1,205 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Application configuration and runtime orchestration.""" + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import Generic, TypeVar + +from flashdreams.infra.config import InstantiateConfig +from flashdreams.runtime.global_condition import ( + GlobalConditionHandler, + RawGlobalCondition, +) +from flashdreams.runtime.inference_runtime import ( + InferenceRuntime, + InferenceRuntimeConfig, +) +from flashdreams.runtime.inference_session import ( + InferenceGlobalCondition, + InferenceInput, +) +from flashdreams.runtime.input_system import UserInputHandler +from flashdreams.runtime.output_system import InferenceOutputHandler + +RuntimeT = TypeVar("RuntimeT", bound=InferenceRuntime) +"""Inference-runtime type owned by the application.""" + + +@dataclass(kw_only=True) +class ApplicationConfig(InstantiateConfig, Generic[RuntimeT]): + """Configuration for constructing an inference application.""" + + _target: type["Application"] = field(default_factory=lambda: Application) + + inference_runtime: InferenceRuntimeConfig[RuntimeT] + """Configuration used to construct the application runtime.""" + + +class Application(ABC, Generic[RuntimeT]): + """Own the components required by an inference application. + + Subclasses construct the application-specific input, global-condition, and + output handlers through initialization hooks called by this base constructor. + """ + + _inference_runtime: RuntimeT + """Runtime that owns the shared inference pipeline.""" + + _user_input_handler: UserInputHandler + """Handler that produces the next per-step user condition.""" + + _inference_global_condition: InferenceGlobalCondition + """Rollout-wide condition supplied when initializing inference.""" + + _global_condition_handler: GlobalConditionHandler + """Handler that converts application-facing rollout conditions.""" + + _inference_output_handler: InferenceOutputHandler + """Handler that consumes output produced by inference steps.""" + + def __init__( + self, + config: ApplicationConfig[RuntimeT], + inference_global_condition: InferenceGlobalCondition, + ) -> None: + """Initialize the application from its configuration. + + Args: + config: Runtime construction configuration. + inference_global_condition: Initial model-ready rollout condition. + + Raises: + TypeError: A subclass does not initialize a valid input, + global-condition, or output handler. + """ + self._inference_runtime = config.inference_runtime.setup() + self._inference_global_condition = inference_global_condition + + user_input_handler = self._initialize_user_input_handler(config) + global_condition_handler = self._initialize_global_condition_handler(config) + inference_output_handler = self._initialize_inference_output_handler(config) + + if not isinstance(user_input_handler, UserInputHandler): + raise TypeError( + f"{type(self).__name__} did not initialize a user input handler" + ) + if not isinstance(global_condition_handler, GlobalConditionHandler): + raise TypeError( + f"{type(self).__name__} did not initialize a global condition handler" + ) + if not isinstance(inference_output_handler, InferenceOutputHandler): + raise TypeError( + f"{type(self).__name__} did not initialize an inference output handler" + ) + + self._user_input_handler = user_input_handler + self._global_condition_handler = global_condition_handler + self._inference_output_handler = inference_output_handler + + @abstractmethod + def _initialize_user_input_handler( + self, config: ApplicationConfig[RuntimeT] + ) -> UserInputHandler | None: + """Construct the application's user-input handler. + + Args: + config: Application configuration, including any subclass fields. + + Returns: + Initialized handler, or ``None`` when initialization failed. + """ + + @abstractmethod + def _initialize_global_condition_handler( + self, config: ApplicationConfig[RuntimeT] + ) -> GlobalConditionHandler | None: + """Construct the application's global-condition handler. + + Args: + config: Application configuration, including any subclass fields. + + Returns: + Initialized handler, or ``None`` when initialization failed. + """ + + @abstractmethod + def _initialize_inference_output_handler( + self, config: ApplicationConfig[RuntimeT] + ) -> InferenceOutputHandler | None: + """Construct the application's inference-output handler. + + Args: + config: Application configuration, including any subclass fields. + + Returns: + Initialized handler, or ``None`` when initialization failed. + """ + + def handle_global_condition( + self, raw_global_condition: RawGlobalCondition + ) -> InferenceGlobalCondition: + """Convert and store a raw rollout-wide condition. + + Args: + raw_global_condition: Application-facing rollout condition. + + Returns: + Model-ready condition stored for the next application run. + + Raises: + TypeError: The handler returns a value that is not an inference global + condition. + """ + inference_global_condition = self._global_condition_handler( + raw_global_condition + ) + if not isinstance(inference_global_condition, InferenceGlobalCondition): + raise TypeError( + f"{type(self._global_condition_handler).__name__} did not return an " + "inference global condition" + ) + self._inference_global_condition = inference_global_condition + return inference_global_condition + + def run(self) -> None: + """Run inference until the user-input handler is exhausted. + + A new inference session is created for the run. The global condition is + included only in the first inference input because it initializes the + rollout-wide state for that session. + """ + inference_session = self._inference_runtime.create_session() + global_condition: InferenceGlobalCondition | None = ( + self._inference_global_condition + ) + + while True: + try: + user_condition = self._user_input_handler() + except StopIteration: + return + + inference_input = InferenceInput( + user_condition=user_condition, + global_condition=global_condition, + ) + inference_output = inference_session.step(inference_input) + self._inference_output_handler(inference_output) + global_condition = None + + +__all__ = ["Application", "ApplicationConfig"] diff --git a/flashdreams/flashdreams/runtime/global_condition.py b/flashdreams/flashdreams/runtime/global_condition.py new file mode 100644 index 000000000..6a41ba082 --- /dev/null +++ b/flashdreams/flashdreams/runtime/global_condition.py @@ -0,0 +1,48 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Raw global-condition contract and conversion handler interface.""" + +from abc import ABC, abstractmethod + +from flashdreams.runtime.inference_session import InferenceGlobalCondition +from pydantic import ConfigDict, validate_call, with_config +from typing_extensions import TypedDict + + +@with_config(ConfigDict(arbitrary_types_allowed=True, extra="forbid")) +class RawGlobalCondition(TypedDict): + """Base typed dictionary for application-facing rollout conditions.""" + + +class GlobalConditionHandler(ABC): + """Interface for converting raw rollout data into inference conditions.""" + + @abstractmethod + @validate_call + def __call__( + self, raw_global_condition: RawGlobalCondition + ) -> InferenceGlobalCondition: + """Convert a raw global condition into a model-ready condition. + + Args: + raw_global_condition: Application-facing rollout condition. + + Returns: + Model-ready rollout condition for an inference session. + """ + + +__all__ = ["GlobalConditionHandler", "RawGlobalCondition"] diff --git a/flashdreams/flashdreams/runtime/inference_runtime.py b/flashdreams/flashdreams/runtime/inference_runtime.py index dc9ae7c15..843819756 100644 --- a/flashdreams/flashdreams/runtime/inference_runtime.py +++ b/flashdreams/flashdreams/runtime/inference_runtime.py @@ -17,10 +17,12 @@ import os from abc import ABC, abstractmethod -from typing import Generic, TypeVar +from dataclasses import dataclass +from typing import Any, Generic, TypeVar, cast import torch from flashdreams.core.distributed import init as init_distributed +from flashdreams.infra.config import InstantiateConfig from flashdreams.infra.pipeline import ( StreamInferencePipeline, StreamInferencePipelineConfig, @@ -36,6 +38,33 @@ def _is_torchrun_env() -> bool: SessionT = TypeVar("SessionT", bound=InferenceSession) """Session type parameter for :class:`InferenceRuntime`.""" +RuntimeT = TypeVar("RuntimeT", bound="InferenceRuntime") +"""Runtime type constructed by :class:`InferenceRuntimeConfig`.""" + + +@dataclass(kw_only=True) +class InferenceRuntimeConfig(InstantiateConfig, Generic[RuntimeT]): + """Configuration for constructing an inference runtime.""" + + _target: type[RuntimeT] + + pipeline: StreamInferencePipelineConfig + """Pipeline configuration instantiated and shared by runtime sessions.""" + + session_type: type[InferenceSession] + """Concrete session type created by the runtime.""" + + def setup(self, **kwargs: Any) -> RuntimeT: + """Construct the configured inference runtime. + + Args: + **kwargs: Additional constructor arguments for the runtime. + + Returns: + Configured inference runtime. + """ + return self._target(self, **kwargs) + class InferenceRuntime(ABC, Generic[SessionT]): """Shared pipeline runtime for distributed inference sessions. @@ -69,14 +98,12 @@ class InferenceRuntime(ABC, Generic[SessionT]): def __init__( self, - pipeline_config: StreamInferencePipelineConfig, - session_type: type[SessionT], + config: InferenceRuntimeConfig, ) -> None: """Initialize distributed state and construct the shared pipeline. Args: - pipeline_config: Pipeline configuration to instantiate. - session_type: Concrete session type to create. + config: Runtime construction configuration. """ # Initialize before pipeline construction so context-parallel components # observe torchrun's world size while allocating their runtime state. @@ -95,8 +122,8 @@ def __init__( self._world_size = 1 self._is_rank_zero = self._global_rank == 0 - self._pipeline = pipeline_config.setup() - self._session_type = session_type + self._pipeline = config.pipeline.setup() + self._session_type = cast(type[SessionT], config.session_type) def create_session(self) -> SessionT: """Create a session backed by the shared pipeline. diff --git a/flashdreams/tests/runtime/test_application.py b/flashdreams/tests/runtime/test_application.py new file mode 100644 index 000000000..4b65762a0 --- /dev/null +++ b/flashdreams/tests/runtime/test_application.py @@ -0,0 +1,375 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""CPU tests for inference application component ownership.""" + +from dataclasses import dataclass, field + +import pytest +from flashdreams.runtime.application import Application, ApplicationConfig +from flashdreams.runtime.global_condition import ( + GlobalConditionHandler, + RawGlobalCondition, +) +from flashdreams.runtime.inference_runtime import ( + InferenceRuntime, + InferenceRuntimeConfig, +) +from flashdreams.runtime.inference_session import ( + InferenceGlobalCondition, + InferenceOutput, + InferenceUserCondition, +) +from flashdreams.runtime.input_system import UserInputHandler +from flashdreams.runtime.output_system import InferenceOutputHandler + +from .mocks import ( + MockInferenceSession, + MockStreamInferencePipeline, + MockStreamInferencePipelineConfig, +) + +pytestmark = pytest.mark.ci_cpu + + +## Component test doubles + + +class _MockInferenceRuntime(InferenceRuntime[MockInferenceSession]): + """Runtime test double that skips pipeline construction.""" + + def __init__(self, config: InferenceRuntimeConfig) -> None: + """Retain the runtime config without constructing a pipeline.""" + self.config = config + + def warmup(self) -> None: + """Complete warmup without model execution.""" + + +class _MockUserInputHandler(UserInputHandler): + """User-input handler test double.""" + + def __call__(self) -> InferenceUserCondition: + """Return an empty user condition.""" + return InferenceUserCondition() + + +class _MockInferenceOutputHandler(InferenceOutputHandler): + """Inference-output handler test double.""" + + def __call__(self, inference_output: InferenceOutput) -> None: + """Consume an inference output without producing a result.""" + del inference_output + + +class _MockGlobalConditionHandler(GlobalConditionHandler): + """Global-condition handler test double.""" + + def __init__(self) -> None: + """Initialize the conversion record.""" + self.conditions: list[RawGlobalCondition] = [] + + def __call__( + self, raw_global_condition: RawGlobalCondition + ) -> InferenceGlobalCondition: + """Record a raw condition and return a model-ready condition.""" + self.conditions.append(raw_global_condition) + return InferenceGlobalCondition() + + +@dataclass(kw_only=True) +class _MockApplicationConfig(ApplicationConfig[_MockInferenceRuntime]): + """Configuration for the component-ownership application test double.""" + + _target: type["_MockApplication"] = field(default_factory=lambda: _MockApplication) + + +class _MockApplication(Application[_MockInferenceRuntime]): + """Application test double that constructs no-op handlers.""" + + def _initialize_user_input_handler( + self, config: ApplicationConfig[_MockInferenceRuntime] + ) -> UserInputHandler: + """Construct a no-op user-input handler.""" + assert isinstance(config, _MockApplicationConfig) + return _MockUserInputHandler() + + def _initialize_global_condition_handler( + self, config: ApplicationConfig[_MockInferenceRuntime] + ) -> GlobalConditionHandler: + """Construct a recording global-condition handler.""" + assert isinstance(config, _MockApplicationConfig) + return _MockGlobalConditionHandler() + + def _initialize_inference_output_handler( + self, config: ApplicationConfig[_MockInferenceRuntime] + ) -> InferenceOutputHandler: + """Construct a no-op inference-output handler.""" + assert isinstance(config, _MockApplicationConfig) + return _MockInferenceOutputHandler() + + +## Application ownership + + +def test_application_privately_owns_inference_components() -> None: + """Verify construction creates a runtime and retains every component.""" + global_condition = InferenceGlobalCondition() + + runtime_config = InferenceRuntimeConfig( + _target=_MockInferenceRuntime, + pipeline=MockStreamInferencePipelineConfig(MockStreamInferencePipeline()), + session_type=MockInferenceSession, + ) + config = _MockApplicationConfig( + inference_runtime=runtime_config, + ) + application = _MockApplication(config, global_condition) + + assert isinstance(application._inference_runtime, _MockInferenceRuntime) + assert application._inference_runtime.config is runtime_config + assert isinstance(application._user_input_handler, _MockUserInputHandler) + assert application._inference_global_condition is global_condition + assert isinstance( + application._global_condition_handler, _MockGlobalConditionHandler + ) + assert isinstance( + application._inference_output_handler, _MockInferenceOutputHandler + ) + + +class _MissingUserInputApplication(_MockApplication): + """Application test double that fails to construct its input handler.""" + + def _initialize_user_input_handler( + self, config: ApplicationConfig[_MockInferenceRuntime] + ) -> UserInputHandler | None: + """Return no user-input handler.""" + del config + return None + + +class _MissingInferenceOutputApplication(_MockApplication): + """Application test double that fails to construct its output handler.""" + + def _initialize_inference_output_handler( + self, config: ApplicationConfig[_MockInferenceRuntime] + ) -> InferenceOutputHandler | None: + """Return no inference-output handler.""" + del config + return None + + +class _MissingGlobalConditionHandlerApplication(_MockApplication): + """Application test double that fails to construct its global handler.""" + + def _initialize_global_condition_handler( + self, config: ApplicationConfig[_MockInferenceRuntime] + ) -> GlobalConditionHandler | None: + """Return no global-condition handler.""" + del config + return None + + +def test_application_rejects_missing_user_input_handler() -> None: + """Verify construction fails when the child omits its input handler.""" + config = _MockApplicationConfig( + inference_runtime=InferenceRuntimeConfig( + _target=_MockInferenceRuntime, + pipeline=MockStreamInferencePipelineConfig(MockStreamInferencePipeline()), + session_type=MockInferenceSession, + ), + ) + + with pytest.raises(TypeError, match="did not initialize a user input handler"): + _MissingUserInputApplication(config, InferenceGlobalCondition()) + + +def test_application_rejects_missing_inference_output_handler() -> None: + """Verify construction fails when the child omits its output handler.""" + config = _MockApplicationConfig( + inference_runtime=InferenceRuntimeConfig( + _target=_MockInferenceRuntime, + pipeline=MockStreamInferencePipelineConfig(MockStreamInferencePipeline()), + session_type=MockInferenceSession, + ), + ) + + with pytest.raises( + TypeError, match="did not initialize an inference output handler" + ): + _MissingInferenceOutputApplication(config, InferenceGlobalCondition()) + + +def test_application_rejects_missing_global_condition_handler() -> None: + """Verify construction fails when the child omits its global handler.""" + config = _MockApplicationConfig( + inference_runtime=InferenceRuntimeConfig( + _target=_MockInferenceRuntime, + pipeline=MockStreamInferencePipelineConfig(MockStreamInferencePipeline()), + session_type=MockInferenceSession, + ), + ) + + with pytest.raises( + TypeError, match="did not initialize a global condition handler" + ): + _MissingGlobalConditionHandlerApplication( + config, + InferenceGlobalCondition(), + ) + + +def test_application_handles_raw_global_condition() -> None: + """Verify public conversion replaces the condition used by future runs.""" + config = _MockApplicationConfig( + inference_runtime=InferenceRuntimeConfig( + _target=_MockInferenceRuntime, + pipeline=MockStreamInferencePipelineConfig(MockStreamInferencePipeline()), + session_type=MockInferenceSession, + ), + ) + application = _MockApplication(config, InferenceGlobalCondition()) + raw_global_condition: RawGlobalCondition = {} + + inference_global_condition = application.handle_global_condition( + raw_global_condition + ) + + handler = application._global_condition_handler + assert isinstance(handler, _MockGlobalConditionHandler) + assert handler.conditions == [raw_global_condition] + assert application._inference_global_condition is inference_global_condition + + +## Application execution + + +class _RunInferenceRuntime(InferenceRuntime[MockInferenceSession]): + """Runtime test double that constructs and returns one session.""" + + def __init__(self, config: InferenceRuntimeConfig) -> None: + """Initialize the session returned by :meth:`create_session`.""" + del config + self.session = MockInferenceSession() + self.create_session_calls = 0 + + def create_session(self) -> MockInferenceSession: + """Return the configured session and record the creation request.""" + self.create_session_calls += 1 + return self.session + + def warmup(self) -> None: + """Complete warmup without model execution.""" + + +class _RunUserInputHandler(UserInputHandler): + """Finite user-input handler test double.""" + + def __init__(self, conditions: list[InferenceUserCondition]) -> None: + """Initialize with the conditions to return before exhaustion.""" + self.conditions = iter(conditions) + self.calls = 0 + + def __call__(self) -> InferenceUserCondition: + """Return the next user condition or signal exhaustion.""" + self.calls += 1 + return next(self.conditions) + + +class _RunInferenceOutputHandler(InferenceOutputHandler): + """Inference-output handler that records consumed outputs.""" + + def __init__(self) -> None: + """Initialize the consumed output record.""" + self.outputs: list[InferenceOutput] = [] + + def __call__(self, inference_output: InferenceOutput) -> None: + """Record an inference output in call order.""" + self.outputs.append(inference_output) + + +@dataclass(kw_only=True) +class _RunApplicationConfig(ApplicationConfig[_RunInferenceRuntime]): + """Configuration for the finite-loop application test double.""" + + _target: type["_RunApplication"] = field(default_factory=lambda: _RunApplication) + + user_conditions: list[InferenceUserCondition] + """Conditions returned by the child-created input handler.""" + + +class _RunApplication(Application[_RunInferenceRuntime]): + """Application test double that constructs recording handlers.""" + + def _initialize_user_input_handler( + self, config: ApplicationConfig[_RunInferenceRuntime] + ) -> UserInputHandler: + """Construct the finite user-input handler.""" + assert isinstance(config, _RunApplicationConfig) + return _RunUserInputHandler(config.user_conditions) + + def _initialize_global_condition_handler( + self, config: ApplicationConfig[_RunInferenceRuntime] + ) -> GlobalConditionHandler: + """Construct the recording global-condition handler.""" + assert isinstance(config, _RunApplicationConfig) + return _MockGlobalConditionHandler() + + def _initialize_inference_output_handler( + self, config: ApplicationConfig[_RunInferenceRuntime] + ) -> InferenceOutputHandler: + """Construct the recording inference-output handler.""" + assert isinstance(config, _RunApplicationConfig) + return _RunInferenceOutputHandler() + + +def test_application_run_processes_inputs_until_handler_exhaustion() -> None: + """Verify the application builds inputs and dispatches every output.""" + user_conditions = [InferenceUserCondition(), InferenceUserCondition()] + global_condition = InferenceGlobalCondition() + runtime_config = InferenceRuntimeConfig( + _target=_RunInferenceRuntime, + pipeline=MockStreamInferencePipelineConfig(MockStreamInferencePipeline()), + session_type=MockInferenceSession, + ) + config = _RunApplicationConfig( + inference_runtime=runtime_config, + user_conditions=user_conditions, + ) + application = _RunApplication(config, global_condition) + runtime = application._inference_runtime + session = runtime.session + user_input_handler = application._user_input_handler + output_handler = application._inference_output_handler + + assert isinstance(user_input_handler, _RunUserInputHandler) + assert isinstance(output_handler, _RunInferenceOutputHandler) + + application.run() + + # A run owns one stateful session and polls once more to observe exhaustion. + assert runtime.create_session_calls == 1 + assert user_input_handler.calls == 3 + + # The global condition initializes the first step and is omitted thereafter. + assert len(session.inputs) == 2 + assert session.inputs[0].user_condition is user_conditions[0] + assert session.inputs[0].global_condition is global_condition + assert session.inputs[1].user_condition is user_conditions[1] + assert session.inputs[1].global_condition is None + + # Each inference output is dispatched to the output handler in step order. + assert output_handler.outputs == session.outputs diff --git a/flashdreams/tests/runtime/test_global_condition.py b/flashdreams/tests/runtime/test_global_condition.py new file mode 100644 index 000000000..8b3b5afde --- /dev/null +++ b/flashdreams/tests/runtime/test_global_condition.py @@ -0,0 +1,70 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""CPU tests for raw global-condition conversion contracts.""" + +from typing import Any, cast + +import pytest +from flashdreams.runtime.global_condition import ( + GlobalConditionHandler, + RawGlobalCondition, +) +from flashdreams.runtime.inference_session import InferenceGlobalCondition +from pydantic import ValidationError, validate_call + +pytestmark = pytest.mark.ci_cpu + + +class _RawPromptCondition(RawGlobalCondition): + """Raw prompt supplied by an application boundary.""" + + prompt: str + """Unprocessed rollout prompt.""" + + +class _PromptCondition(InferenceGlobalCondition): + """Model-ready prompt condition used by the test handler.""" + + prompt: str + """Normalized rollout prompt.""" + + +class _PromptConditionHandler(GlobalConditionHandler): + """Normalize a raw prompt into an inference condition.""" + + @validate_call + def __call__(self, raw_global_condition: _RawPromptCondition) -> _PromptCondition: + """Normalize whitespace around the rollout prompt.""" + return _PromptCondition(prompt=raw_global_condition["prompt"].strip()) + + +def test_global_condition_handler_validates_and_converts_raw_condition() -> None: + """Verify a concrete handler receives validated typed-dictionary data.""" + handler = _PromptConditionHandler() + + condition = handler(_RawPromptCondition(prompt=" drive forward ")) + + assert condition.prompt == "drive forward" + + +def test_global_condition_handler_rejects_invalid_raw_condition() -> None: + """Verify Pydantic rejects invalid raw condition fields and extras.""" + handler = _PromptConditionHandler() + + with pytest.raises(ValidationError): + handler(cast(Any, {"prompt": 42})) + with pytest.raises(ValidationError): + handler(cast(Any, {"prompt": "drive", "unexpected": True})) From 4aebecb917dfc7be6ee29d6ffa00f42f49d1483f Mon Sep 17 00:00:00 2001 From: Fangjun Zhou Date: Fri, 7 Aug 2026 17:58:50 -0700 Subject: [PATCH 29/30] Add VideoOutputApplication --- .../application/video_output_application.py | 78 +++++++++ .../runtime/test_video_output_application.py | 152 ++++++++++++++++++ 2 files changed, 230 insertions(+) create mode 100644 flashdreams/flashdreams/runtime/builtin/application/video_output_application.py create mode 100644 flashdreams/tests/runtime/test_video_output_application.py diff --git a/flashdreams/flashdreams/runtime/builtin/application/video_output_application.py b/flashdreams/flashdreams/runtime/builtin/application/video_output_application.py new file mode 100644 index 000000000..2dfdeec4e --- /dev/null +++ b/flashdreams/flashdreams/runtime/builtin/application/video_output_application.py @@ -0,0 +1,78 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +"""Application base with video-artifact output handling.""" + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Generic, TypeVar + +from flashdreams.runtime.application import Application, ApplicationConfig +from flashdreams.runtime.builtin.inference_output.handler.video_output_handler import ( + VideoOutputHandler, +) +from flashdreams.runtime.inference_runtime import InferenceRuntime + +RuntimeT = TypeVar("RuntimeT", bound=InferenceRuntime) +"""Inference-runtime type owned by the video-output application.""" + + +@dataclass(kw_only=True) +class VideoOutputApplicationConfig(ApplicationConfig[RuntimeT], Generic[RuntimeT]): + """Configuration for an application that writes video output.""" + + _target: type["VideoOutputApplication"] = field( + default_factory=lambda: VideoOutputApplication + ) + + artifact_path: str | Path + """Destination written by the video output handler.""" + + +class VideoOutputApplication(Application[RuntimeT], Generic[RuntimeT]): + """Application base that collects inference frames into a video artifact.""" + + _inference_output_handler: VideoOutputHandler + """Video handler constructed by the output initialization hook.""" + + def _initialize_inference_output_handler( + self, config: ApplicationConfig[RuntimeT] + ) -> VideoOutputHandler: + """Construct the video output handler from application configuration. + + Args: + config: Application configuration accepted for the base hook contract. + + Returns: + Handler configured with the artifact destination. + + Raises: + TypeError: The application was not given video-output configuration. + """ + if not isinstance(config, VideoOutputApplicationConfig): + raise TypeError( + "VideoOutputApplication requires VideoOutputApplicationConfig; " + f"got {type(config).__name__}" + ) + return VideoOutputHandler(config.artifact_path) + + def run(self) -> None: + """Run inference and finish the video artifact after input exhaustion.""" + super().run() + self._inference_output_handler.finish() + + +__all__ = ["VideoOutputApplication", "VideoOutputApplicationConfig"] diff --git a/flashdreams/tests/runtime/test_video_output_application.py b/flashdreams/tests/runtime/test_video_output_application.py new file mode 100644 index 000000000..1ce63d66e --- /dev/null +++ b/flashdreams/tests/runtime/test_video_output_application.py @@ -0,0 +1,152 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""CPU tests for the builtin video-output application.""" + +from pathlib import Path + +import pytest +from flashdreams.runtime.application import Application, ApplicationConfig +from flashdreams.runtime.builtin.application.video_output_application import ( + VideoOutputApplication, + VideoOutputApplicationConfig, +) +from flashdreams.runtime.builtin.inference_output.handler.video_output_handler import ( + VideoOutputHandler, +) +from flashdreams.runtime.global_condition import ( + GlobalConditionHandler, + RawGlobalCondition, +) +from flashdreams.runtime.inference_runtime import ( + InferenceRuntime, + InferenceRuntimeConfig, +) +from flashdreams.runtime.inference_session import ( + InferenceGlobalCondition, + InferenceUserCondition, +) +from flashdreams.runtime.input_system import UserInputHandler + +from .mocks import ( + MockInferenceSession, + MockStreamInferencePipeline, + MockStreamInferencePipelineConfig, +) + +pytestmark = pytest.mark.ci_cpu + + +class _MockInferenceRuntime(InferenceRuntime[MockInferenceSession]): + """Runtime test double that skips pipeline construction.""" + + def __init__(self, config: InferenceRuntimeConfig) -> None: + """Retain the runtime configuration.""" + self.config = config + + def warmup(self) -> None: + """Complete warmup without model execution.""" + + +class _MockUserInputHandler(UserInputHandler): + """User-input handler test double.""" + + def __call__(self) -> InferenceUserCondition: + """Return an empty user condition.""" + return InferenceUserCondition() + + +class _MockGlobalConditionHandler(GlobalConditionHandler): + """Global-condition handler test double.""" + + def __call__( + self, raw_global_condition: RawGlobalCondition + ) -> InferenceGlobalCondition: + """Return an empty inference global condition.""" + del raw_global_condition + return InferenceGlobalCondition() + + +class _TestVideoOutputApplication(VideoOutputApplication[_MockInferenceRuntime]): + """Concrete video-output application test double.""" + + def _initialize_user_input_handler( + self, config: ApplicationConfig[_MockInferenceRuntime] + ) -> UserInputHandler: + """Construct the application user-input handler.""" + del config + return _MockUserInputHandler() + + def _initialize_global_condition_handler( + self, config: ApplicationConfig[_MockInferenceRuntime] + ) -> GlobalConditionHandler: + """Construct the global-condition handler.""" + del config + return _MockGlobalConditionHandler() + + +def test_video_output_application_initializes_video_handler(tmp_path: Path) -> None: + """Verify construction binds the artifact path to a video output handler.""" + runtime_config = InferenceRuntimeConfig( + _target=_MockInferenceRuntime, + pipeline=MockStreamInferencePipelineConfig(MockStreamInferencePipeline()), + session_type=MockInferenceSession, + ) + artifact_path = tmp_path / "generated.mp4" + config = VideoOutputApplicationConfig( + inference_runtime=runtime_config, + artifact_path=artifact_path, + ) + + application = _TestVideoOutputApplication( + config, + InferenceGlobalCondition(), + ) + + # The base constructor obtains each handler through its child initialization hook. + assert isinstance(application._user_input_handler, _MockUserInputHandler) + assert isinstance(application._inference_output_handler, VideoOutputHandler) + assert application._inference_output_handler.artifact_path == artifact_path + + +def test_video_output_application_finishes_video_handler_after_run( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Verify a completed application run finishes its video output handler.""" + lifecycle: list[str] = [] + + def run(_application: Application) -> None: + lifecycle.append("run") + + def finish(handler: VideoOutputHandler) -> Path: + lifecycle.append("finish") + return handler.artifact_path + + monkeypatch.setattr(Application, "run", run) + monkeypatch.setattr(VideoOutputHandler, "finish", finish) + config = VideoOutputApplicationConfig( + inference_runtime=InferenceRuntimeConfig( + _target=_MockInferenceRuntime, + pipeline=MockStreamInferencePipelineConfig(MockStreamInferencePipeline()), + session_type=MockInferenceSession, + ), + artifact_path=tmp_path / "generated.mp4", + ) + application = _TestVideoOutputApplication(config, InferenceGlobalCondition()) + + application.run() + + assert lifecycle == ["run", "finish"] From bcd48f2a6391bfe71423afa0c606ea5fef7ee4f4 Mon Sep 17 00:00:00 2001 From: Fangjun Zhou Date: Sat, 8 Aug 2026 12:36:25 -0700 Subject: [PATCH 30/30] Add omnidreams headless application --- integrations/omnidreams/omnidreams/config.py | 184 +----- .../omnidreams/omnidreams/runner_config.py | 199 ++++++ .../runtime/application/headless.py | 597 +++++++++++++++++ .../omnidreams/runtime/global_condition.py | 155 +++++ .../omnidreams/runtime/inference_session.py | 16 +- .../runtime/user_input/hdmap_input_handler.py | 148 ++++- integrations/omnidreams/pyproject.toml | 7 +- .../tests/runtime/test_global_condition.py | 176 +++++ .../runtime/test_headless_application.py | 599 ++++++++++++++++++ .../tests/runtime/test_inference_session.py | 6 +- .../omnidreams/tests/test_demo_api.py | 29 +- .../tests/test_quality_regression.py | 3 +- .../omnidreams/tests/test_recipe_configs.py | 9 +- 13 files changed, 1904 insertions(+), 224 deletions(-) create mode 100644 integrations/omnidreams/omnidreams/runner_config.py create mode 100644 integrations/omnidreams/omnidreams/runtime/application/headless.py create mode 100644 integrations/omnidreams/omnidreams/runtime/global_condition.py create mode 100644 integrations/omnidreams/tests/runtime/test_global_condition.py create mode 100644 integrations/omnidreams/tests/runtime/test_headless_application.py diff --git a/integrations/omnidreams/omnidreams/config.py b/integrations/omnidreams/omnidreams/config.py index 9cfac26ab..4fee8537d 100644 --- a/integrations/omnidreams/omnidreams/config.py +++ b/integrations/omnidreams/omnidreams/config.py @@ -13,15 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""User-facing configs for Omnidreams. - -Hosts both the pre-built :class:`OmnidreamsPipelineConfig` literals -and the per-slug :class:`OmnidreamsRunnerConfig` literals that drive -``flashdreams-run``. Each ``RUNNER_*`` literal is wired into the -``flashdreams.runner_configs`` entry-point group by this package's -``pyproject.toml`` and discovered at install time -- no in-tree -registration is performed here. -""" +"""Pre-built OmniDreams inference pipeline configurations.""" from __future__ import annotations @@ -29,21 +21,6 @@ from typing import cast import torch -from omnidreams.encoder.pixel_shuffle import ( - PixelShuffleVAEEncoderConfig, -) -from omnidreams.pipeline import ( - OmnidreamsPipelineConfig, -) -from omnidreams.runner import OmnidreamsRunnerConfig -from omnidreams.transformer import CosmosTransformerConfig -from omnidreams.transformer.impl.network import ( - CosmosDiTNetworkConfig, -) -from omnidreams.vae_native import ( - OmnidreamsWanVAEEncoderConfig as WanVAEEncoderConfig, -) - from flashdreams.infra.config import derive_config from flashdreams.infra.diffusion.model import DiffusionModelConfig from flashdreams.infra.diffusion.scheduler.fm import ( @@ -55,7 +32,6 @@ from flashdreams.infra.encoder.text.cosmos_reason1 import ( CosmosReason1TextEncoderConfig, ) -from flashdreams.infra.runner import RunnerConfig from flashdreams.recipes.taehv import ( AVAILABLE_TAEHV_CHECKPOINT_PATHS, TeahvVAEDecoderConfig, @@ -64,6 +40,19 @@ AVAILABLE_WAN_VAE_CHECKPOINT_PATHS, WanVAEDecoderConfig, ) +from omnidreams.encoder.pixel_shuffle import ( + PixelShuffleVAEEncoderConfig, +) +from omnidreams.pipeline import ( + OmnidreamsPipelineConfig, +) +from omnidreams.transformer import CosmosTransformerConfig +from omnidreams.transformer.impl.network import ( + CosmosDiTNetworkConfig, +) +from omnidreams.vae_native import ( + OmnidreamsWanVAEEncoderConfig as WanVAEEncoderConfig, +) AVAILABLE_OMNIDREAMS_CHECKPOINT_PATHS: dict[str, str] = { "1view-vae-chunk2": ( @@ -428,148 +417,3 @@ def _lightvae_fp8_state_path() -> str | None: ) } """All shipped Omnidreams variants, keyed by ``name``.""" - - -## Per-variant runner-config literals (slug == ``name``). - -_DEFAULT_PROMPT_1V = ( - "Driving scene from a front-facing car camera. Urban environment with roads, " - "vehicles, pedestrians, traffic signs, and buildings. Clear visibility, " - "realistic lighting, photorealistic quality. High resolution dashcam footage " - "of city driving." -) -_DEFAULT_PROMPT_4V = ( - "Wide-angle urban street scene from a low, dashboard-level viewpoint. " - "A straight two-lane road with a faded center line and curbside parking on " - "both sides. Parked sedans and SUVs in neutral colors line the curbs. On the " - "right, a white stucco mid-rise building with blue fabric awnings, rectangular " - "windows, and small storefronts at street level. On the left, a low commercial " - "strip with dark trim, glass fronts, signage, and shaded sidewalks. Mature green " - "trees punctuate both sides. Clear blue sky with sparse soft clouds. Bright midday " - "sunlight, natural colors, realistic materials, crisp shadows, clean asphalt texture." -) - -RUNNER_SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE = OmnidreamsRunnerConfig( - runner_name=SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE.name, - description="Single-view 2-step distilled chunk2 (LightVAE + LightTAE).", - pipeline=SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE, - prompt=_DEFAULT_PROMPT_1V, -) - -RUNNER_SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE_PERF = OmnidreamsRunnerConfig( - runner_name=SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE_PERF.name, - description=( - "Single-view chunk2 perf preset (compile + CUDA graphs across all stages)." - ), - pipeline=SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE_PERF, - prompt=_DEFAULT_PROMPT_1V, -) - -RUNNER_SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE_NATIVE_PERF = OmnidreamsRunnerConfig( - runner_name=SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE_NATIVE_PERF.name, - description=( - "Single-view chunk2 native VAE perf preset " - "(LightVAE FP8 encoder + PyTorch LightTAE decoder)." - ), - pipeline=SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE_NATIVE_PERF, - prompt=_DEFAULT_PROMPT_1V, -) - -RUNNER_SV_2STEPS_CHUNK2_LOC6_VAE_VAE = OmnidreamsRunnerConfig( - runner_name=SV_2STEPS_CHUNK2_LOC6_VAE_VAE.name, - description="Single-view chunk2 with the full Wan VAE on encoder + decoder.", - pipeline=SV_2STEPS_CHUNK2_LOC6_VAE_VAE, - prompt=_DEFAULT_PROMPT_1V, -) - -RUNNER_SV_2STEPS_CHUNK3_LOC6_VAE_VAE = OmnidreamsRunnerConfig( - runner_name=SV_2STEPS_CHUNK3_LOC6_VAE_VAE.name, - description="Single-view chunk3 (len_t=3) with the full Wan VAE.", - pipeline=SV_2STEPS_CHUNK3_LOC6_VAE_VAE, - prompt=_DEFAULT_PROMPT_1V, -) - -RUNNER_SV_2STEPS_CHUNK4_LOC8_PSHUFFLE_LIGHTTAE = OmnidreamsRunnerConfig( - runner_name=SV_2STEPS_CHUNK4_LOC8_PSHUFFLE_LIGHTTAE.name, - description="Single-view chunk4 with the PixelShuffle HDMap encoder + LightTAE.", - pipeline=SV_2STEPS_CHUNK4_LOC8_PSHUFFLE_LIGHTTAE, - prompt=_DEFAULT_PROMPT_1V, -) - -RUNNER_MV_2STEPS_CHUNK4_LOC8_PSHUFFLE_LIGHTTAE = OmnidreamsRunnerConfig( - runner_name=MV_2STEPS_CHUNK4_LOC8_PSHUFFLE_LIGHTTAE.name, - description="4-camera multi-view chunk4 (PixelShuffle HDMap + LightTAE).", - pipeline=MV_2STEPS_CHUNK4_LOC8_PSHUFFLE_LIGHTTAE, - prompt=_DEFAULT_PROMPT_4V, -) - -RUNNER_SV_35STEPS_CHUNK2_LOC24_COSMOS2_2B_RES720P_30FPS_HDMAP_VAE_MADS1M = OmnidreamsRunnerConfig( - runner_name=SV_35STEPS_CHUNK2_LOC24_COSMOS2_2B_RES720P_30FPS_HDMAP_VAE_MADS1M.name, - description=( - "Teacher: single-view 35-step UniPC chunk2 (Cosmos2 2B, 720p, CFG=3.0)." - ), - pipeline=SV_35STEPS_CHUNK2_LOC24_COSMOS2_2B_RES720P_30FPS_HDMAP_VAE_MADS1M, - prompt=_DEFAULT_PROMPT_1V, -) - -RUNNER_SV_35STEPS_CHUNK48_LOC48_COSMOS2_2B_RES720P_30FPS_HDMAP_VAE_MADS1M = OmnidreamsRunnerConfig( - runner_name=SV_35STEPS_CHUNK48_LOC48_COSMOS2_2B_RES720P_30FPS_HDMAP_VAE_MADS1M.name, - description=( - "Teacher: single-view 35-step bidirectional chunk48 (one rollout, 720p)." - ), - pipeline=SV_35STEPS_CHUNK48_LOC48_COSMOS2_2B_RES720P_30FPS_HDMAP_VAE_MADS1M, - prompt=_DEFAULT_PROMPT_1V, -) - -RUNNER_EXPERIMENT1_BASELINE = OmnidreamsRunnerConfig( - runner_name=EXPERIMENT1_BASELINE.name, - description="Experiment-1 baseline (re-publishes the chunk2 perf chassis).", - pipeline=EXPERIMENT1_BASELINE, - prompt=_DEFAULT_PROMPT_1V, -) - -RUNNER_EXPERIMENT1_SKIP_FINALIZE_KV_CACHE = OmnidreamsRunnerConfig( - runner_name=EXPERIMENT1_SKIP_FINALIZE_KV_CACHE.name, - description="Experiment-1: skip-finalize-kv-cache ablation.", - pipeline=EXPERIMENT1_SKIP_FINALIZE_KV_CACHE, - prompt=_DEFAULT_PROMPT_1V, -) - -RUNNER_EXPERIMENT1_SKIP_FINALIZE_KV_CACHE_NOISE350 = OmnidreamsRunnerConfig( - runner_name=EXPERIMENT1_SKIP_FINALIZE_KV_CACHE_NOISE350.name, - description="Experiment-1: skip-finalize + denoising_timesteps=[1000, 350].", - pipeline=EXPERIMENT1_SKIP_FINALIZE_KV_CACHE_NOISE350, - prompt=_DEFAULT_PROMPT_1V, -) - -RUNNER_EXPERIMENT1_SKIP_FINALIZE_KV_CACHE_NOISE250 = OmnidreamsRunnerConfig( - runner_name=EXPERIMENT1_SKIP_FINALIZE_KV_CACHE_NOISE250.name, - description="Experiment-1: skip-finalize + denoising_timesteps=[1000, 250].", - pipeline=EXPERIMENT1_SKIP_FINALIZE_KV_CACHE_NOISE250, - prompt=_DEFAULT_PROMPT_1V, -) - -RUNNER_EXPERIMENT1_SKIP_FINALIZE_KV_CACHE_NOISE150 = OmnidreamsRunnerConfig( - runner_name=EXPERIMENT1_SKIP_FINALIZE_KV_CACHE_NOISE150.name, - description="Experiment-1: skip-finalize + denoising_timesteps=[1000, 150].", - pipeline=EXPERIMENT1_SKIP_FINALIZE_KV_CACHE_NOISE150, - prompt=_DEFAULT_PROMPT_1V, -) - -RUNNER_EXPERIMENT1_SKIP_FINALIZE_KV_CACHE_NOISE100 = OmnidreamsRunnerConfig( - runner_name=EXPERIMENT1_SKIP_FINALIZE_KV_CACHE_NOISE100.name, - description="Experiment-1: skip-finalize + denoising_timesteps=[1000, 100].", - pipeline=EXPERIMENT1_SKIP_FINALIZE_KV_CACHE_NOISE100, - prompt=_DEFAULT_PROMPT_1V, -) - - -OMNIDREAMS_RUNNERS: dict[str, RunnerConfig] = { - cfg.runner_name: cfg - for cfg in ( - RUNNER_SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE, - RUNNER_SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE_PERF, - ) -} -"""All shipped Omnidreams runners (single- and multi-view variants), -keyed by ``runner_name``.""" diff --git a/integrations/omnidreams/omnidreams/runner_config.py b/integrations/omnidreams/omnidreams/runner_config.py new file mode 100644 index 000000000..082e10350 --- /dev/null +++ b/integrations/omnidreams/omnidreams/runner_config.py @@ -0,0 +1,199 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Legacy ``flashdreams-run`` configuration for OmniDreams pipelines.""" + +from flashdreams.infra.runner import RunnerConfig +from omnidreams.config import ( + EXPERIMENT1_BASELINE, + EXPERIMENT1_SKIP_FINALIZE_KV_CACHE, + EXPERIMENT1_SKIP_FINALIZE_KV_CACHE_NOISE100, + EXPERIMENT1_SKIP_FINALIZE_KV_CACHE_NOISE150, + EXPERIMENT1_SKIP_FINALIZE_KV_CACHE_NOISE250, + EXPERIMENT1_SKIP_FINALIZE_KV_CACHE_NOISE350, + MV_2STEPS_CHUNK4_LOC8_PSHUFFLE_LIGHTTAE, + SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE, + SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE_NATIVE_PERF, + SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE_PERF, + SV_2STEPS_CHUNK2_LOC6_VAE_VAE, + SV_2STEPS_CHUNK3_LOC6_VAE_VAE, + SV_2STEPS_CHUNK4_LOC8_PSHUFFLE_LIGHTTAE, + SV_35STEPS_CHUNK2_LOC24_COSMOS2_2B_RES720P_30FPS_HDMAP_VAE_MADS1M, + SV_35STEPS_CHUNK48_LOC48_COSMOS2_2B_RES720P_30FPS_HDMAP_VAE_MADS1M, +) +from omnidreams.runner import OmnidreamsRunnerConfig + +## Per-variant runner-config literals (slug == ``name``). + +_DEFAULT_PROMPT_1V = ( + "Driving scene from a front-facing car camera. Urban environment with roads, " + "vehicles, pedestrians, traffic signs, and buildings. Clear visibility, " + "realistic lighting, photorealistic quality. High resolution dashcam footage " + "of city driving." +) +_DEFAULT_PROMPT_4V = ( + "Wide-angle urban street scene from a low, dashboard-level viewpoint. " + "A straight two-lane road with a faded center line and curbside parking on " + "both sides. Parked sedans and SUVs in neutral colors line the curbs. On the " + "right, a white stucco mid-rise building with blue fabric awnings, rectangular " + "windows, and small storefronts at street level. On the left, a low commercial " + "strip with dark trim, glass fronts, signage, and shaded sidewalks. Mature green " + "trees punctuate both sides. Clear blue sky with sparse soft clouds. Bright midday " + "sunlight, natural colors, realistic materials, crisp shadows, clean asphalt texture." +) + +RUNNER_SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE = OmnidreamsRunnerConfig( + runner_name=SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE.name, + description="Single-view 2-step distilled chunk2 (LightVAE + LightTAE).", + pipeline=SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE, + prompt=_DEFAULT_PROMPT_1V, +) + +RUNNER_SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE_PERF = OmnidreamsRunnerConfig( + runner_name=SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE_PERF.name, + description=( + "Single-view chunk2 perf preset (compile + CUDA graphs across all stages)." + ), + pipeline=SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE_PERF, + prompt=_DEFAULT_PROMPT_1V, +) + +RUNNER_SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE_NATIVE_PERF = OmnidreamsRunnerConfig( + runner_name=SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE_NATIVE_PERF.name, + description=( + "Single-view chunk2 native VAE perf preset " + "(LightVAE FP8 encoder + PyTorch LightTAE decoder)." + ), + pipeline=SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE_NATIVE_PERF, + prompt=_DEFAULT_PROMPT_1V, +) + +RUNNER_SV_2STEPS_CHUNK2_LOC6_VAE_VAE = OmnidreamsRunnerConfig( + runner_name=SV_2STEPS_CHUNK2_LOC6_VAE_VAE.name, + description="Single-view chunk2 with the full Wan VAE on encoder + decoder.", + pipeline=SV_2STEPS_CHUNK2_LOC6_VAE_VAE, + prompt=_DEFAULT_PROMPT_1V, +) + +RUNNER_SV_2STEPS_CHUNK3_LOC6_VAE_VAE = OmnidreamsRunnerConfig( + runner_name=SV_2STEPS_CHUNK3_LOC6_VAE_VAE.name, + description="Single-view chunk3 (len_t=3) with the full Wan VAE.", + pipeline=SV_2STEPS_CHUNK3_LOC6_VAE_VAE, + prompt=_DEFAULT_PROMPT_1V, +) + +RUNNER_SV_2STEPS_CHUNK4_LOC8_PSHUFFLE_LIGHTTAE = OmnidreamsRunnerConfig( + runner_name=SV_2STEPS_CHUNK4_LOC8_PSHUFFLE_LIGHTTAE.name, + description="Single-view chunk4 with the PixelShuffle HDMap encoder + LightTAE.", + pipeline=SV_2STEPS_CHUNK4_LOC8_PSHUFFLE_LIGHTTAE, + prompt=_DEFAULT_PROMPT_1V, +) + +RUNNER_MV_2STEPS_CHUNK4_LOC8_PSHUFFLE_LIGHTTAE = OmnidreamsRunnerConfig( + runner_name=MV_2STEPS_CHUNK4_LOC8_PSHUFFLE_LIGHTTAE.name, + description="4-camera multi-view chunk4 (PixelShuffle HDMap + LightTAE).", + pipeline=MV_2STEPS_CHUNK4_LOC8_PSHUFFLE_LIGHTTAE, + prompt=_DEFAULT_PROMPT_4V, +) + +RUNNER_SV_35STEPS_CHUNK2_LOC24_COSMOS2_2B_RES720P_30FPS_HDMAP_VAE_MADS1M = OmnidreamsRunnerConfig( + runner_name=SV_35STEPS_CHUNK2_LOC24_COSMOS2_2B_RES720P_30FPS_HDMAP_VAE_MADS1M.name, + description=( + "Teacher: single-view 35-step UniPC chunk2 (Cosmos2 2B, 720p, CFG=3.0)." + ), + pipeline=SV_35STEPS_CHUNK2_LOC24_COSMOS2_2B_RES720P_30FPS_HDMAP_VAE_MADS1M, + prompt=_DEFAULT_PROMPT_1V, +) + +RUNNER_SV_35STEPS_CHUNK48_LOC48_COSMOS2_2B_RES720P_30FPS_HDMAP_VAE_MADS1M = OmnidreamsRunnerConfig( + runner_name=SV_35STEPS_CHUNK48_LOC48_COSMOS2_2B_RES720P_30FPS_HDMAP_VAE_MADS1M.name, + description=( + "Teacher: single-view 35-step bidirectional chunk48 (one rollout, 720p)." + ), + pipeline=SV_35STEPS_CHUNK48_LOC48_COSMOS2_2B_RES720P_30FPS_HDMAP_VAE_MADS1M, + prompt=_DEFAULT_PROMPT_1V, +) + +RUNNER_EXPERIMENT1_BASELINE = OmnidreamsRunnerConfig( + runner_name=EXPERIMENT1_BASELINE.name, + description="Experiment-1 baseline (re-publishes the chunk2 perf chassis).", + pipeline=EXPERIMENT1_BASELINE, + prompt=_DEFAULT_PROMPT_1V, +) + +RUNNER_EXPERIMENT1_SKIP_FINALIZE_KV_CACHE = OmnidreamsRunnerConfig( + runner_name=EXPERIMENT1_SKIP_FINALIZE_KV_CACHE.name, + description="Experiment-1: skip-finalize-kv-cache ablation.", + pipeline=EXPERIMENT1_SKIP_FINALIZE_KV_CACHE, + prompt=_DEFAULT_PROMPT_1V, +) + +RUNNER_EXPERIMENT1_SKIP_FINALIZE_KV_CACHE_NOISE350 = OmnidreamsRunnerConfig( + runner_name=EXPERIMENT1_SKIP_FINALIZE_KV_CACHE_NOISE350.name, + description="Experiment-1: skip-finalize + denoising_timesteps=[1000, 350].", + pipeline=EXPERIMENT1_SKIP_FINALIZE_KV_CACHE_NOISE350, + prompt=_DEFAULT_PROMPT_1V, +) + +RUNNER_EXPERIMENT1_SKIP_FINALIZE_KV_CACHE_NOISE250 = OmnidreamsRunnerConfig( + runner_name=EXPERIMENT1_SKIP_FINALIZE_KV_CACHE_NOISE250.name, + description="Experiment-1: skip-finalize + denoising_timesteps=[1000, 250].", + pipeline=EXPERIMENT1_SKIP_FINALIZE_KV_CACHE_NOISE250, + prompt=_DEFAULT_PROMPT_1V, +) + +RUNNER_EXPERIMENT1_SKIP_FINALIZE_KV_CACHE_NOISE150 = OmnidreamsRunnerConfig( + runner_name=EXPERIMENT1_SKIP_FINALIZE_KV_CACHE_NOISE150.name, + description="Experiment-1: skip-finalize + denoising_timesteps=[1000, 150].", + pipeline=EXPERIMENT1_SKIP_FINALIZE_KV_CACHE_NOISE150, + prompt=_DEFAULT_PROMPT_1V, +) + +RUNNER_EXPERIMENT1_SKIP_FINALIZE_KV_CACHE_NOISE100 = OmnidreamsRunnerConfig( + runner_name=EXPERIMENT1_SKIP_FINALIZE_KV_CACHE_NOISE100.name, + description="Experiment-1: skip-finalize + denoising_timesteps=[1000, 100].", + pipeline=EXPERIMENT1_SKIP_FINALIZE_KV_CACHE_NOISE100, + prompt=_DEFAULT_PROMPT_1V, +) + + +OMNIDREAMS_RUNNERS: dict[str, RunnerConfig] = { + cfg.runner_name: cfg + for cfg in ( + RUNNER_SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE, + RUNNER_SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE_PERF, + ) +} +"""All shipped Omnidreams runners (single- and multi-view variants), +keyed by ``runner_name``.""" + +__all__ = [ + "OMNIDREAMS_RUNNERS", + "RUNNER_EXPERIMENT1_BASELINE", + "RUNNER_EXPERIMENT1_SKIP_FINALIZE_KV_CACHE", + "RUNNER_EXPERIMENT1_SKIP_FINALIZE_KV_CACHE_NOISE100", + "RUNNER_EXPERIMENT1_SKIP_FINALIZE_KV_CACHE_NOISE150", + "RUNNER_EXPERIMENT1_SKIP_FINALIZE_KV_CACHE_NOISE250", + "RUNNER_EXPERIMENT1_SKIP_FINALIZE_KV_CACHE_NOISE350", + "RUNNER_MV_2STEPS_CHUNK4_LOC8_PSHUFFLE_LIGHTTAE", + "RUNNER_SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE", + "RUNNER_SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE_NATIVE_PERF", + "RUNNER_SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE_PERF", + "RUNNER_SV_2STEPS_CHUNK2_LOC6_VAE_VAE", + "RUNNER_SV_2STEPS_CHUNK3_LOC6_VAE_VAE", + "RUNNER_SV_2STEPS_CHUNK4_LOC8_PSHUFFLE_LIGHTTAE", + "RUNNER_SV_35STEPS_CHUNK2_LOC24_COSMOS2_2B_RES720P_30FPS_HDMAP_VAE_MADS1M", + "RUNNER_SV_35STEPS_CHUNK48_LOC48_COSMOS2_2B_RES720P_30FPS_HDMAP_VAE_MADS1M", +] diff --git a/integrations/omnidreams/omnidreams/runtime/application/headless.py b/integrations/omnidreams/omnidreams/runtime/application/headless.py new file mode 100644 index 000000000..c04d32e5d --- /dev/null +++ b/integrations/omnidreams/omnidreams/runtime/application/headless.py @@ -0,0 +1,597 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +"""Headless Omnidreams application with HDMap input and video output.""" + +from __future__ import annotations + +import argparse +import copy +from collections.abc import Sequence +from dataclasses import dataclass, field +from pathlib import Path +from typing import Generic, TypeVar + +import torch +from flashdreams.infra.runner_io import ( + DEFAULT_RUNNER_INSTALL_HINT, + load_first_frame_tensor, +) +from flashdreams.runtime.application import ApplicationConfig +from flashdreams.runtime.builtin.application.video_output_application import ( + VideoOutputApplication, + VideoOutputApplicationConfig, +) +from flashdreams.runtime.global_condition import GlobalConditionHandler +from flashdreams.runtime.inference_runtime import ( + InferenceRuntime, + InferenceRuntimeConfig, +) +from flashdreams.runtime.input_system import UserInputHandler +from omnidreams.config import OMNIDREAMS_CONFIGS +from omnidreams.constants import NEGATIVE_PROMPT +from omnidreams.pipeline import OmnidreamsPipeline +from omnidreams.runtime.global_condition import ( + GlobalConditionHandler as OmnidreamsGlobalConditionHandler, +) +from omnidreams.runtime.global_condition import RawGlobalCondition +from omnidreams.runtime.inference_session import ( + InferenceGlobalCondition, + InferenceSession, +) +from omnidreams.runtime.user_input.hdmap_input_handler import HDMapInputHandler +from omnidreams.transformer import CosmosTransformerConfig + +RuntimeT = TypeVar("RuntimeT", bound=InferenceRuntime) +"""Inference-runtime type owned by the headless application.""" + +DEFAULT_TEXT_PROMPT = ( + "Driving scene from a front-facing car camera. Urban environment with roads, " + "vehicles, pedestrians, traffic signs, and buildings. Clear visibility, " + "realistic lighting, photorealistic quality. High resolution dashcam footage " + "of city driving." +) +"""Default positive prompt for a single-view Omnidreams rollout.""" + +DEFAULT_VIDEO_HEIGHT = 704 +"""Default pixel-space height for HDMap and first-frame inputs.""" + +DEFAULT_VIDEO_WIDTH = 1280 +"""Default pixel-space width for HDMap and first-frame inputs.""" + +DEFAULT_NUM_CHUNKS = 60 +"""Default number of autoregressive chunks generated by a headless rollout.""" + +DEFAULT_ARTIFACT_PATH = Path("outputs/omnidreams.mp4") +"""Default video artifact path for direct application construction.""" + +EXAMPLE_DATA_HF_REPO = "nvidia/omni-dreams-samples" +"""Hugging Face dataset containing single-view HDMap clips and first frames.""" + +EXAMPLE_DATA_HF_BROWSER_URL = ( + "https://huggingface.co/datasets/nvidia/omni-dreams-samples/tree/main/" + "data/single_view" +) +"""Browser URL listing the available single-view example UUIDs.""" + +DEFAULT_EXAMPLE_DATA_UUID = "239560dc-33d1-11ef-9720-00044bcbccac" +"""Default bundled single-view scene UUID.""" + + +@dataclass(kw_only=True) +class OmnidreamsInferenceRuntimeConfig( + InferenceRuntimeConfig["OmnidreamsInferenceRuntime"] +): + """Configuration for the runtime used by the headless CLI.""" + + _target: type[OmnidreamsInferenceRuntime] = field( + default_factory=lambda: OmnidreamsInferenceRuntime + ) + + device: str = "cuda" + """Device receiving the fully constructed inference pipeline.""" + + +class OmnidreamsInferenceRuntime(InferenceRuntime[InferenceSession]): + """Own an Omnidreams pipeline on the configured inference device.""" + + _pipeline: OmnidreamsPipeline + """Pipeline narrowed to the integration type after construction.""" + + def __init__(self, config: OmnidreamsInferenceRuntimeConfig) -> None: + """Construct and place the configured Omnidreams pipeline. + + Args: + config: Pipeline, session, and device configuration. + + Raises: + TypeError: The pipeline config does not construct Omnidreams. + """ + super().__init__(config) + if not isinstance(self._pipeline, OmnidreamsPipeline): + raise TypeError( + "OmnidreamsInferenceRuntime requires an OmnidreamsPipeline; " + f"got {type(self._pipeline).__name__}" + ) + device = f"cuda:{self._local_rank}" if self._world_size > 1 else config.device + self._pipeline = self._pipeline.to(device).eval() + + def warmup(self) -> None: + """Complete the optional warmup hook without a synthetic rollout.""" + + +@dataclass(kw_only=True) +class OmnidreamsHeadlessConfig( + VideoOutputApplicationConfig[RuntimeT], Generic[RuntimeT] +): + """Configuration for a headless HDMap-conditioned application.""" + + _target: type[OmnidreamsHeadless] = field( + default_factory=lambda: OmnidreamsHeadless + ) + + hdmap_path: str | Path | None = None + """HDMap video; required unless bundled example data supplies it.""" + + first_frame_path: str | Path | None = None + """Initial RGB image or video; required unless example data supplies it.""" + + example_data: bool = False + """Download bundled inputs for any path that is not configured.""" + + example_data_uuid: str = DEFAULT_EXAMPLE_DATA_UUID + """Bundled single-view scene UUID selected when ``example_data`` is enabled.""" + + artifact_path: str | Path = DEFAULT_ARTIFACT_PATH + """Destination written by the video output handler.""" + + text_prompt: str = DEFAULT_TEXT_PROMPT + """Positive prompt applied to the generated driving scene.""" + + negative_text_prompt: str = NEGATIVE_PROMPT + """Negative prompt used for classifier-free guidance.""" + + num_frames: int | None = None + """Exact generated frame count; mutually exclusive with ``num_chunks``.""" + + num_chunks: int | None = DEFAULT_NUM_CHUNKS + """Exact generated chunk count; defaults to ``DEFAULT_NUM_CHUNKS``.""" + + pixel_height: int = DEFAULT_VIDEO_HEIGHT + """Resize target height for HDMap videos and first-frame images.""" + + pixel_width: int = DEFAULT_VIDEO_WIDTH + """Resize target width for HDMap videos and first-frame images.""" + + +class OmnidreamsHeadless(VideoOutputApplication[RuntimeT], Generic[RuntimeT]): + """Run Omnidreams from an HDMap video and write generated video output.""" + + def __init__( + self, + config: OmnidreamsHeadlessConfig[RuntimeT], + ) -> None: + """Initialize all input, conditioning, inference, and output components. + + Args: + config: Runtime and application-facing rollout configuration. + + Raises: + ValueError: The rollout limit or raw global condition is invalid. + """ + _validate_generation_limit(config.num_frames, config.num_chunks) + hdmap_path, first_frame_path = _resolve_input_paths(config) + config.hdmap_path = hdmap_path + config.first_frame_path = first_frame_path + super().__init__(config, _placeholder_global_condition()) + + pipeline = self._inference_runtime._pipeline + if not isinstance(pipeline, OmnidreamsPipeline): + raise TypeError( + "OmnidreamsHeadless requires an OmnidreamsPipeline; " + f"got {type(pipeline).__name__}" + ) + first_frame_image = _load_first_frame( + first_frame_path, + pixel_height=config.pixel_height, + pixel_width=config.pixel_width, + device=pipeline.device, + dtype=pipeline.diffusion_model.dtype, + ) + self.handle_global_condition( + RawGlobalCondition( + text_prompt=config.text_prompt, + negative_text_prompt=config.negative_text_prompt, + first_frame_image=first_frame_image, + ) + ) + pipeline.release_oneshot_encoders() + + def _initialize_global_condition_handler( + self, config: ApplicationConfig[RuntimeT] + ) -> GlobalConditionHandler: + """Construct the raw-condition embedding handler for the pipeline. + + Args: + config: Application configuration accepted for the base hook contract. + + Returns: + Handler backed by the runtime pipeline's one-shot encoders. + + Raises: + TypeError: The configured runtime does not own an Omnidreams pipeline. + """ + del config + pipeline = self._inference_runtime._pipeline + if not isinstance(pipeline, OmnidreamsPipeline): + raise TypeError( + "OmnidreamsHeadless requires an OmnidreamsPipeline; " + f"got {type(pipeline).__name__}" + ) + return OmnidreamsGlobalConditionHandler(pipeline) + + def _initialize_user_input_handler( + self, config: ApplicationConfig[RuntimeT] + ) -> UserInputHandler: + """Construct the HDMap input handler for the Omnidreams pipeline. + + Args: + config: Application configuration accepted for the base hook contract. + + Returns: + Handler that yields correctly sized HDMap chunks on the pipeline device. + + Raises: + TypeError: The application was not given headless configuration, or the + configured runtime does not own an Omnidreams pipeline. + """ + if not isinstance(config, OmnidreamsHeadlessConfig): + raise TypeError( + "OmnidreamsHeadless requires OmnidreamsHeadlessConfig; " + f"got {type(config).__name__}" + ) + if config.hdmap_path is None: + raise ValueError("OmnidreamsHeadless requires an HDMap path") + pipeline = self._inference_runtime._pipeline + if not isinstance(pipeline, OmnidreamsPipeline): + raise TypeError( + "OmnidreamsHeadless requires an OmnidreamsPipeline; " + f"got {type(pipeline).__name__}" + ) + return HDMapInputHandler( + config.hdmap_path, + get_num_frames=pipeline.get_num_frames, + num_frames=config.num_frames, + num_chunks=config.num_chunks, + pixel_height=config.pixel_height, + pixel_width=config.pixel_width, + device=pipeline.device, + dtype=pipeline.diffusion_model.dtype, + ) + + +def download_single_view_example_data(uuid: str) -> tuple[Path, Path]: + """Download the HDMap video and first frame for one example UUID. + + Args: + uuid: UUID of a scene under the dataset's ``data/single_view`` directory. + + Returns: + Local cached paths to the HDMap video and first-frame image. + + Raises: + FileNotFoundError: The UUID contains no HDMap video. + RuntimeError: The UUID contains more than one HDMap video. + """ + from huggingface_hub import HfApi, hf_hub_download + from huggingface_hub.hf_api import RepoFile + + subdir = f"data/single_view/{uuid}" + entries = HfApi().list_repo_tree( + repo_id=EXAMPLE_DATA_HF_REPO, + repo_type="dataset", + path_in_repo=subdir, + recursive=False, + ) + files = [entry.path for entry in entries if isinstance(entry, RepoFile)] + hdmap_candidates = [path for path in files if path.endswith("_hdmap.mp4")] + if not hdmap_candidates: + raise FileNotFoundError( + f"No '*_hdmap.mp4' under {subdir!r} in Hugging Face dataset " + f"{EXAMPLE_DATA_HF_REPO!r}. Pick a UUID listed at " + f"{EXAMPLE_DATA_HF_BROWSER_URL} via --example-data-uuid, or supply " + "--hdmap-path and --first-frame-path explicitly." + ) + if len(hdmap_candidates) > 1: + raise RuntimeError( + f"Multiple '*_hdmap.mp4' files under {subdir!r} in " + f"{EXAMPLE_DATA_HF_REPO!r}: {hdmap_candidates}." + ) + + hdmap_path = Path( + hf_hub_download( + repo_id=EXAMPLE_DATA_HF_REPO, + repo_type="dataset", + filename=hdmap_candidates[0], + ) + ) + first_frame_path = Path( + hf_hub_download( + repo_id=EXAMPLE_DATA_HF_REPO, + repo_type="dataset", + filename=f"{subdir}/first_frame.png", + ) + ) + return hdmap_path, first_frame_path + + +def _resolve_input_paths( + config: OmnidreamsHeadlessConfig, +) -> tuple[Path, Path]: + """Resolve explicit or bundled HDMap and first-frame inputs. + + Args: + config: Headless application configuration containing input selection. + + Returns: + Resolved HDMap and first-frame paths. + + Raises: + ValueError: Either path is missing while example data is disabled. + """ + hdmap_path = config.hdmap_path + first_frame_path = config.first_frame_path + if config.example_data and (hdmap_path is None or first_frame_path is None): + example_hdmap_path, example_first_frame_path = ( + download_single_view_example_data(config.example_data_uuid) + ) + hdmap_path = hdmap_path or example_hdmap_path + first_frame_path = first_frame_path or example_first_frame_path + + if hdmap_path is None or first_frame_path is None: + missing = [ + name + for name, path in ( + ("--hdmap-path", hdmap_path), + ("--first-frame-path", first_frame_path), + ) + if path is None + ] + raise ValueError( + f"{', '.join(missing)} must be supplied unless example_data is enabled" + ) + return Path(hdmap_path), Path(first_frame_path) + + +def _validate_generation_limit(num_frames: int | None, num_chunks: int | None) -> None: + """Validate that exactly one positive rollout limit is configured.""" + if (num_frames is None) == (num_chunks is None): + raise ValueError("exactly one of num_frames or num_chunks must be configured") + for name, value in (("num_frames", num_frames), ("num_chunks", num_chunks)): + if value is not None and ( + isinstance(value, bool) or not isinstance(value, int) or value <= 0 + ): + raise ValueError(f"{name} must be a positive integer; got {value!r}") + + +def _headless_config_names() -> tuple[str, ...]: + """Return shipped single-view pipeline names supported by this application.""" + return tuple( + sorted( + name + for name, config in OMNIDREAMS_CONFIGS.items() + if isinstance(config.diffusion_model.transformer, CosmosTransformerConfig) + and config.diffusion_model.transformer.num_views == 1 + ) + ) + + +def _parse_bool(value: str) -> bool: + """Parse a command-line boolean accepted as an explicit value.""" + normalized = value.strip().lower() + if normalized in {"1", "true", "yes", "on"}: + return True + if normalized in {"0", "false", "no", "off"}: + return False + raise argparse.ArgumentTypeError(f"expected a boolean value, got {value!r}") + + +def build_parser() -> argparse.ArgumentParser: + """Build the ``omnidreams-headless`` command-line parser.""" + parser = argparse.ArgumentParser( + prog="omnidreams-headless", + description=( + "Run a single-view Omnidreams rollout from an HDMap video and " + "write the generated frames to a video artifact." + ), + ) + parser.add_argument( + "--config", + required=True, + choices=_headless_config_names(), + help="Shipped single-view pipeline configuration to run.", + ) + parser.add_argument( + "--artifact-path", + type=Path, + help="Destination video artifact path; defaults to outputs/.mp4.", + ) + parser.add_argument( + "--hdmap-path", + type=Path, + help="HDMap video consumed one inference chunk at a time.", + ) + parser.add_argument( + "--first-frame-path", + type=Path, + help=( + "Initial RGB image or video. Video inputs use their first frame and " + "must match the HDMap resolution." + ), + ) + parser.add_argument( + "--example-data", + nargs="?", + const=True, + default=False, + type=_parse_bool, + help="Download bundled inputs for paths that were not supplied.", + ) + parser.add_argument( + "--example-data-uuid", + default=DEFAULT_EXAMPLE_DATA_UUID, + help="Bundled single-view scene UUID.", + ) + parser.add_argument( + "--text-prompt", + default=DEFAULT_TEXT_PROMPT, + help="Positive text prompt for the generated driving scene.", + ) + parser.add_argument( + "--negative-text-prompt", + default=NEGATIVE_PROMPT, + help="Negative text prompt for classifier-free guidance.", + ) + parser.add_argument( + "--device", + default="cuda", + help="Inference device; defaults to cuda.", + ) + + parser.add_argument( + "--pixel-height", + type=int, + default=DEFAULT_VIDEO_HEIGHT, + help=f"Input resize height; defaults to {DEFAULT_VIDEO_HEIGHT}.", + ) + parser.add_argument( + "--pixel-width", + type=int, + default=DEFAULT_VIDEO_WIDTH, + help=f"Input resize width; defaults to {DEFAULT_VIDEO_WIDTH}.", + ) + + rollout_limit = parser.add_mutually_exclusive_group() + rollout_limit.add_argument( + "--num-frames", + type=int, + help="Exact frame count ending on an autoregressive chunk boundary.", + ) + rollout_limit.add_argument( + "--num-chunks", + "--total-blocks", + dest="num_chunks", + type=int, + help=( + "Exact number of autoregressive chunks to generate; defaults to " + f"{DEFAULT_NUM_CHUNKS} when neither rollout limit is provided." + ), + ) + return parser + + +def _load_first_frame( + path: Path, + *, + pixel_height: int, + pixel_width: int, + device: torch.device, + dtype: torch.dtype, +) -> torch.Tensor: + """Load one normalized first frame in ``[1, 1, 1, 3, H, W]`` layout.""" + frame = load_first_frame_tensor( + path, + pixel_height=pixel_height, + pixel_width=pixel_width, + device=device, + dtype=dtype, + allow_video=True, + install_hint=DEFAULT_RUNNER_INSTALL_HINT, + ) + return frame.unsqueeze(1).unsqueeze(1) + + +def _placeholder_global_condition() -> InferenceGlobalCondition: + """Build the temporary condition replaced before the application runs.""" + return InferenceGlobalCondition( + text_embeddings=torch.empty(1, 1, 1, 1), + image_embeddings=torch.empty(1, 1, 1, 1, 1, 1), + ) + + +def _run_from_args(args: argparse.Namespace) -> None: + """Construct and run the headless application from parsed arguments.""" + artifact_path = args.artifact_path or Path("outputs") / f"{args.config}.mp4" + num_chunks = args.num_chunks + if args.num_frames is None and num_chunks is None: + num_chunks = DEFAULT_NUM_CHUNKS + + pipeline_config = copy.deepcopy(OMNIDREAMS_CONFIGS[args.config]) + runtime_config = OmnidreamsInferenceRuntimeConfig( + pipeline=pipeline_config, + session_type=InferenceSession, + device=args.device, + ) + application_config = OmnidreamsHeadlessConfig( + inference_runtime=runtime_config, + artifact_path=artifact_path, + hdmap_path=args.hdmap_path, + first_frame_path=args.first_frame_path, + example_data=args.example_data, + example_data_uuid=args.example_data_uuid, + text_prompt=args.text_prompt, + negative_text_prompt=args.negative_text_prompt, + num_frames=args.num_frames, + num_chunks=num_chunks, + pixel_height=args.pixel_height, + pixel_width=args.pixel_width, + ) + application = OmnidreamsHeadless(application_config) + application.run() + + +def main(argv: Sequence[str] | None = None) -> int: + """Parse command-line arguments and run the headless application. + + Args: + argv: Optional explicit arguments; ``None`` reads from ``sys.argv``. + + Returns: + Process exit status. + """ + args = build_parser().parse_args(argv) + _run_from_args(args) + return 0 + + +__all__ = [ + "DEFAULT_ARTIFACT_PATH", + "DEFAULT_EXAMPLE_DATA_UUID", + "DEFAULT_NUM_CHUNKS", + "DEFAULT_TEXT_PROMPT", + "DEFAULT_VIDEO_HEIGHT", + "DEFAULT_VIDEO_WIDTH", + "OmnidreamsHeadless", + "OmnidreamsHeadlessConfig", + "OmnidreamsInferenceRuntime", + "OmnidreamsInferenceRuntimeConfig", + "build_parser", + "main", +] + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/integrations/omnidreams/omnidreams/runtime/global_condition.py b/integrations/omnidreams/omnidreams/runtime/global_condition.py new file mode 100644 index 000000000..14a0ebf03 --- /dev/null +++ b/integrations/omnidreams/omnidreams/runtime/global_condition.py @@ -0,0 +1,155 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Raw and embedded global conditions for Omnidreams inference.""" + +from typing import Annotated, TypeAlias + +import torch +from flashdreams.runtime.global_condition import ( + GlobalConditionHandler as BaseGlobalConditionHandler, +) +from flashdreams.runtime.global_condition import ( + RawGlobalCondition as BaseRawGlobalCondition, +) +from omnidreams.pipeline import OmnidreamsPipeline +from omnidreams.runtime.inference_session import InferenceGlobalCondition +from pydantic import AfterValidator, StringConstraints, validate_call +from torch import Tensor + + +def _validate_first_frame_image(tensor: Tensor) -> Tensor: + """Validate a normalized single-view first-frame image tensor.""" + if tensor.ndim != 6: + raise ValueError( + "expected a rank-6 first-frame image in " + "[B=1, V=1, T=1, C=3, H, W] layout; " + f"got rank {tensor.ndim} with shape {tuple(tensor.shape)}" + ) + if tuple(tensor.shape[:4]) != (1, 1, 1, 3): + raise ValueError( + "expected first-frame image shape [B=1, V=1, T=1, C=3, H, W]; " + f"got {tuple(tensor.shape)}" + ) + if tensor.shape[-2] <= 0 or tensor.shape[-1] <= 0: + raise ValueError( + "expected positive first-frame image spatial dimensions; " + f"got {tuple(tensor.shape)}" + ) + if not tensor.dtype.is_floating_point: + raise ValueError( + "first-frame image must use a floating-point dtype for normalized " + f"[-1, 1] pixels; got {tensor.dtype}" + ) + return tensor + + +_TextPrompt: TypeAlias = Annotated[ + str, StringConstraints(strip_whitespace=True, min_length=1) +] +"""Validated non-empty positive text prompt.""" + +_NegativeTextPrompt: TypeAlias = Annotated[ + str, StringConstraints(strip_whitespace=True) +] +"""Validated negative text prompt; the empty prompt remains valid.""" + +_FirstFrameImage: TypeAlias = Annotated[ + Tensor, AfterValidator(_validate_first_frame_image) +] +"""Normalized first-frame pixels in ``[1, 1, 1, 3, H, W]`` layout.""" + + +class RawGlobalCondition(BaseRawGlobalCondition): + """Application-facing Omnidreams rollout conditions.""" + + text_prompt: _TextPrompt + """Positive prompt applied to the generated driving scene.""" + + negative_text_prompt: _NegativeTextPrompt + """Negative prompt embedded for classifier-free guidance.""" + + first_frame_image: _FirstFrameImage + """Normalized first-frame pixels in ``[1, 1, 1, 3, H, W]`` layout.""" + + +class GlobalConditionHandler(BaseGlobalConditionHandler): + """Embed raw Omnidreams prompts and a first-frame image.""" + + _pipeline: OmnidreamsPipeline + """Pipeline whose one-shot encoders produce the rollout embeddings.""" + + def __init__(self, pipeline: OmnidreamsPipeline) -> None: + """Initialize the handler with a pipeline containing one-shot encoders. + + Args: + pipeline: Omnidreams pipeline used to validate and embed conditions. + + Raises: + RuntimeError: The pipeline's text or image encoder is not loaded. + """ + self._pipeline = pipeline + self._require_encoders() + + @torch.no_grad() + @validate_call + def __call__( + self, raw_global_condition: RawGlobalCondition + ) -> InferenceGlobalCondition: + """Embed raw prompts and first-frame pixels for an inference session. + + Args: + raw_global_condition: Positive and negative prompts plus normalized + first-frame pixels. + + Returns: + Model-ready text, negative-text, and image embeddings. + + Raises: + RuntimeError: The pipeline's text or image encoder is not loaded. + ValidationError: The raw condition fails Pydantic validation. + ValueError: The image resolution violates pipeline alignment. + """ + text_encoder, image_encoder = self._require_encoders() + first_frame_image = raw_global_condition["first_frame_image"] + self._pipeline._validate_image_resolution(first_frame_image) + + text_embeddings = text_encoder([raw_global_condition["text_prompt"]]).unsqueeze( + 0 + ) + negative_text_embeddings = text_encoder( + [raw_global_condition["negative_text_prompt"]] + ).unsqueeze(0) + image_embeddings = image_encoder(first_frame_image) + return InferenceGlobalCondition( + text_embeddings=text_embeddings, + negative_text_embeddings=negative_text_embeddings, + image_embeddings=image_embeddings, + ) + + def _require_encoders(self): + """Return loaded one-shot encoders or fail with lifecycle guidance.""" + text_encoder = self._pipeline.text_encoder + image_encoder = self._pipeline.image_encoder + if text_encoder is None or image_encoder is None: + raise RuntimeError( + "GlobalConditionHandler requires loaded Omnidreams text and image " + "encoders; construct the pipeline with both encoder configs and " + "do not release one-shot encoders before conversion" + ) + return text_encoder, image_encoder + + +__all__ = ["GlobalConditionHandler", "RawGlobalCondition"] diff --git a/integrations/omnidreams/omnidreams/runtime/inference_session.py b/integrations/omnidreams/omnidreams/runtime/inference_session.py index 5195f8346..eaa0d2b0c 100644 --- a/integrations/omnidreams/omnidreams/runtime/inference_session.py +++ b/integrations/omnidreams/omnidreams/runtime/inference_session.py @@ -13,7 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""OmniDreams inference session with embedding and HDMap conditions.""" +"""Omnidreams inference session with embedding and HDMap conditions.""" from typing import Annotated, TypeAlias, cast @@ -98,14 +98,14 @@ def _validate_image_embeddings(tensor: Tensor) -> Tensor: class InferenceUserCondition(BaseInferenceUserCondition): - """Per-step HDMap condition for OmniDreams inference.""" + """Per-step HDMap condition for Omnidreams inference.""" hdmap: _HDMapTensor """HDMap pixels ``[B, V, T, 3, H, W]`` for the next video chunk.""" class InferenceGlobalCondition(BaseInferenceGlobalCondition): - """Rollout-wide embedding conditions for OmniDreams inference.""" + """Rollout-wide embedding conditions for Omnidreams inference.""" text_embeddings: _TextEmbeddingsTensor """Text embeddings ``[B, V, L, D]`` for the rollout prompts.""" @@ -120,7 +120,7 @@ class InferenceGlobalCondition(BaseInferenceGlobalCondition): InferenceInput: TypeAlias = BaseInferenceInput[ InferenceUserCondition, InferenceGlobalCondition ] -"""OmniDreams conditions consumed by one inference step.""" +"""Omnidreams conditions consumed by one inference step.""" class _InferenceValidationContext(TypedDict): @@ -227,10 +227,10 @@ def _validate_condition_shapes( class InferenceSession(BaseInferenceSession): - """Stateful OmniDreams inference session backed by a per-rollout cache.""" + """Stateful Omnidreams inference session backed by a per-rollout cache.""" _pipeline: OmnidreamsPipeline - """OmniDreams pipeline shared with the inference runtime.""" + """Omnidreams pipeline shared with the inference runtime.""" _cache: OmnidreamsPipelineCache | None """Per-rollout cache; ``None`` until global conditions initialize it.""" @@ -256,7 +256,7 @@ def __init__( """Initialize the session with a presentation frame rate. Args: - pipeline: OmniDreams pipeline to drive. + pipeline: Omnidreams pipeline to drive. presentation_fps: Frame rate for output presentation timestamps. Raises: @@ -275,7 +275,7 @@ def reset(self) -> None: self._presented_frame_count = 0 def step(self, inference_input: InferenceInput) -> FrameChunkOutput: - """Generate one video chunk from validated OmniDreams conditions. + """Generate one video chunk from validated Omnidreams conditions. Args: inference_input: Per-step HDMap and optional first-step embeddings. diff --git a/integrations/omnidreams/omnidreams/runtime/user_input/hdmap_input_handler.py b/integrations/omnidreams/omnidreams/runtime/user_input/hdmap_input_handler.py index 794485a5c..04a80a3fc 100644 --- a/integrations/omnidreams/omnidreams/runtime/user_input/hdmap_input_handler.py +++ b/integrations/omnidreams/omnidreams/runtime/user_input/hdmap_input_handler.py @@ -13,7 +13,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""HDMap video input handler for OmniDreams inference.""" + +"""HDMap video input handler for Omnidreams inference.""" from __future__ import annotations @@ -24,6 +25,7 @@ from flashdreams.infra.runner_io import ( DEFAULT_RUNNER_INSTALL_HINT, read_video_rgb, + resize_rgb_video, rgb_video_to_normalized_tensor, ) from flashdreams.runtime.input_system import UserInputHandler @@ -37,14 +39,24 @@ class HDMapInputHandler(UserInputHandler): hdmap_video_path: Path to an RGB HDMap video. get_num_frames: Optional function mapping an autoregressive step index to its required number of pixel frames. Pass the pipeline's - get_num_frames method when feeding an OmniDreams inference session. + ``get_num_frames`` method when feeding an Omnidreams inference session. When omitted, each call returns one frame. + num_frames: Exact number of frames to return across all calls. The count + must end on a chunk boundary. Mutually exclusive with ``num_chunks``. + num_chunks: Exact number of complete conditions to return. Mutually + exclusive with ``num_frames``. + pixel_height: Optional resize target height. Must be supplied together + with ``pixel_width``. + pixel_width: Optional resize target width. Must be supplied together + with ``pixel_height``. device: Device on which returned HDMap tensors are stored. dtype: Floating-point dtype used for normalized HDMap pixels. Raises: - TypeError: If dtype is not a floating-point dtype. - ValueError: If the decoded video is empty or malformed. + TypeError: ``dtype`` is not floating point or the frame-count provider + returns a non-integer value while resolving a requested limit. + ValueError: The decoded video is malformed, a limit is invalid, the exact + frame count does not end on a chunk boundary, or the video is too short. """ def __init__( @@ -52,12 +64,21 @@ def __init__( hdmap_video_path: str | Path, *, get_num_frames: Callable[[int], int] | None = None, + num_frames: int | None = None, + num_chunks: int | None = None, + pixel_height: int | None = None, + pixel_width: int | None = None, device: torch.device | str = "cpu", dtype: torch.dtype = torch.float32, ) -> None: """Load and normalize the HDMap video for iterative consumption.""" if not dtype.is_floating_point: raise TypeError(f"dtype must be floating point; got {dtype}") + _validate_rollout_limits(num_frames=num_frames, num_chunks=num_chunks) + _validate_resize_dimensions( + pixel_height=pixel_height, + pixel_width=pixel_width, + ) self.hdmap_video_path = Path(hdmap_video_path) video = read_video_rgb( @@ -73,6 +94,13 @@ def __init__( raise ValueError( f"HDMap video must have non-empty dimensions: {self.hdmap_video_path}" ) + if pixel_height is not None and pixel_width is not None: + video = resize_rgb_video( + video, + pixel_height=pixel_height, + pixel_width=pixel_width, + install_hint=DEFAULT_RUNNER_INSTALL_HINT, + ) hdmap = rgb_video_to_normalized_tensor( video, @@ -85,6 +113,10 @@ def __init__( self._get_num_frames = get_num_frames or _one_frame_per_condition self._autoregressive_index = 0 self._next_frame_index = 0 + self._num_chunks = self._resolve_num_chunks( + num_frames=num_frames, + num_chunks=num_chunks, + ) def __call__(self) -> InferenceUserCondition: """Return the next complete HDMap condition. @@ -93,34 +125,112 @@ def __call__(self) -> InferenceUserCondition: Normalized HDMap pixels for the next inference step. Raises: - TypeError: If the frame-count provider does not return an integer. - ValueError: If the frame-count provider returns a non-positive count. - StopIteration: If no complete condition remains in the video. + TypeError: The frame-count provider does not return an integer. + ValueError: The frame-count provider returns a non-positive count. + StopIteration: The configured limit or available complete video chunks + have been exhausted. """ - num_frames = self._get_num_frames(self._autoregressive_index) + if ( + self._num_chunks is not None + and self._autoregressive_index >= self._num_chunks + ): + raise StopIteration + + num_frames = self._validated_num_frames(self._autoregressive_index) + end_frame_index = self._next_frame_index + num_frames + if end_frame_index > self._hdmap.shape[2]: + raise StopIteration + + condition = InferenceUserCondition( + hdmap=self._hdmap[:, :, self._next_frame_index : end_frame_index] + ) + self._next_frame_index = end_frame_index + self._autoregressive_index += 1 + return condition + + def _validated_num_frames(self, autoregressive_index: int) -> int: + """Return the validated frame count for one autoregressive step.""" + num_frames = self._get_num_frames(autoregressive_index) if isinstance(num_frames, bool) or not isinstance(num_frames, int): raise TypeError( "get_num_frames must return an integer; " f"got {num_frames!r} at autoregressive index " - f"{self._autoregressive_index}" + f"{autoregressive_index}" ) if num_frames <= 0: raise ValueError( "get_num_frames must return a positive value; " f"got {num_frames} at autoregressive index " - f"{self._autoregressive_index}" + f"{autoregressive_index}" ) + return num_frames - end_frame_index = self._next_frame_index + num_frames - if end_frame_index > self._hdmap.shape[2]: - raise StopIteration + def _resolve_num_chunks( + self, + *, + num_frames: int | None, + num_chunks: int | None, + ) -> int | None: + """Resolve an optional exact frame or chunk limit to a chunk count.""" + available_num_frames = int(self._hdmap.shape[2]) + if num_frames is not None and num_frames > available_num_frames: + raise ValueError( + f"requested rollout requires {num_frames} HDMap frames; " + f"video contains {available_num_frames}" + ) - condition = InferenceUserCondition( - hdmap=self._hdmap[:, :, self._next_frame_index : end_frame_index] - ) - self._next_frame_index = end_frame_index - self._autoregressive_index += 1 - return condition + resolved_num_chunks = num_chunks + required_num_frames = 0 + if num_frames is not None: + resolved_num_chunks = 0 + while required_num_frames < num_frames: + required_num_frames += self._validated_num_frames(resolved_num_chunks) + resolved_num_chunks += 1 + if required_num_frames != num_frames: + raise ValueError( + f"num_frames={num_frames} does not end on an autoregressive " + f"chunk boundary; the next boundary is {required_num_frames}" + ) + elif num_chunks is not None: + required_num_frames = sum( + self._validated_num_frames(index) for index in range(num_chunks) + ) + + if required_num_frames > available_num_frames: + raise ValueError( + f"requested rollout requires {required_num_frames} HDMap frames; " + f"video contains {available_num_frames}" + ) + return resolved_num_chunks + + +def _validate_rollout_limits(*, num_frames: int | None, num_chunks: int | None) -> None: + """Validate optional mutually exclusive rollout limits.""" + if num_frames is not None and num_chunks is not None: + raise ValueError("num_frames and num_chunks are mutually exclusive") + for name, value in (("num_frames", num_frames), ("num_chunks", num_chunks)): + if value is not None and ( + isinstance(value, bool) or not isinstance(value, int) or value <= 0 + ): + raise ValueError(f"{name} must be a positive integer; got {value!r}") + + +def _validate_resize_dimensions( + *, + pixel_height: int | None, + pixel_width: int | None, +) -> None: + """Validate optional paired resize dimensions.""" + if (pixel_height is None) != (pixel_width is None): + raise ValueError("pixel_height and pixel_width must be supplied together") + for name, value in ( + ("pixel_height", pixel_height), + ("pixel_width", pixel_width), + ): + if value is not None and ( + isinstance(value, bool) or not isinstance(value, int) or value <= 0 + ): + raise ValueError(f"{name} must be a positive integer; got {value!r}") def _one_frame_per_condition(_autoregressive_index: int) -> int: diff --git a/integrations/omnidreams/pyproject.toml b/integrations/omnidreams/pyproject.toml index eafd80a1c..f92acfc36 100644 --- a/integrations/omnidreams/pyproject.toml +++ b/integrations/omnidreams/pyproject.toml @@ -92,6 +92,9 @@ dev = [ ] [project.scripts] +# Generate a video artifact from prerecorded HDMap and first-frame inputs. +omnidreams-headless = "omnidreams.runtime.application.headless:main" + # ``omnidreams-prepare`` stages resources used by *both* demo paths # (desktop ``interactive-drive`` *and* ``omnidreams.webrtc.server``): # the scene USDZs from ``nvidia/omni-dreams-scenes`` and the @@ -124,8 +127,8 @@ interactive-drive-configuration = "omnidreams.interactive_drive.input_config.app # scans this group at CLI startup; the entry-point name itself is purely # informational, the registry key always comes from ``cfg.runner_name``. [project.entry-points."flashdreams.runner_configs"] -"omnidreams-sv-2steps-chunk2-loc6-lightvae-lighttae" = "omnidreams.config:RUNNER_SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE" -"omnidreams-sv-2steps-chunk2-loc6-lightvae-lighttae-perf" = "omnidreams.config:RUNNER_SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE_PERF" +"omnidreams-sv-2steps-chunk2-loc6-lightvae-lighttae" = "omnidreams.runner_config:RUNNER_SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE" +"omnidreams-sv-2steps-chunk2-loc6-lightvae-lighttae-perf" = "omnidreams.runner_config:RUNNER_SV_2STEPS_CHUNK2_LOC6_LIGHTVAE_LIGHTTAE_PERF" [tool.setuptools.packages.find] include = ["omnidreams*"] diff --git a/integrations/omnidreams/tests/runtime/test_global_condition.py b/integrations/omnidreams/tests/runtime/test_global_condition.py new file mode 100644 index 000000000..00c1f90ab --- /dev/null +++ b/integrations/omnidreams/tests/runtime/test_global_condition.py @@ -0,0 +1,176 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""CPU tests for Omnidreams raw global-condition embedding.""" + +from typing import Any, cast + +import pytest +import torch +from omnidreams.pipeline import OmnidreamsPipeline +from omnidreams.runtime.global_condition import ( + GlobalConditionHandler, + RawGlobalCondition, +) +from omnidreams.runtime.inference_session import InferenceGlobalCondition +from pydantic import ValidationError +from torch import Tensor + +pytestmark = pytest.mark.ci_cpu + + +class _TextEncoder: + """Text-encoder test double that records prompts and returns embeddings.""" + + def __init__(self) -> None: + """Initialize the prompt record.""" + self.calls: list[list[str]] = [] + + def __call__(self, prompts: list[str]) -> Tensor: + """Return one deterministic embedding per prompt.""" + self.calls.append(prompts) + value = float(len(self.calls)) + return torch.full((len(prompts), 2, 4), value) + + +class _ImageEncoder: + """Image-encoder test double that records and spatially pools pixels.""" + + def __init__(self) -> None: + """Initialize the image record.""" + self.calls: list[Tensor] = [] + + def __call__(self, image: Tensor) -> Tensor: + """Return a small latent while preserving batch, view, and time axes.""" + self.calls.append(image) + return image.mean(dim=(-2, -1), keepdim=True) + + +class _Pipeline: + """Pipeline test double exposing the one-shot conditioning contract.""" + + def __init__(self) -> None: + """Initialize loaded encoders and image-validation records.""" + self.text_encoder: _TextEncoder | None = _TextEncoder() + self.image_encoder: _ImageEncoder | None = _ImageEncoder() + self.validated_images: list[Tensor] = [] + + def _validate_image_resolution(self, image: Tensor) -> None: + """Record the image passed through pipeline alignment validation.""" + self.validated_images.append(image) + + +def _handler() -> tuple[GlobalConditionHandler, _Pipeline]: + """Build a handler backed by lightweight one-shot encoders.""" + pipeline = _Pipeline() + return GlobalConditionHandler(cast(OmnidreamsPipeline, pipeline)), pipeline + + +def test_global_condition_handler_embeds_prompts_and_first_frame() -> None: + """Verify conversion produces the inference session's embedding layouts.""" + handler, pipeline = _handler() + first_frame_image = torch.full((1, 1, 1, 3, 8, 16), 0.5) + + condition = handler( + RawGlobalCondition( + text_prompt=" drive through a city ", + negative_text_prompt=" blurry ", + first_frame_image=first_frame_image, + ) + ) + + assert isinstance(condition, InferenceGlobalCondition) + assert pipeline.validated_images == [first_frame_image] + assert pipeline.text_encoder is not None + assert pipeline.text_encoder.calls == [["drive through a city"], ["blurry"]] + assert pipeline.image_encoder is not None + assert pipeline.image_encoder.calls == [first_frame_image] + assert condition.text_embeddings.shape == (1, 1, 2, 4) + assert condition.negative_text_embeddings is not None + assert condition.negative_text_embeddings.shape == (1, 1, 2, 4) + assert condition.image_embeddings.shape == (1, 1, 1, 3, 1, 1) + torch.testing.assert_close(condition.text_embeddings, torch.ones(1, 1, 2, 4)) + torch.testing.assert_close( + condition.negative_text_embeddings, + torch.full((1, 1, 2, 4), 2.0), + ) + + +@pytest.mark.parametrize( + "first_frame_image", + [ + torch.zeros(1, 1, 3, 8, 8), + torch.zeros(1, 2, 1, 3, 8, 8), + torch.zeros(1, 1, 1, 4, 8, 8), + torch.zeros(1, 1, 1, 3, 0, 8), + torch.zeros(1, 1, 1, 3, 8, 8, dtype=torch.uint8), + ], +) +def test_global_condition_handler_rejects_invalid_first_frame( + first_frame_image: Tensor, +) -> None: + """Verify raw first-frame tensors satisfy the single-view image contract.""" + handler, _pipeline = _handler() + + with pytest.raises(ValidationError): + handler( + cast( + Any, + { + "text_prompt": "city", + "negative_text_prompt": "blur", + "first_frame_image": first_frame_image, + }, + ) + ) + + +def test_global_condition_handler_rejects_invalid_prompt_and_extra_fields() -> None: + """Verify Pydantic validates raw prompt fields and rejects extras.""" + handler, _pipeline = _handler() + first_frame_image = torch.zeros(1, 1, 1, 3, 8, 8) + + with pytest.raises(ValidationError): + handler( + cast( + Any, + { + "text_prompt": " ", + "negative_text_prompt": "blur", + "first_frame_image": first_frame_image, + }, + ) + ) + with pytest.raises(ValidationError): + handler( + cast( + Any, + { + "text_prompt": "city", + "negative_text_prompt": "blur", + "first_frame_image": first_frame_image, + "unexpected": True, + }, + ) + ) + + +def test_global_condition_handler_requires_loaded_encoders() -> None: + """Verify construction fails after either one-shot encoder is released.""" + pipeline = _Pipeline() + pipeline.text_encoder = None + + with pytest.raises(RuntimeError, match="requires loaded.*text and image"): + GlobalConditionHandler(cast(OmnidreamsPipeline, pipeline)) diff --git a/integrations/omnidreams/tests/runtime/test_headless_application.py b/integrations/omnidreams/tests/runtime/test_headless_application.py new file mode 100644 index 000000000..b6909a024 --- /dev/null +++ b/integrations/omnidreams/tests/runtime/test_headless_application.py @@ -0,0 +1,599 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +"""CPU tests for the headless Omnidreams application.""" + +from dataclasses import dataclass, field +from pathlib import Path + +import pytest +import torch +from flashdreams.infra.decoder import StreamingVideoDecoder +from flashdreams.infra.diffusion.model import DiffusionModelConfig +from flashdreams.infra.diffusion.scheduler.fm_euler import ( + FlowMatchEulerDiscreteSchedulerConfig, +) +from flashdreams.infra.encoder import Encoder +from flashdreams.infra.encoder.text.cosmos_reason1 import ( + CosmosReason1TextEncoderConfig, +) +from flashdreams.recipes.taehv import TeahvVAEDecoder, TeahvVAEDecoderConfig +from flashdreams.recipes.taehv.impl import TAEHVCache +from flashdreams.runtime.builtin.application.video_output_application import ( + VideoOutputApplicationConfig, +) +from flashdreams.runtime.builtin.inference_output.handler.video_output_handler import ( + VideoOutputHandler, +) +from omnidreams.encoder.pixel_shuffle import PixelShuffleVAEEncoderConfig +from omnidreams.pipeline import OmnidreamsPipeline, OmnidreamsPipelineConfig +from omnidreams.runtime.application import headless as headless_module +from omnidreams.runtime.application.headless import ( + OmnidreamsHeadless, + OmnidreamsHeadlessConfig, + OmnidreamsInferenceRuntime, + OmnidreamsInferenceRuntimeConfig, +) +from omnidreams.runtime.global_condition import GlobalConditionHandler +from omnidreams.runtime.inference_session import ( + InferenceGlobalCondition, + InferenceSession, + InferenceUserCondition, +) +from omnidreams.runtime.user_input import hdmap_input_handler +from omnidreams.runtime.user_input.hdmap_input_handler import HDMapInputHandler +from omnidreams.transformer import CosmosTransformerConfig +from omnidreams.transformer.impl.network import CosmosDiTNetworkConfig +from omnidreams.vae_native import OmnidreamsWanVAEEncoderConfig +from torch import Tensor + +pytestmark = pytest.mark.ci_cpu + + +@dataclass(kw_only=True) +class _TextEncoderConfig(CosmosReason1TextEncoderConfig): + """Configure the checkpoint-free text encoder used by the CPU pipeline.""" + + _target: type["_TextEncoder"] = field(default_factory=lambda: _TextEncoder) + + +class _TextEncoder(Encoder): + """Produce deterministic text embeddings without loading a checkpoint.""" + + def __init__(self, config: CosmosReason1TextEncoderConfig) -> None: + """Initialize the stateless encoder contract.""" + super().__init__(config) + + def forward(self, prompts: list[str]) -> Tensor: + """Return one fixed-size embedding sequence per prompt.""" + return torch.ones(len(prompts), 2, 4) + + +@dataclass(kw_only=True) +class _ImageEncoderConfig(OmnidreamsWanVAEEncoderConfig): + """Configure the checkpoint-free image encoder used by the CPU pipeline.""" + + _target: type["_ImageEncoder"] = field(default_factory=lambda: _ImageEncoder) + + +class _ImageEncoder(Encoder): + """Produce deterministic first-frame latents without loading a checkpoint.""" + + def __init__(self, config: OmnidreamsWanVAEEncoderConfig) -> None: + """Initialize the stateless encoder contract.""" + super().__init__(config) + + def forward(self, image: Tensor) -> Tensor: + """Pool pixels and expand them to the Omnidreams latent-channel count.""" + pooled = image.mean(dim=(-3, -2, -1), keepdim=True) + return pooled.expand(*image.shape[:3], 16, 1, 1) + + +@dataclass(kw_only=True) +class _CPUDecoderConfig(TeahvVAEDecoderConfig): + """Configure the checkpoint-free decoder used by the CPU pipeline.""" + + _target: type["_CPUDecoder"] = field(default_factory=lambda: _CPUDecoder) + + +class _CPUDecoder(TeahvVAEDecoder): + """Preserve the concrete TAEHV contract without loading decoder weights.""" + + def __init__(self, config: TeahvVAEDecoderConfig) -> None: + """Initialize only the streaming decoder interface.""" + StreamingVideoDecoder.__init__(self, config) + + def initialize_autoregressive_cache(self) -> TAEHVCache: + """Return an empty TAEHV-compatible cache.""" + return TAEHVCache() + + def forward( + self, + input: Tensor, + autoregressive_index: int = 0, + cache: TAEHVCache | None = None, + ) -> Tensor: + """Expose three latent channels as a cheap decoded video.""" + del autoregressive_index, cache + return input[..., :3, :, :] + + +def _pipeline_config() -> OmnidreamsPipelineConfig: + """Build an actual Omnidreams pipeline from tiny CPU components.""" + return OmnidreamsPipelineConfig( + name="test-omnidreams-headless", + text_encoder=_TextEncoderConfig(), + image_encoder=_ImageEncoderConfig(), + encoder=PixelShuffleVAEEncoderConfig(), + decoder=_CPUDecoderConfig(), + diffusion_model=DiffusionModelConfig( + transformer=CosmosTransformerConfig( + network=CosmosDiTNetworkConfig( + in_channels=16, + out_channels=16, + patch_spatial=1, + patch_temporal=1, + model_channels=12, + num_blocks=0, + num_heads=1, + mlp_ratio=1.0, + concat_padding_mask=False, + use_adaln_lora=False, + use_crossattn_projection=False, + crossattn_emb_channels=4, + additional_concat_ch=192, + ), + dtype=torch.float32, + checkpoint_path=None, + batch_shape=(1,), + num_views=1, + len_t=2, + h_extrapolation_ratio=1.0, + w_extrapolation_ratio=1.0, + window_size_t=2, + sink_size_t=0, + compile_network=False, + use_cuda_graph=False, + skip_finalize_kv_cache=True, + ), + scheduler=FlowMatchEulerDiscreteSchedulerConfig( + num_inference_steps=1, + fixed_timesteps=(1000.0, 0.0), + ), + seed=0, + ), + ) + + +def _runtime_config() -> OmnidreamsInferenceRuntimeConfig: + """Build the production runtime around the checkpoint-free CPU pipeline.""" + return OmnidreamsInferenceRuntimeConfig( + pipeline=_pipeline_config(), + session_type=InferenceSession, + device="cpu", + ) + + +def _patch_hdmap_video(monkeypatch: pytest.MonkeyPatch, *, num_frames: int) -> None: + """Patch HDMap decoding with an in-memory RGB video.""" + video = torch.zeros(num_frames, 2, 3, 3, dtype=torch.uint8) + + def normalize_video( + value: Tensor, + *, + device: torch.device, + dtype: torch.dtype, + ) -> Tensor: + """Convert the patched THWC video into normalized TCHW layout.""" + return value.permute(0, 3, 1, 2).to(device=device, dtype=dtype) + + monkeypatch.setattr( + hdmap_input_handler, + "read_video_rgb", + lambda _path, **_kwargs: video, + ) + monkeypatch.setattr( + hdmap_input_handler, + "rgb_video_to_normalized_tensor", + normalize_video, + ) + monkeypatch.setattr( + hdmap_input_handler, + "resize_rgb_video", + lambda value, **_kwargs: value, + ) + monkeypatch.setattr( + headless_module, + "_load_first_frame", + lambda _path, **_kwargs: torch.full((1, 1, 1, 3, 8, 8), 0.5), + ) + + +def test_omnidreams_headless_config_defaults(tmp_path: Path) -> None: + """Verify direct construction exposes stable rollout defaults.""" + config = OmnidreamsHeadlessConfig( + inference_runtime=_runtime_config(), + hdmap_path=tmp_path / "hdmap.mp4", + first_frame_path=tmp_path / "first.png", + ) + + assert config.artifact_path == headless_module.DEFAULT_ARTIFACT_PATH + assert config.example_data is False + assert config.example_data_uuid == headless_module.DEFAULT_EXAMPLE_DATA_UUID + assert config.text_prompt == headless_module.DEFAULT_TEXT_PROMPT + assert config.negative_text_prompt == headless_module.NEGATIVE_PROMPT + assert config.num_frames is None + assert config.num_chunks == headless_module.DEFAULT_NUM_CHUNKS + assert config.pixel_height == headless_module.DEFAULT_VIDEO_HEIGHT + assert config.pixel_width == headless_module.DEFAULT_VIDEO_WIDTH + + +def test_omnidreams_headless_resolves_example_data( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Verify direct construction resolves bundled inputs before setup.""" + _patch_hdmap_video(monkeypatch, num_frames=13) + hdmap_path = tmp_path / "example_hdmap.mp4" + first_frame_path = tmp_path / "first_frame.png" + requested_uuids: list[str] = [] + + def download_example(uuid: str) -> tuple[Path, Path]: + """Record the requested UUID and return local test assets.""" + requested_uuids.append(uuid) + return hdmap_path, first_frame_path + + monkeypatch.setattr( + headless_module, + "download_single_view_example_data", + download_example, + ) + config = OmnidreamsHeadlessConfig( + inference_runtime=_runtime_config(), + artifact_path=tmp_path / "generated.mp4", + example_data=True, + example_data_uuid="test-scene", + num_chunks=2, + ) + + application = OmnidreamsHeadless(config) + + assert requested_uuids == ["test-scene"] + assert config.hdmap_path == hdmap_path + assert config.first_frame_path == first_frame_path + input_handler = application._user_input_handler + assert isinstance(input_handler, HDMapInputHandler) + assert input_handler.hdmap_video_path == hdmap_path + + +def test_omnidreams_headless_requires_paths_without_example_data() -> None: + """Verify missing explicit inputs fail before runtime construction.""" + config = OmnidreamsHeadlessConfig(inference_runtime=_runtime_config()) + + with pytest.raises(ValueError, match="example_data is enabled"): + OmnidreamsHeadless(config) + + +@pytest.mark.parametrize( + ("num_frames", "num_chunks"), + [(13, None), (None, 2)], +) +def test_omnidreams_headless_initializes_hdmap_and_video_handlers( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + num_frames: int | None, + num_chunks: int | None, +) -> None: + """Verify construction configures all production application handlers.""" + _patch_hdmap_video(monkeypatch, num_frames=13) + artifact_path = tmp_path / "generated.mp4" + hdmap_path = tmp_path / "hdmap.mp4" + config = OmnidreamsHeadlessConfig( + inference_runtime=_runtime_config(), + artifact_path=artifact_path, + hdmap_path=hdmap_path, + first_frame_path=tmp_path / "first.png", + num_frames=num_frames, + num_chunks=num_chunks, + ) + + # The specialized integration config extends the reusable video config. + assert isinstance(config, VideoOutputApplicationConfig) + assert config._target is OmnidreamsHeadless + + application = OmnidreamsHeadless(config) + + # Runtime setup follows the production construction path and owns an actual + # Omnidreams pipeline instead of a manually assembled pipeline shell. + assert type(application._inference_runtime) is OmnidreamsInferenceRuntime + assert type(application._inference_runtime._pipeline) is OmnidreamsPipeline + + # The production input handler is set up during application initialization + # with the pipeline's chunk sizing, placement, and rollout limit. + input_handler = application._user_input_handler + assert type(input_handler) is HDMapInputHandler + assert input_handler.hdmap_video_path == hdmap_path + assert input_handler._get_num_frames(0) == 5 + assert input_handler._get_num_frames(1) == 8 + assert input_handler._num_chunks == 2 + assert input_handler._hdmap.device == torch.device("cpu") + assert input_handler._hdmap.dtype == torch.float32 + + # Construction embeds the config-owned prompt and first frame before + # releasing the one-shot encoders. + assert type(application._global_condition_handler) is GlobalConditionHandler + embedded_condition = application._inference_global_condition + assert isinstance(embedded_condition, InferenceGlobalCondition) + assert embedded_condition.text_embeddings.shape == (1, 1, 2, 4) + assert embedded_condition.negative_text_embeddings is not None + assert embedded_condition.negative_text_embeddings.shape == (1, 1, 2, 4) + assert embedded_condition.image_embeddings.shape == (1, 1, 1, 16, 1, 1) + assert application._inference_runtime._pipeline.text_encoder is None + assert application._inference_runtime._pipeline.image_encoder is None + + # The reusable parent binds the artifact destination to its video handler. + output_handler = application._inference_output_handler + assert isinstance(output_handler, VideoOutputHandler) + assert output_handler.artifact_path == artifact_path + + +@pytest.mark.parametrize( + ("num_frames", "num_chunks"), + [(13, None), (None, 2)], +) +def test_omnidreams_headless_limits_hdmap_input_during_initialization( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + num_frames: int | None, + num_chunks: int | None, +) -> None: + """Verify both limit modes produce exactly two complete HDMap chunks.""" + _patch_hdmap_video(monkeypatch, num_frames=21) + config = OmnidreamsHeadlessConfig( + inference_runtime=_runtime_config(), + artifact_path=tmp_path / "generated.mp4", + hdmap_path=tmp_path / "hdmap.mp4", + first_frame_path=tmp_path / "first.png", + num_frames=num_frames, + num_chunks=num_chunks, + ) + application = OmnidreamsHeadless(config) + input_handler = application._user_input_handler + + first = input_handler() + second = input_handler() + + assert isinstance(first, InferenceUserCondition) + assert isinstance(second, InferenceUserCondition) + assert first.hdmap.shape == (1, 1, 5, 3, 2, 3) + assert second.hdmap.shape == (1, 1, 8, 3, 2, 3) + with pytest.raises(StopIteration): + input_handler() + + +@pytest.mark.parametrize( + ("num_frames", "num_chunks", "message"), + [ + (None, None, "exactly one"), + (13, 2, "exactly one"), + (0, None, "num_frames must be a positive integer"), + (None, -1, "num_chunks must be a positive integer"), + ], +) +def test_omnidreams_headless_rejects_invalid_rollout_limits( + tmp_path: Path, + num_frames: int | None, + num_chunks: int | None, + message: str, +) -> None: + """Verify application initialization requires one positive rollout limit.""" + config = OmnidreamsHeadlessConfig( + inference_runtime=_runtime_config(), + artifact_path=tmp_path / "generated.mp4", + hdmap_path=tmp_path / "hdmap.mp4", + first_frame_path=tmp_path / "first.png", + num_frames=num_frames, + num_chunks=num_chunks, + ) + + with pytest.raises(ValueError, match=message): + OmnidreamsHeadless(config) + + +def test_omnidreams_headless_rejects_non_boundary_frame_limit( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Verify an exact frame limit cannot split an autoregressive chunk.""" + _patch_hdmap_video(monkeypatch, num_frames=21) + config = OmnidreamsHeadlessConfig( + inference_runtime=_runtime_config(), + artifact_path=tmp_path / "generated.mp4", + hdmap_path=tmp_path / "hdmap.mp4", + first_frame_path=tmp_path / "first.png", + num_frames=12, + num_chunks=None, + ) + + with pytest.raises(ValueError, match="next boundary is 13"): + OmnidreamsHeadless(config) + + +def test_omnidreams_headless_rejects_short_hdmap_video( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Verify construction fails when the HDMap cannot supply every chunk.""" + _patch_hdmap_video(monkeypatch, num_frames=20) + config = OmnidreamsHeadlessConfig( + inference_runtime=_runtime_config(), + artifact_path=tmp_path / "generated.mp4", + hdmap_path=tmp_path / "hdmap.mp4", + first_frame_path=tmp_path / "first.png", + num_chunks=3, + ) + + with pytest.raises(ValueError, match="requires 21 HDMap frames.*contains 20"): + OmnidreamsHeadless(config) + + +def test_headless_cli_builds_config_and_runs( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Verify argparse values drive the concrete application configuration.""" + config_name = headless_module._headless_config_names()[0] + monkeypatch.setitem( + headless_module.OMNIDREAMS_CONFIGS, + config_name, + _pipeline_config(), + ) + events: list[str] = [] + recorded: dict[str, object] = {} + + class _CLIApplication: + """Record CLI application construction and execution.""" + + def __init__(self, config: OmnidreamsHeadlessConfig) -> None: + """Store the application config produced by argument parsing.""" + recorded["config"] = config + + def run(self) -> None: + """Record application execution.""" + events.append("run") + + monkeypatch.setattr(headless_module, "OmnidreamsHeadless", _CLIApplication) + artifact_path = tmp_path / "generated.mp4" + hdmap_path = tmp_path / "hdmap.mp4" + first_frame_path = tmp_path / "first.png" + exit_status = headless_module.main( + [ + "--config", + config_name, + "--artifact-path", + str(artifact_path), + "--hdmap-path", + str(hdmap_path), + "--first-frame-path", + str(first_frame_path), + "--text-prompt", + "drive through a city", + "--negative-text-prompt", + "blurry", + "--device", + "cpu", + "--num-chunks", + "2", + ] + ) + + assert exit_status == 0 + assert events == ["run"] + + application_config = recorded["config"] + assert isinstance(application_config, OmnidreamsHeadlessConfig) + assert application_config.artifact_path == artifact_path + assert application_config.hdmap_path == hdmap_path + assert application_config.first_frame_path == first_frame_path + assert application_config.example_data is False + assert application_config.text_prompt == "drive through a city" + assert application_config.negative_text_prompt == "blurry" + assert application_config.num_frames is None + assert application_config.num_chunks == 2 + + runtime_config = application_config.inference_runtime + assert isinstance(runtime_config, OmnidreamsInferenceRuntimeConfig) + assert runtime_config.device == "cpu" + assert runtime_config.pipeline.name == "test-omnidreams-headless" + assert ( + runtime_config.pipeline is not headless_module.OMNIDREAMS_CONFIGS[config_name] + ) + + +def test_headless_cli_accepts_runtime_input_arguments( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify example-data selection and the chunk alias reach CLI dispatch.""" + recorded: dict[str, object] = {} + config_name = "omnidreams-sv-2steps-chunk2-loc6-lightvae-lighttae-perf" + + monkeypatch.setattr( + headless_module, + "_run_from_args", + lambda args: recorded.update(vars(args)), + ) + + exit_status = headless_module.main( + [ + "--config", + config_name, + "--device", + "cuda:0", + "--example-data", + "True", + "--example-data-uuid", + "239560dc-33d1-11ef-9720-00044bcbccac", + "--total-blocks", + "60", + ] + ) + + assert exit_status == 0 + assert recorded["config"] == config_name + assert recorded["device"] == "cuda:0" + assert recorded["num_chunks"] == 60 + assert recorded["artifact_path"] is None + assert recorded["hdmap_path"] is None + assert recorded["first_frame_path"] is None + assert recorded["example_data"] is True + assert recorded["example_data_uuid"] == "239560dc-33d1-11ef-9720-00044bcbccac" + + +def test_headless_cli_uses_runtime_defaults( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Verify omitted optional arguments retain runtime defaults.""" + recorded: dict[str, object] = {} + config_name = "omnidreams-sv-2steps-chunk2-loc6-lightvae-lighttae-perf" + + monkeypatch.setattr( + headless_module, + "_run_from_args", + lambda args: recorded.update(vars(args)), + ) + + assert ( + headless_module.main( + [ + "--config", + config_name, + "--hdmap-path", + str(tmp_path / "hdmap.mp4"), + "--first-frame-path", + str(tmp_path / "first.png"), + ] + ) + == 0 + ) + assert recorded["text_prompt"] == headless_module.DEFAULT_TEXT_PROMPT + assert recorded["negative_text_prompt"] == headless_module.NEGATIVE_PROMPT + assert recorded["pixel_height"] == headless_module.DEFAULT_VIDEO_HEIGHT + assert recorded["pixel_width"] == headless_module.DEFAULT_VIDEO_WIDTH + assert recorded["num_frames"] is None + assert recorded["num_chunks"] is None + assert recorded["example_data"] is False + assert recorded["example_data_uuid"] == headless_module.DEFAULT_EXAMPLE_DATA_UUID diff --git a/integrations/omnidreams/tests/runtime/test_inference_session.py b/integrations/omnidreams/tests/runtime/test_inference_session.py index e1f2666dc..16e6992ce 100644 --- a/integrations/omnidreams/tests/runtime/test_inference_session.py +++ b/integrations/omnidreams/tests/runtime/test_inference_session.py @@ -13,7 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""CPU lifecycle tests for the OmniDreams inference session.""" +"""CPU lifecycle tests for the Omnidreams inference session.""" from __future__ import annotations @@ -88,7 +88,7 @@ def forward( @pytest.fixture def pipeline() -> OmnidreamsPipeline: - """Set up the actual OmniDreams pipeline with tiny CPU components.""" + """Set up the actual Omnidreams pipeline with tiny CPU components.""" config = OmnidreamsPipelineConfig( name="test-omnidreams-inference-session", # Conditions arrive as precomputed embeddings, so one-shot text/image @@ -368,7 +368,7 @@ def test_step_validates_omnidreams_condition_fields( session: InferenceSession, missing_field: str, ) -> None: - """Verify Pydantic rejects missing required OmniDreams conditions.""" + """Verify Pydantic rejects missing required Omnidreams conditions.""" inference_input: Any = { "user_condition": {"hdmap": torch.zeros(1)}, "global_condition": { diff --git a/integrations/omnidreams/tests/test_demo_api.py b/integrations/omnidreams/tests/test_demo_api.py index d9411475a..8426911be 100644 --- a/integrations/omnidreams/tests/test_demo_api.py +++ b/integrations/omnidreams/tests/test_demo_api.py @@ -12,21 +12,6 @@ import pytest import torch from aiohttp import web -from omnidreams.config import OMNIDREAMS_RUNNERS -from omnidreams.demo import ( - DEFAULT_OMNIDREAMS_PRESET, - OMNIDREAMS_MODEL_ID, - OmnidreamsDemoAdapter, - OmnidreamsReplayScenario, - OmnidreamsWebRTCScenario, -) -from omnidreams.demo.cli import _replay_spec, _webrtc_spec, parse_args -from omnidreams.demo.replay import ( - OmnidreamsReplayRuntime, - OmnidreamsReplayRuntimeOptions, -) -from omnidreams.demo.webrtc import OmnidreamsDemoWebRTCSessionManager - from flashdreams.infra.video_output import VideoStepResult from flashdreams.runtime import ( InferenceConfig, @@ -44,6 +29,20 @@ from flashdreams.runtime.demo.replay import run_replay_demo from flashdreams.runtime.demo.webrtc import WebRTCDemo, build_webrtc_demo from flashdreams.serving.webrtc.server import SESSION_MANAGER_KEY +from omnidreams.demo import ( + DEFAULT_OMNIDREAMS_PRESET, + OMNIDREAMS_MODEL_ID, + OmnidreamsDemoAdapter, + OmnidreamsReplayScenario, + OmnidreamsWebRTCScenario, +) +from omnidreams.demo.cli import _replay_spec, _webrtc_spec, parse_args +from omnidreams.demo.replay import ( + OmnidreamsReplayRuntime, + OmnidreamsReplayRuntimeOptions, +) +from omnidreams.demo.webrtc import OmnidreamsDemoWebRTCSessionManager +from omnidreams.runner_config import OMNIDREAMS_RUNNERS pytestmark = pytest.mark.ci_cpu diff --git a/integrations/omnidreams/tests/test_quality_regression.py b/integrations/omnidreams/tests/test_quality_regression.py index f12dd8842..d910e43b4 100644 --- a/integrations/omnidreams/tests/test_quality_regression.py +++ b/integrations/omnidreams/tests/test_quality_regression.py @@ -17,8 +17,6 @@ from typing import Any import pytest -from omnidreams.config import OMNIDREAMS_RUNNERS - from flashdreams.infra.config import derive_config from flashdreams.quality.clip_compare import ( ClipComparisonThresholds, @@ -28,6 +26,7 @@ parse_frame_indices, read_video_rgb, ) +from omnidreams.runner_config import OMNIDREAMS_RUNNERS pytestmark = pytest.mark.ci_gpu diff --git a/integrations/omnidreams/tests/test_recipe_configs.py b/integrations/omnidreams/tests/test_recipe_configs.py index 4de5e2ac4..3cedf96b4 100644 --- a/integrations/omnidreams/tests/test_recipe_configs.py +++ b/integrations/omnidreams/tests/test_recipe_configs.py @@ -31,10 +31,9 @@ import pytest import tomli as tomllib -from omnidreams import config as config_mod -from omnidreams.config import OMNIDREAMS_RUNNERS - from flashdreams.infra.runner import RunnerConfig +from omnidreams import runner_config as runner_config_mod +from omnidreams.runner_config import OMNIDREAMS_RUNNERS pytestmark = pytest.mark.ci_cpu @@ -87,10 +86,10 @@ def test_entry_points_match_module_literals() -> None: # Resolve the entry-point target the same way importlib.metadata # would, but skip the actual ``entry_points()`` call so the test # passes even when the plugin isn't pip-installed yet. - assert module_name == "omnidreams.config", ( + assert module_name == "omnidreams.runner_config", ( f"unexpected module in entry point {slug!r}: {module_name}" ) - cfg = cast(RunnerConfig, getattr(config_mod, attr)) + cfg = cast(RunnerConfig, getattr(runner_config_mod, attr)) assert cfg.runner_name == slug, ( f"entry point {slug!r} -> {attr} resolves to " f"runner_name={cfg.runner_name!r}"