Skip to main content

Crate ommx_pyo3_bridge

Crate ommx_pyo3_bridge 

Source
Expand description

§ommx-pyo3-bridge

ommx-pyo3-bridge converts values from the Rust ommx crate into the canonical classes provided by the installed Python ommx package. It is for independently built PyO3 extension modules that cannot safely pass Rust values or private PyO3 wrapper types across a shared-library boundary.

The default sender feature provides Rust SDK v3 conversions and pyo3-stub-gen return types. A receiving Python SDK disables it:

ommx-pyo3-bridge = { version = "=3.0.0-beta.6", default-features = false }

Without sender, the crate depends only on PyO3: protocol IDs, receiver configurations, registration, and SDK exception lookup remain available. It does not depend on ommx, protobuf codecs, or pyo3-stub-gen. The receiving SDK supplies its own parsers and constructors, so a Rust SDK v2 receiver does not need to compile or pass through Rust SDK v3. Producers use the default features for resolve_target, Target::transfer, and the Py* return types.

Select a supported protocol and transfer the value before returning it:

use ommx_pyo3_bridge::{
    resolve_target, BridgeError, ProtobufV2, PyFunction, TransferProtocolId,
};
use pyo3::prelude::*;

#[pyo3_stub_gen::derive::gen_stub_pyfunction]
#[pyfunction]
fn objective(py: Python<'_>) -> PyResult<PyFunction> {
    let target = resolve_target::<ProtobufV2>(py)?.ok_or_else(|| {
        BridgeError::no_supported_protocol(py, &[TransferProtocolId::ProtobufV2])
    })?;
    target.transfer(py, ommx::Function::default())
}

At runtime this return value is an ommx.Function. pyo3-stub-gen also emits ommx.Function in the generated stub. The crate provides the same pattern for PyConstraint, PyDecisionVariable, PyInstance, PyParametricInstance, PySolution, and PySampleSet. Each wrapper owns a completed Python object and is constructed through Target::transfer.

§Sender and receiver contract

This crate serves two independently built extensions: a producer such as JijModeling, and the installed OMMX Python SDK. They communicate through a Python callable contract.

SideResponsibility
Sender / transfer APIRead ommx._ommx_rust._bridge_supported_protocols(), select a protocol, and call its named receiver functions under ommx._ommx_rust with the specified arguments.
Python SDK / receiver APISupply factories that parse protocol payloads with the receiving SDK and construct its canonical classes. register_receivers implements the required functions and derives the supported-protocol list from those configurations.

The sender relies on those functions and their behavior, not on how the SDK implements them. A receiver can implement the same contract without using this crate. The receiver API is an implementation aid: keeping both sides’ endpoint names, call signatures, and registration in the bridge crate makes their definitions easier to maintain together. Payload parsing and feature validation belong to the receiving SDK; Rust SDK serialization belongs to the optional sender implementation.

The contract specifies payload representations, ownership, and canonical Python return types such as ommx.Instance. It does not require public Python constructors or from_v1_bytes / from_v2_bytes methods. The SDK’s factories must construct the promised Python classes and preserve the supplied domain data; the sender does not inspect their private Rust implementation or validate the returned Python class at runtime. Rust values and factory pointers stay inside their respective extensions.

Compatibility is determined by the selected protocol, not by matching SDK or bridge crate version numbers:

  • Once a protocol is published, its ID and callable contract must retain their meaning. The underscore-prefixed receiver functions are internal to the bridge, but independently distributed senders depend on them. Incompatible contract changes, including adding required receiver functions, need a new protocol ID.
  • Protocol support covers the transfer representation, not every payload feature. Serializers and parsers decide whether a particular value is supported. A failed transfer never automatically retries another protocol.
  • A receiver can register old and new protocol configurations together during migration. Removing support breaks senders that rely on that protocol and must be treated as an explicit compatibility change.

The two extensions may use different bridge crate versions while honoring the same runtime contract. Within each extension, the SDK, bridge, and PyO3 Rust dependencies must resolve compatible types. This release accepts PyO3 0.27.2 through the 0.29 release line. The sender feature uses pyo3-stub-gen 0.23; a receiving SDK can use its own stub generator version without taking a dependency on the bridge’s generator.

§Select a transfer target before compilation

Probe one protocol at a time with resolve_target::<P>. The order of the caller’s if statements expresses its preference:

use ommx::{Constraint, ConstraintID, DecisionVariable, Instance, Sense};
use ommx_pyo3_bridge::{
    resolve_target, BridgeError, ProtobufV1, ProtobufV2, PyInstance, TransferProtocolId,
};
use pyo3::prelude::*;
use std::collections::BTreeMap;

