Skip to content

QVAC-23489 feat[api]: add ABot-World interactive world sessions to the SDK - #3812

Draft
donriddo wants to merge 7 commits into
mainfrom
feat/sdk-world-model
Draft

QVAC-23489 feat[api]: add ABot-World interactive world sessions to the SDK#3812
donriddo wants to merge 7 commits into
mainfrom
feat/sdk-world-model

Conversation

@donriddo

@donriddo donriddo commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

🎯 What problem does this PR solve?

  • ABot-World shipped in @qvac/diffusion-cpp 0.19.0 (addon QVAC-21981 feat[api]: ABot-World interactive world sessions (walk + native scene creation) #3352), but nothing in @qvac/sdk exposed it — the capability was reachable only by require('@qvac/diffusion-cpp/world') from a bare/Node app.
  • A walk session does not fit the plugin model contract as-is: it is stateful across calls, its load() reads a scene pack that may not exist yet, and its native teardown is synchronous down to a thread join.

📝 How does it solve it?

  • modelConfig.mode: 'world' on the existing sdcpp-generation plugin (same addon package, following the video / upscale mode precedent), with two streaming ops: worldCreateScene builds a world from a prompt + first frame, worldStep walks it one generated block at a time and streams frames as they decode.
  • Deferred activation. With sceneSrc the session activates at loadModel so a bad pack fails fast; without it, activation waits for the first worldStep after a world exists — because the caller is about to build one.
  • Teardown waits for in-flight native work. The addon's unload() is synchronous down to processingThread_.join(), so entering it mid-job blocks the worker's event loop — every model and every RPC on it — until that job finishes. Measured: a mid-block unloadModel took 779 ms with a worst event-loop gap of 76 ms; entering the join would have stalled it for the full 779 ms.
  • Scene packs are server-managed and ephemeral. Stored at a path derived from a hash of the model id (a caller-supplied id cannot steer the write) and deleted on unload, failed load and worker shutdown. Callers persist a world by keeping the bytes worldCreateScene returns and passing them back as sceneSrc. Replacement is staged and promoted with an atomic rename, so a failed generation leaves the previous world intact rather than leaving the model with none.
  • Cancellation follows the engine's real semantics. A walk cancel is block-granular: the current block finishes internally, delivery stops, and the step rejects rather than resolving with a silently truncated block — the DiT has already committed that block to session history. Scene creation takes no abort predicate at all, so its concurrency slot is held until the native encode settles even if the caller disconnects.
  • One job per model, rejected rather than queued: a walk is driven by live key input, so a backlog of stale keypresses is worse for the caller than a refusal it can drop.
  • Delegated mode: 'world' is refused at loadModel rather than succeeding and failing on the first step, since the world ops have no delegated route.

🧪 How was it tested?

Statictypecheck, lint --max-warnings=0, format, contract:export + contract:check, and tsc over packages/sdk/e2e.

Suites — unit 114/114 files; bare 131/131 tests / 523/523 asserts; test:security and test:security:bare.

New coverage

  • test/bare/sdcpp-world-ops.test.ts — drives the ops through an injected native session: run() follows the response to terminal state (not just dispatch), teardown is not entered while a job is tracked, abort never emits done: true, and the model slot is held until settle even when the consumer abandons the stream.
  • test/bare/sdcpp-world-plugin.test.ts — companion resolution, world-only field rejection, eager vs deferred activation, staged replacement and rollback, ephemeral cleanup, and a traversal-shaped model id not steering the scene path.
  • test/unit/world-{schemas,client,concurrency}.test.ts — config accept/reject matrix, key normalisation across array/object/mask forms, both client result factories including error paths, and the installed reject-on-overlap admission policy.

Hardware (RTX 5080, 448x256 low-VRAM tier)loadModelworldCreateSceneworldStep xN → unloadModel:

check result
scene pack 9,458,120 bytes, 2,811 ms
first / second block frames 9 / 12
actionMask for W+L 129
frame dimensions 448x256
concurrent step rejected, structured error
cancel mid-block rejected Job cancelled; session reloaded and walked again
unloadModel mid-block 779 ms, worst event-loop gap 76 ms
VRAM after unload back to 18 MiB; managed pack deleted

SDK E2E (desktop consumer, RTX 5080, real models via the registry) — 8/8 passing. npx qvac-test run:local:desktop --filter world, covering scene creation, first/second block frame counts, step-before-scene, invalid key and dimensions, overlap rejection and cancel-then-reload. Peak RSS across the batch 3,791 MB.

Running them found a defect in the tests themselves: world-step-before-scene-fails passed only when it happened to run before any world was created, because the resource is shared through ensureLoaded. It evicts first now, so the precondition is real rather than dependent on test ordering (7/8 → 8/8).

🔌 API Changes

const modelId = await loadModel({
  modelSrc: ABOT_WORLD_0_5B_Q8_0,
  modelType: 'sdcpp-generation',
  modelConfig: {
    mode: 'world',
    taehvModelSrc: ABOT_WORLD_0_5B_LF_VAE,
    t5XxlModelSrc: UMT5_XXL_ENC_Q8_0,
    vaeModelSrc: ABOT_WORLD_0_5B_LF_VAE_F16,
    world: { kvCache: true, frameJpegQuality: 85 }
  }
})

// Once per world: prompt + first frame -> scene pack, returned for reuse via sceneSrc.
const { scene } = worldCreateScene({ modelId, prompt, image })
await scene

// Walk: one generated block per call, frames stream as they decode.
const { frameStream } = worldStep({ modelId, keys: ['W', 'L'] })
for await (const frame of frameStream) render(frame)

keys also accepts a key-state object ({ W: true }) or a raw 8-bit mask, so a keyboard handler can pass its state straight through.

📋 Open items

  • Darwin desktop E2E registration. Mac CI has OOM-killed on large diffusion model sets in this area (mac-mini-m4-gpu, addon lane), and the ABot set is 13.3 GB. A Darwin skip looks warranted, but the desktop consumer currently has no platform-conditional logic at all, so this would be the first — flagging rather than introducing that pattern unilaterally.
  • Hardware validation covered the 448x256 correctness tier; the 832x480 full-fidelity/performance run on the 2x RTX 5090 host is still outstanding and non-blocking.
  • Delegated inference for the diffusion-family ops is out of scope here and wants its own ticket: only completionStream has a delegated route today, so the mobile delegation guidance in sdcppConfigSchema cannot work as written.

…e SDK

Exposes @qvac/diffusion-cpp/world through the sdcpp-generation plugin as
`modelConfig.mode: 'world'`, with two streaming operations: worldCreateScene
builds a world from a prompt and a first frame, and worldStep walks it one
generated block at a time, streaming decoded frames as they arrive.

A walk session does not fit the plugin load contract as-is, so the plugin wraps
it:

- Activation is deferred when no scene pack exists yet, because the caller is
  about to build one. Supplying modelConfig.sceneSrc activates eagerly at
  loadModel so a bad pack fails fast like any other model.
- Teardown waits for in-flight native work. The addon's unload() is synchronous
  down to a thread join, so entering it mid-job would block the worker's event
  loop — every model and every RPC on it — until that job finished on its own.
- Scene packs are server-managed and ephemeral. They live at a path derived from
  a hash of the model id, so a caller-supplied id cannot steer the write, and
  are deleted on unload, on failed load, and on worker shutdown. Callers persist
  a world by keeping the bytes worldCreateScene returns and passing them back as
  sceneSrc.
- Replacing a world stages the new pack and promotes it with an atomic rename,
  so a failed generation leaves the previous world intact rather than leaving
  the model with none.

Cancellation follows the engine's real semantics rather than an idealised one.
A walk cancel is block-granular: the current block finishes internally, delivery
stops, and the step rejects instead of resolving with a silently truncated
block, since the DiT has already committed that block to session history. Scene
creation takes no abort predicate at all, so its concurrency slot is held until
the native encode settles even when the caller disconnects.

One job runs per model. A second step, or a scene creation arriving mid-walk, is
rejected rather than queued: a walk is driven by live key input, so a backlog of
stale keypresses is worse for the caller than a refusal it can drop.

World sessions are bound to the worker holding the GPU. A delegated load with
mode: 'world' is refused at loadModel rather than succeeding and failing on the
first step, because the world operations have no delegated route.
…lk in e2e

Registers worldStepStream and worldSceneStream with the operation-metrics
profiler so a walk reports the same gauges every other streaming op does —
without this the world ops are the only inference path invisible to profiling.
Step gauges cover the per-block and cumulative timings plus frame counts; scene
creation reports its encode time.

Regenerates the Python client, which the contract change had made stale. The
new wire schemas add their request, response and stats models to the generated
bindings.

Adds the SDK e2e definitions and a desktop executor for the walk, at the
448x256 low-VRAM tier so the tests fit the shared GPU desktop runners rather
than needing the 20 GB the 832x480 tier does. Coverage: scene creation returning
a safetensors pack, the 9-then-12 frame counts, frame dimensions, the W+L action
mask, stepping before a world exists, invalid keys and dimensions, overlapping
requests, and cancel-then-reload. The cancellation case accepts either the typed
error or a clean resolve, because a cancel arriving after the block finished
legitimately succeeds and asserting otherwise would flake.

Mobile and Electron skip the walk, matching how both already skip diffusion: the
model set is 13.3 GB and the session needs a dedicated GPU.
The executor declared no `pattern`, so BaseExecutor could not route any
world- test to it, and its handler map used the wrong generic arity for
HandlerFn/ExtractTest. Neither showed up until the e2e package's own
dependencies were installed, since without them every file in that package
fails to resolve @tetherto/qvac-test-suite and the real errors are buried.
@github-actions

github-actions Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

License compliance — findings detected (warn-only)

Critical: 0 · High: 1 · Medium: 0

Dependency License Scope Severity Outcome
@qvac/diffusion-cpp@^0.19.0 (none detected) runtime High blocks

How to resolve a blocking finding:

  • Remove or replace the disallowed dependency, or
  • If the license is genuinely acceptable, run the compliance SKILL and record the decision in .github/license-allowlist.yml (CODEOWNERS-reviewed), or
  • For a one-off, a maintainer can apply the license-override label (High findings only; Critical cannot be overridden).

Warn-only (shadow) mode — this check does not block merges yet.

Updated automatically by the canonical license compliance workflow.

NOTICE presence (advisory)

Missing NOTICE (advisory, does not block):

  • ./.github/actions/release-merge-guard
  • ./docs/website
  • ./packages/ggml-coload-smoke
  • ./packages/fabric/test/integration
  • ./packages/inference-addon-cpp/mobile
  • ./packages/sdk/e2e
  • ./packages/llm-llamacpp/benchmarks/performance
  • ./packages/llm-llamacpp/benchmarks/server
  • ./packages/vla-ggml/sim/server
  • ./packages/embed-llamacpp/benchmarks/performance
  • ./packages/embed-llamacpp/benchmarks/server
  • ./packages/asr-ggml/benchmarks/server

@socket-security

socket-security Bot commented Aug 13, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addednpm/​@​qvac/​diffusion-cpp@​0.19.0871001009780

View full report

@github-actions

Copy link
Copy Markdown
Contributor

Review Status

Current Status: ❌ PENDING
Approvals so far: none

Pending reviews: Needs 1 Management or Team Lead, and 1 more from Management, Team Lead, or Member.

…ition real

The test asserted that stepping fails before a world exists, but the world
resource is shared through ensureLoaded, so an earlier test in the batch had
already created one on that model and the step legitimately succeeded. It
evicts first now — unload deletes the managed pack, so the reload is a
genuinely world-less session rather than one that depends on test ordering.

Found by running the suite: 7/8 before, 8/8 after.
The walk-key enum generates a member named `l` (keyboard "L"), which trips
ruff's ambiguous-name rule and failed the sdk-python Generate + test job. The
name is fixed by the RPC contract and cannot be renamed, and the file is
generated rather than written, so the rule has nothing to act on. Scoped to
_generated so E741 still applies to hand-written code, mirroring how E501 is
handled for black-formatted output.

Verified with the pinned toolchain the workflow uses (datamodel-code-generator
0.68.0, black 26.5.1, ruff 0.15.21): generate.py --check, ruff and black all
clean.
Review found two of the eight tests passing for the wrong reason.

world-concurrent-step-rejected matched errorContains: 'world', but the model id
is abot-world-0-5b-lf-dit-q8_0.gguf, so almost any server error naming the model
satisfied it — including "No world exists", a different failure entirely. It now
matches the policy rejection specifically, and proves the first step really held
the slot by requiring it to have delivered a full block. Admission is checked
after the fact rather than before: world generates a whole block before emitting
any frame, so waiting for one would release the slot and let the overlap through
legitimately.

world-cancel-then-reload discarded both the resolved value and the rejection, so
it would have passed with cancel() removed. It now asserts which branch ran and
fails on a rejection that is not a cancellation. Either branch is still accepted,
because a cancel landing after the block finished legitimately resolves.

Also: the idle block asserts actionMask 0, so a silent coercion to some default
key set can no longer pass on frame count alone; scene creation asserts
sceneCreateMs and the dimension round-trip, which the new profiler entry reads;
the sceneCreateMs metric guard no longer drops a legitimate 0; and the two
client-side validation tests declare dependency 'none' instead of dragging the
13.3 GB model set for assertions that reject before any RPC.

Verified: 8/8 on GPU. The concurrency test failed under a badly-timed first
attempt at the same fix, which is what showed the assertion now depends on the
policy rather than on a substring.
Comment on lines +10 to +22
import {
SCENE_HEIGHT,
SCENE_WIDTH,
worldTests,
worldCancelThenReload,
worldConcurrentStepRejected,
worldCreateSceneReturnsPack,
worldFirstBlockFrames,
worldInvalidDimensionsRejected,
worldInvalidKeyRejected,
worldSecondBlockFrames,
worldStepBeforeSceneFails
} from '../../world-tests.js'
Prettier was not re-run after the last executor edit, so the SDK pod format
check failed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant