Lifecycle and Ownership
Runtime creation, ownership, state transitions, and shutdown contracts.
Use this section when you need to know who owns a Runtime handle, which API ends that ownership, and which shutdown call fits your host code. This section is about handle lifetime only; the per-step loop itself belongs in Step Execution.
Related Chapters For Builder configuration and pack generation, see Builder and Data Contracts. For the step-loop execution model, see Step Execution. For concurrency rules and the customer-facing thread-safety matrix, see Concurrency and Shutdown.
Use this section when your host integration question is primarily:
- who owns the
tdse_model_t*handle right now - which shutdown path fits the host's policy and wait budget
- when local ownership is gone and all further calls must stop
If your question is instead "what exact calls happen every accepted step," jump to Step Execution first.
Thread-Safety Matrix
Lifecycle ownership and thread-safety are the same contract viewed from two angles. The user-facing rule is one owner per live handle; the complete customer-facing matrix is in Concurrency and Shutdown.
Use that matrix when you need to classify:
- snapshot-style queries that can read a live handle during worker traffic
- guarded step, reset, close, destroy, and release calls
- immutable device and resource options supplied to
tdse_model_create(...) - callback/logging and telemetry worker interactions
Why Lifecycle Comes First
The most important Runtime rule is not the math. It is lifecycle. If you understand when a handle is live, closing, destroying, released, or no longer locally owned, the rest of the API becomes much easier to use correctly.
Where This Fits In A Host Integration
For a minimal host integration, lifecycle is usually one of three patterns:
| Host pattern | Runtime lifecycle concern |
|---|---|
| single model owned by one solver thread | ordinary create -> destroy path |
| worker pool with one model per thread | ownership must stay per-handle, not per process |
| supervisory or fault-handling shutdown | close or timeout-bearing destroy policy matters |
This chapter only answers the ownership side of those patterns. It does not define the numerical step loop.
The Runtime Lifecycle In One Table
| API | Primary Use | Wait Behavior | Returns Status | Recommended For Host Code |
|---|---|---|---|---|
tdse_model_create(...) | create a model from pack bytes | n/a | yes | yes |
tdse_model_execution_prepare(...) | prepare the selected CPU, GPU, or FPGA execution before the step loop | setup-only; may allocate representations and provider plans | yes | hosts that use a prepared execution interval |
tdse_model_execution_end(...) | release the prepared execution interval | bounded at a completed-step boundary | yes | hosts that prepared execution |
tdse_model_close(...) | explicit non-blocking close attempt | does not wait for an in-flight same-handle API | yes | situational |
tdse_model_destroy(...) | explicit bounded destroy | caller-controlled wait policy | yes | yes |
tdse_model_release(...) | terminal cleanup / finalizer path | may wait for an in-flight same-handle API; returns INVALID_STATE if another terminal path owns it | yes | no |
Short version:
- use
createto create - finish host resource configuration before calling
tdse_model_execution_prepare(...); preparation performs the selected device/provider setup but does not advance simulation state - after preparation, optionally run non-committing trial queries followed by
tdse_step_discard(...)when the host needs to remove first-use compute effects before a deadline - build physically meaningful history only by running the normal host-driven step loop and committing accepted primary values; TDSE never invents application warmup inputs
- keep only step calls in the realtime interval, then call
tdse_model_execution_end(...)on the owner thread - use
destroyfor ordinary host-managed shutdown - use
closewhen you need an immediate status-bearing close attempt - use
releaseonly for terminal cleanup paths that are not driving lifecycle policy
After tdse_model_execution_end(...), a later prepare begins a new
control-plane interval. CPU execution rechecks the current host-owned
provider/thread/CPU-set contract before reusing retained plans; for
host-inherited threading, the reference is the provider state observed at
model creation, so a changed contract fails closed. Accelerator execution
revalidates its selected provider. These checks are not performed by the step
APIs.
Benchmark warmup and application initialization are different. The benchmark runs untimed prepared steps solely to make its latency samples representative of steady execution. A customer simulation does not need to reproduce that measurement policy. It either starts with the model's defined zero prehistory or runs its own physically valid pre-event/initialization interval through the ordinary step API.
When first-step latency matters, the host may additionally run a bounded, non-committing computational prewarm after preparation. See Warmup, Computational Prewarm, and Physical Initialization for the common CPU/GPU call count and exact call order.
Create Semantics
The standard create path is:
tdse_model_create_diagnostics_t diag = tdse_model_create_diagnostics_init();
tdse_model_t* model = NULL;
tdse_status_t st = tdse_model_create(
pack_data, pack_size, NULL, &diag, &model);
Key rules:
- pass
tdse_model_create_diagnostics_tso create failures remain request-scoped - treat
diag.pack_error_codeas part of the normal error path, not as optional trivia - after successful create, the caller owns the handle locally
Device, precision, threading, residency, and memory policy are explicit and per handle:
tdse_model_create_options_t options = tdse_model_create_options_init();
options.device = TDSE_DEVICE_CPU;
options.precision = TDSE_PRECISION_FP64;
tdse_status_t st = tdse_model_create(
pack_data, pack_size, &options, &diag, &model);
Passing NULL options selects CPU, FP64, and host-resident data. A model's
execution options are immutable; release and recreate the model to change
them. Use tdse_model_get_execution_info(...) to inspect the selected and
observed resources.
When the host needs metadata before deciding whether to create, prefer:
tdse_pack_inspect(...)for atdse_model_info_tsnapshot decoded from pack bytestdse_runtime_pack_inspect_summary(...)for version and payload summary fields intdse_pack_summary_t
These APIs validate pack header and required metadata chunks without creating a tdse_model_t.
Owning the handle locally means:
- your thread or wrapper may enter the handle according to the Runtime rules
- your code is responsible for selecting the shutdown path
- another thread has not yet taken over close or destroy
Live-Handle Query APIs
These queries are safe snapshot-style reads on a live handle:
tdse_model_info(...)tdse_model_state_info(...)tdse_model_last_error_info(...)tdse_model_diagnostics_summary(...)tdse_model_diagnostics_payload(...)tdse_model_diagnostics_query_many(...)
They are intended for metadata, state, and failure diagnosis.
Use the compact summary for the default support record, add only the versioned
payloads needed by the incident, and use tdse_model_diagnostics_query_many(...)
when the summary and several payloads must come from one internally consistent
snapshot. The earlier aggregate draft was never released and was removed before
ABI v1.
If close or destroy has already started on the handle, these queries return TDSE_STATUS_INVALID_STATE.
Memory Preflight Before Preparation
tdse_model_get_memory_preflight(...) is the read-only capacity-planning query for a
created model. Call it before tdse_model_execution_prepare(...) or the first
step. It is repeatable and does not
allocate, materialize representations, scan topology, call provider setters, change
thread/affinity state, or change model state.
The result separates three byte views so callers do not double-count:
currently_allocated_*_bytesandcurrently_allocated_bytesare TDSE-owned allocations that already exist;h_matrix_bytesthroughruntime_scratch_bytes, together withtotal_resident_bytes, are the known TDSE-owned resident bytes expected for the model's preparation; andpreparation_required_*_bytesandpreparation_required_bytesare additional known TDSE-owned bytes still required beyond the current allocations.
These known totals cover the reported matrix, history/ring, linear-mirror, and
runtime-scratch payload categories. They exclude allocator metadata, the model control
object, and implementation-private allocations whose exact size is not portable to query.
For CPU, the total is exact when resident_bytes_are_upper_bound == 0. When CPU preparation may
benchmark a zero-padded execution shape, the flag is nonzero and the result is a
safe bound for the largest candidate that may remain resident; the logical and
bounded output-row counts are both reported. For GPU_CUBLAS, the result reports
the largest known per-device payload across the explicitly selected GPU set.
estimate_kind is TDSE_MODEL_MEMORY_ESTIMATE_KNOWN_PAYLOAD_LOWER_BOUND because
CUDA, cuBLAS, graph, allocator, and driver-private allocations cannot be queried
portably before preparation. Ordinary GPU preflight includes only the column-major
delayed-H matrix and the ring, mirror, packed-input, and output workspace used by
the normal history path; optional Circuit persistent-capability storage is not
created or charged unless Circuit requests that capability. FPGA preflight remains
unsupported because its provider-owned device allocation contract is opaque.
The total excludes allocator metadata, the model control object, transient preparation-only buffers, thread stacks, and implementation-private memory. The result identifies the selected device, storage precision/layout and rectangular dimensions, plus the expected thread and host CPU execution contract. Implementation-private memory remains explicitly opaque and is not added to the TDSE-owned total. The query never substitutes a CPU estimate for an accelerator.
Lifecycle States In Practice
The runtime does not publish a first-class enum for every human-readable lifecycle state, but product integrations should reason in these terms:
| Practical State | Meaning | Safe Actions |
|---|---|---|
| locally owned and idle | handle exists, no active trial step | query info/state, start a step, destroy, close |
| trial step active | tdse_step_begin(...) succeeded and commit has not yet ended the trial | query op, hr, ir, commit |
| destroy pending locally | caller has entered destroy and is waiting or finishing | observe destroy result only |
| ownership already handed off | another thread already started close or destroy | stop using the handle locally |
| terminally cleaned up | storage has been released | no further API use is valid |
Step-Lifecycle Ownership
Inside a live handle, the runtime lifecycle is:
tdse_step_begin(...)- one or more trial-safe queries:
tdse_step_op(...)tdse_step_hr(...)tdse_step_ir(...)
tdse_step_commit(...)- optional
tdse_step_dr(...)
tdse_step_discard(...) may be used instead of commit while a trial step is active.
It clears only the trial/interpolation/IR state (and invalidates trial prefetch work),
leaving the last committed state and output unchanged. Ordinary mode may then retry
with a different t or dt. A prepared real-time session remains armed and owned by
the same thread, so its fixed-step contract still applies on retry. Calling discard
without an active trial returns TDSE_STATUS_INVALID_STATE; a foreign owner receives
the normal concurrent-use status.
Two rules matter:
tdse_step_commit(...)is the preferred state-advancing call- if a trial solve fails, do not call
commit; committed state remains unchanged
Lifecycle bugs and step-order bugs usually appear together. If ownership or sequencing is wrong, the first visible symptom is often a bad step call rather than a clearly named lifecycle error.
Close Semantics
tdse_model_close(...) is the explicit non-blocking lifecycle endpoint.
Use it when:
- the caller needs a synchronous lifecycle result immediately
- the caller does not want to wait for an in-flight same-handle API
Interpretation:
TDSE_STATUS_OK: close completed and local ownership endedTDSE_STATUS_CONCURRENT_API_USE: another same-handle API is still in flightTDSE_STATUS_INVALID_STATE: another thread already started close or destroy
close is not the recommended production shutdown path.
It is the immediate-answer path.
Typical uses:
- supervisory code wants a fast lifecycle answer
- a wrapper wants overlap detection without entering a wait path
- tests intentionally exercise ownership conflicts
Close is best understood as a guardrail API, not as a general shutdown API.
Destroy Semantics
tdse_model_destroy(...) is the recommended host-managed shutdown path.
Use it when:
- the caller owns lifecycle policy
- timeout matters
- the caller wants structured result details
Typical usage:
tdse_model_destroy_options_t opt = tdse_model_destroy_options_init();
tdse_model_destroy_result_t result = tdse_model_destroy_result_init();
opt.wait_timeout_ms = 250.0;
tdse_status_t st = tdse_model_destroy(model, &opt, &result);
Interpretation:
TDSE_STATUS_OK: destroy completedTDSE_STATUS_TIMEOUT: destroy did not acquire ownership before the budget expired; the handle is still validTDSE_STATUS_INVALID_STATE: another thread already started close or destroy; local ownership should be treated as gone
Destroy Result Matrix
| Status | Ownership After Return | Handle Still Live? | Typical Host Action |
|---|---|---|---|
TDSE_STATUS_OK | no longer local | no | clear references and continue |
TDSE_STATUS_TIMEOUT | still local | yes | retry later, escalate, or change policy |
TDSE_STATUS_INVALID_STATE | no longer local | unknown to this thread; assume not locally usable | stop using the handle locally |
Release Semantics
tdse_model_release(...) exists so C and C++ have an explicit finalizer target.
Use it when:
- a destructor, guard, or finally block must make a best-effort terminal cleanup
- the caller is not trying to implement a bounded lifecycle policy
Do not use it as the primary shutdown path in ordinary host code.
Interpretation:
TDSE_STATUS_OK: release completedTDSE_STATUS_INVALID_STATE: another thread already started close, destroy, or release
This API is intentionally secondary to tdse_model_destroy(...).
Its purpose is very narrow and very important:
- it gives destructors and unwind paths a clean terminal target
- it avoids turning finalizer code into a policy engine
Release Result Matrix
| Status | Interpretation |
|---|---|
TDSE_STATUS_OK | finalizer cleanup completed |
TDSE_STATUS_INVALID_STATE | another thread already owns terminal cleanup; local ownership is gone |
C++ Wrapper Interpretation
The C++ wrapper follows the same lifecycle rules:
tdse::Model::fromMemory(...)wrapstdse_model_create(...)tdse::Model::close()wrapstdse_model_close(...)tdse::Model::destroy(options)wrapstdse_model_destroy(...)tdse::Model::release()wrapstdse_model_release(...)but is noexcept and ignores the returned status- runtime status-bearing failures surface as
tdse::Error, which preserves both the API name andtdse_status_t tdse::Model::empty()/operator bool()are the supported way to check moved-from or already-cleaned-up wrappers- plain C integrations already receive the unified
tdse_status_t; usetdse_status_error_category(...),tdse_status_stable_reason(...), andtdse_status_message(...)for machine classification, stable reason tokens, and human-readable logging
Important rule:
tdse::Model::destroy(...)requires explicit destroy options so the wait policy is always intentional- moved-from wrappers may be destroyed, checked with
empty()/if (model), or assigned again; business APIs still throw on use
That requirement is deliberate. Product code should not drift into implicit infinite-wait shutdown through a convenient wrapper default.
Ownership Handoff Rules
When local ownership is gone, stop using the handle immediately. This is especially important when:
tdse_model_close(...)returnsTDSE_STATUS_OKtdse_model_destroy(...)returnsTDSE_STATUS_OKtdse_model_destroy(...)returnsTDSE_STATUS_INVALID_STATEtdse_model_release(...)returnsTDSE_STATUS_OKtdse_model_release(...)returnsTDSE_STATUS_INVALID_STATE
The Runtime contract is:
- if another thread has already started close or destroy, ownership is no longer local
- the old pointer must not be treated as a reusable live handle
Ownership handoff is the place where many host bugs become latent use-after-destroy bugs. The safest policy is simple: once handoff happens, clear local references immediately.
C And C++ Mapping Table
| Intent | C API | C++ Wrapper |
|---|---|---|
| create with request-scoped diagnostics | tdse_model_create(...) | tdse::Model::fromMemory(..., &diag) |
| collect the compact runtime summary | tdse_model_diagnostics_summary(...) | tdse::Model::diagnosticsSummary() / tryDiagnosticsSummary() |
| collect one versioned detail payload | tdse_model_diagnostics_payload(...) | tdse::Model::diagnosticsPayload() / tryDiagnosticsPayload() |
| collect one consistent summary plus payload set | tdse_model_diagnostics_query_many(...) | tdse::Model::diagnosticsQueryMany() / tryDiagnosticsQueryMany() |
| fast close attempt | tdse_model_close(...) | tdse::Model::close() |
| canonical bounded shutdown | tdse_model_destroy(...) | tdse::Model::destroy(options) |
| terminal finalizer cleanup | tdse_model_release(...) | tdse::Model::release() and destructor |
Anti-Patterns
Avoid these patterns even if they appear to work in a small local test:
- using
tdse_model_release(...)as the primary host shutdown API - retrying destroy locally after
TDSE_STATUS_INVALID_STATE - treating
TDSE_STATUS_TIMEOUTas if the handle were already gone - continuing to use a handle after ownership handoff
- assuming
closeis just a fasterdestroy
A Good Mental Checklist
Before every lifecycle decision, ask:
- Do I still own this handle locally?
- Do I need a status-bearing shutdown answer?
- Do I need a bounded wait budget?
- Am I in business logic or finalizer cleanup?
If the answer is "business logic and I need a bounded answer," the API is almost always
tdse_model_destroy(...).
Worked Lifecycle Scenarios
Scenario A. Clean Business Shutdown
- caller owns a live handle
- no same-handle API is in flight
- caller invokes
tdse_model_destroy(...) - runtime returns
TDSE_STATUS_OK - caller drops all references
This is the ideal host-managed path.
Scenario B. Destroy Races With A Worker Step
- worker thread is still inside a same-handle step call
- supervisory code calls
tdse_model_destroy(...) - destroy waits within its configured budget
- runtime returns either
TDSE_STATUS_OKorTDSE_STATUS_TIMEOUT
This is why destroy carries explicit wait policy and result details.
Scenario C. Another Thread Already Took Ownership
- thread A starts close or destroy
- thread B also attempts close, destroy, or release
- thread B receives
TDSE_STATUS_INVALID_STATE - thread B must stop using the handle locally
This is not a local retry condition. It is ownership handoff.
Recommended Patterns
Recommended:
-
ordinary host shutdown:
tdse_model_destroy(...) -
timeout-aware supervision:
tdse_model_destroy(...)plus an explicit policy forTDSE_STATUS_TIMEOUT -
destructor or guard cleanup:
tdse_model_release(...) -
immediate non-waiting close attempt:
tdse_model_close(...)
Avoid:
- using
tdse_model_release(...)as the first-choice business-logic shutdown API - relying on infinite-wait destroy as the default production pattern without documenting why
- continuing to use a handle after destroy, release, or ownership handoff