#[pyo3_stub_gen::derive::gen_stub_pyfunction]
#[pyfunction]
fn compile(py: Python<'_>) -> PyResult<PyInstance> {
    if let Some(target) = resolve_target::<ProtobufV2>(py)? {
        let mut instance: Instance = compile_regular();
        // Promote x + y = 1 to a first-class one-hot constraint, requiring v2.
        let request = [ConstraintID::from(23)].into_iter().collect();
        instance.plan_promote_one_hot(&request)
            .apply_if_fully_valid()
            .expect("the example is exactly one-hot");
        return target.transfer(py, ommx::v2::Instance::from(instance));
    }
    if let Some(target) = resolve_target::<ProtobufV1>(py)? {
        // Validate the same equality, then describe it with an advisory hint.
        let mut instance: Instance = compile_regular();
        let request = [ConstraintID::from(23)].into_iter().collect();
        let hints = instance.plan_promote_one_hot(&request)
            .into_v1_hints()
            .expect("the example is exactly one-hot");
        let message = instance.into_v1_with_hints(hints)
            .expect("the regular formulation supports v1");
        return target.transfer(py, message);
    }
    Err(BridgeError::no_supported_protocol(
        py,
        &[TransferProtocolId::ProtobufV2, TransferProtocolId::ProtobufV1],
    ))
}

fn compile_regular() -> Instance {
    // Minimize x subject to x + y = 1, with binary x and y.
    let equality =
        ((ommx::linear!(7) + ommx::linear!(8)).unwrap() + ommx::coeff!(-1.0)).unwrap();
    Instance::builder()
        .sense(Sense::Minimize)
        .objective(ommx::Function::from(ommx::linear!(7)))
        .decision_variables(BTreeMap::from([
            (7.into(), DecisionVariable::binary()),
            (8.into(), DecisionVariable::binary()),
        ]))
        .constraints(BTreeMap::from([(
            23.into(),
            Constraint::equal_to_zero(equality.into()),
        )]))
        .build()
        .unwrap()
}

Both paths construct the same regular formulation and validate a promotion plan. The V2 path applies the plan and creates a v2::Instance containing a first-class one-hot constraint. The V1 path exports the plan as advisory hints and creates a checked v1::Instance containing the original equality. into_v1_with_hints validates those hints against the instance being sent. The bridge encodes the chosen message without changing the mathematical model. The same workflow applies to SOS1 Big-M promotion plans. Transfer does not lower constraints, apply promotion, or retry after an export error.

The stub is def compile() -> ommx.Instance. Protocols belong to target selection and transfer; the return type is simply PyInstance on both paths. Target<P>::transfer infers the return type T, or accepts it explicitly as transfer::<PyInstance>. It requires T: TransferVia<P> and an input implementing Export<P, T>, so unsupported type/protocol pairs and Rust inputs fail to compile. Export is open to downstream source types; protocols and Python type/protocol pairs are bridge-owned. A target can transfer multiple supported types through the same protocol without probing the SDK again.

resolve_target imports ommx and calls the binding-private declaration ommx._ommx_rust._bridge_supported_protocols() -> list[int]. It also loads the SDK’s ommx._ommx_rust.BridgeError class. It returns Some with a Target<P> if P is advertised, or None otherwise. Each probe reads the declaration anew; callers decide which protocol to try next and what to do if none are supported. Errors propagate with ? instead of being interpreted as lack of support.

The examples raise BridgeError when the loaded SDK supports none of the caller’s requested protocols. This is a caller-owned compatibility decision after a successful import and protocol probe.

A successful target retains the selected protocol, loaded SDK module, and its exception class. Target::transfer resolves the type-specific reconstruction callable on that module, exports the payload, and imports it. Receiver lookup errors therefore occur at transfer, when the return type is known. Replacing sys.modules["ommx"] does not redirect an existing target, but modifying receivers on the retained module affects subsequent transfers. The target never rechecks protocol support.

The returned PyInstance internally owns the completed Python object. Returning it through PyO3 does not repeat the transfer. The wrapper holds a single owned Python reference without an intermediate Rust value.

Pass Rust SDK values or complete protobuf messages to transfer; Py* wrappers are output values. Components with owner-side data use tuples: (Constraint, ConstraintContext) and (VariableID, DecisionVariable, ModelingLabel).

There is no SDK version comparison, attribute-based capability inference, or global negotiation cache. Unknown IDs are ignored. A missing SDK, exception class, declaration API, or receiver raises ImportError. Non-callable bridge APIs also raise ImportError. Invalid declarations and export/import failures raise the SDK’s BridgeError, with transfer failures identifying the Python type, protocol, and stage, with the underlying exception in __cause__. Failure after selection never retries another protocol. Payload features are validated by the serializers and parsers, not by negotiation.

§Bridge errors

The Python SDK defines ommx.BridgeError, a RuntimeError subclass used by senders and receivers. Invalid protocol declarations, receiver registration errors, and payload/transfer failures use this class. After all acceptable protocol probes return None, callers use BridgeError::no_supported_protocol(py, &requested) to report the mismatch. It includes the requested protocol names in caller order without probing the SDK again. BridgeError::new_err(py, message) is available for other caller-defined bridge failures. Transfer wrappers retain the original Python exception in __cause__. Python argument-binding errors and exceptions raised directly by SDK factories keep their original types; a sender wraps factory failures during transfer.

The SDK supplies its exception class to register_receivers, which publishes it as ommx._ommx_rust.BridgeError. The SDK re-exports that class as ommx.BridgeError; the sender retrieves the actual Python class from the SDK. Independently built senders do not define their own exception classes, so except ommx.BridgeError catches their transfer failures too. SDK imports and missing bridge APIs raise ImportError because the expected SDK interface is unavailable. No Rust exception object or layout is shared across extensions.

Python v3 advertises both protocols. A future Python v2 compatibility release must explicitly advertise the contracts it implements and provide the same top-level class aliases and receivers. This change does not by itself make existing Python v2 releases compatible with negotiated transfers.

§Transfer contracts

TransferProtocolId is the bridge’s registry. IDs are stable and never reused; their numeric order does not express preference. A protocol defines payloads, receiver signatures, and ownership, independently of SDK release numbers.

IDRust markerRoot representation
1ProtobufV1ommx.v1 protobuf messages
2ProtobufV2ommx.v2 protobuf messages

The root types are Instance, ParametricInstance, Solution, and SampleSet. They use private receivers on ommx._ommx_rust, named _bridge_protobuf_v1_<kind>_from_bytes(bytes) and _bridge_protobuf_v2_<kind>_from_bytes(bytes), where <kind> is instance, parametric_instance, solution, or sample_set. Public Python class constructors and decoding methods are not part of the transfer contract.

ommx::Instance and ommx::ParametricInstance export over ProtobufV1 through their checked v1 serializers. Special constraints that cannot be represented in v1 cause an export error. Domain Solution and SampleSet only export over ProtobufV2: their existing v1 conversions discard special-constraint results, so callers choosing ProtobufV1 must explicitly construct v1 messages. All four domain roots export over ProtobufV2.

For callers that already hold complete raw ommx::v1 roots, ProtobufV1 also accepts those messages without parsing them into the Rust v3 domain model. This preserves advisory ConstraintHints until they reach the receiver. A v3 receiver may ignore those hints, as allowed by the schema.

ProtobufV2 also accepts a raw ommx::v2::Instance. Both raw instance paths encode the supplied message without parsing it into a Rust domain model; the selected Python receiver owns validation of incoming wire data.

Components use binding-private receivers on ommx._ommx_rust:

TypeProtobufV1 receiver and argumentsProtobufV2 receiver and arguments
Function_bridge_protobuf_v1_function_from_bytes(v1_function)_bridge_protobuf_v2_function_from_bytes(v1_function)
Constraint_bridge_protobuf_v1_constraint_from_bytes(v1_constraint)_bridge_protobuf_v2_constraint_from_bytes(v2_regular_constraint, v2_context)
DecisionVariable_bridge_protobuf_v1_decision_variable_from_bytes(v1_variable)_bridge_protobuf_v2_decision_variable_from_bytes(id, v2_variable, v2_label)

All arguments other than id are protobuf bytes. Function’s schema remains in ommx.v1 even in normalized roots. Component payloads do not create new persistence roots.

ProtobufV1 accepts raw v1 messages for all three components, plus Rust Function and (VariableID, DecisionVariable, ModelingLabel). The v1 Constraint source must carry its original ID and label; the bridge does not invent an ID for a detached PyConstraint. Python v3 reconstructs a detached constraint, whose identity will be assigned by its eventual owning Instance. A v1 variable with a fixed value is rejected by the detached receiver because fixed values belong to an Instance. ProtobufV2 accepts Rust Function, (Constraint, ConstraintContext), and (VariableID, DecisionVariable, ModelingLabel).

These contracts cover representation and ownership, not feature sets. An older parser can reject a protobuf variant it does not understand even when its protocol is selected. Existing IDs and endpoint meanings must not be changed to reinterpret such payloads. A future shared-memory or Arrow transfer would get a new marker, ID, and TransferVia::Payload type; payloads are not required to be byte buffers and own their resources until import or drop.

§Configure Python SDK receivers

The Python SDK supplies a ProtobufV1ReceiverConfig and/or ProtobufV2ReceiverConfig. These are distinct concrete types, each defining the required factory signatures for its protocol. Both currently require factories for all seven canonical classes: Function, Constraint, DecisionVariable, Instance, ParametricInstance, Solution, and SampleSet.

Factories receive the protocol’s wire arguments unchanged. ProtobufV1 factories accept (Python, &[u8]) containing complete v1 messages. ProtobufV2 uses the same shape for roots and Function, (Python, &[u8], &[u8]) for Constraint, and (Python, u64, &[u8], &[u8]) for DecisionVariable, matching the argument table above. Borrowed bytes remain valid for the call; factories return owned Python objects. Future protocols may use representations other than bytes.

Each SDK factory parses and validates with its own SDK, then constructs the promised Python class. The bridge does not decode into an intermediate Rust SDK domain model. In particular, a v2 factory can preserve complete v1 ConstraintHints and component IDs without going through a v3 parser. A v3 factory uses the v3 parsers, which may ignore advisory v1 hints. Factories must preserve domain data required by the contract, reject unsupported features through their parsers, and classify malformed or unsupported payloads using the SDK’s BridgeError. Python-owned constructor exceptions propagate unchanged; senders wrap failures with transfer context and __cause__.

Collect the concrete configurations as ReceiverConfig enum values and pass the complete list to register_receivers once during SDK initialization:

use ommx_pyo3_bridge::{
    register_receivers, ProtobufV1ReceiverConfig, ProtobufV2ReceiverConfig, ReceiverConfig,
};
use pyo3::{prelude::*, types::PyType};

fn install_receivers(
    module: &Bound<'_, PyModule>,
    bridge_error: &Bound<'_, PyType>,
    v1: ProtobufV1ReceiverConfig,
    v2: ProtobufV2ReceiverConfig,
) -> PyResult<()> {
    let configs: Vec<ReceiverConfig> = vec![v1.into(), v2.into()];
    register_receivers(module, bridge_error, configs)
}

The list determines the supported protocols and their declaration order; there is no separate list of protocol IDs to keep in sync. Registration receives the SDK-defined BridgeError class alongside that list and uses it for registration failures. Sender-side errors use the same class via Python lookup. Registration prepares all private receivers and publishes the support declaration last. Duplicate protocols, repeated registration, and occupied endpoint names raise BridgeError before any module attributes are changed. An empty list advertises no supported protocols. Registration also exposes the shared BridgeError class. No public Python domain classes or decoding methods need to be present for registration.

Sender lookups and receiver registration use the same bridge-owned endpoint definitions. Receiver factories are retained locally by private bound Python methods, with no global configuration and no Rust object sharing between independent extension modules. Call signatures, registration, and sender error conversion belong to the bridge crate. Each Python SDK owns payload decoding, feature validation, its canonical classes, and public decoding APIs. The bridge does not look up or call public domain constructors or decoding methods.

Structs§

BridgeError
Constructors for the installed Python SDK’s ommx.BridgeError exception.
ProtobufV1
Transfer through the OMMX v1 protobuf representations.
ProtobufV2
Transfer through normalized roots and the established detached receivers.
ProtobufV1ReceiverConfig
Python SDK factories for the complete ProtobufV1 transfer contract.
ProtobufV2ReceiverConfig
Python SDK factories for the complete ProtobufV2 transfer contract.
PyConstraint
A completed transfer of ommx.Constraint.
PyDecisionVariable
A completed transfer of ommx.DecisionVariable.
PyFunction
A completed transfer of ommx.Function.
PyInstance
A completed transfer of ommx.Instance.
PyParametricInstance
A completed transfer of ommx.ParametricInstance.
PySampleSet
A completed transfer of ommx.SampleSet.
PySolution
A completed transfer of ommx.Solution.
Target
A loaded Python SDK that explicitly supports protocol P.

Enums§

ReceiverConfig
One complete protocol configuration in a receiver registration list.
TransferProtocolId
Fixed transfer contracts, independent of SDK and protobuf version numbers. IDs are never reused and their numeric order does not express preference.

Traits§

Export
Convert a complete source value into the representation for T over P.
TransferProtocol
A bridge-owned transfer contract, including representation and ownership. New contracts get a new ID; codecs determine which values they can encode.
TransferVia
A Python return type that can be reconstructed through protocol P.

Functions§

register_receivers
Register one list of protocol configurations on the SDK’s extension module.
resolve_target
Load Python OMMX and probe its explicit support for one protocol.