From 95b220d7c354a2f20a557090429599d975e8799a Mon Sep 17 00:00:00 2001 From: Allan Scott Date: Fri, 29 May 2026 22:01:15 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat(runtime-python):=20cross-tool=20bridge?= =?UTF-8?q?=20=E2=80=94=20typed=20returns=20+=20q-orca=20invocation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the orca side of the cross-tool bridge protocol (docs/cross-tool-invoke-and-returns.md) in the Python runtime — a classical orca orchestrator can now invoke a q-orca quantum child. - Parser: ## returns section -> ReturnDef; InvokeDef.returns/shots via inline (`invoke: QForward input:{...} shots:1024 returns:{prob: prob_bits_0}`) and multi-line forms. - bridge.py: the q-orca bridge-protocol contract mirrored at version 1.0 — descriptor / invocation / result envelopes, BridgeError, and dispatch_foreign (invocation on stdin -> result on stdout). - Runtime: OrcaMachine.register_foreign_runner() + outbound dispatch in start_child_machine (foreign child -> bridge), binding the child's returns into parent context and firing on_done/on_error. - Public API: ReturnDef, InvokeDef, and the bridge functions exported from the package. - 12 tests (parser, protocol/conformance, dispatch over a real subprocess, and the end-to-end runtime path with a mock foreign quantum child). Full runtime-python suite: 105 passed. Docs: adds a "Multi-Runtime Adoption" section explaining the protocol is runtime-agnostic (JSON + process boundary) and what TS/Go/Rust would each need, plus the shared-conformance-suite rationale. Scoped to the outbound direction (orchestrator -> quantum child); inbound needs a per-runtime auto-driver for reactive machines. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/cross-tool-invoke-and-returns.md | 46 +++++ .../orca_runtime_python/__init__.py | 24 +++ .../orca_runtime_python/bridge.py | 153 ++++++++++++++++ .../orca_runtime_python/machine.py | 65 ++++++- .../orca_runtime_python/parser.py | 85 +++++++-- .../orca_runtime_python/types.py | 20 +++ packages/runtime-python/tests/test_bridge.py | 168 ++++++++++++++++++ 7 files changed, 540 insertions(+), 21 deletions(-) create mode 100644 packages/runtime-python/orca_runtime_python/bridge.py create mode 100644 packages/runtime-python/tests/test_bridge.py diff --git a/docs/cross-tool-invoke-and-returns.md b/docs/cross-tool-invoke-and-returns.md index d47ac21..51f3469 100644 --- a/docs/cross-tool-invoke-and-returns.md +++ b/docs/cross-tool-invoke-and-returns.md @@ -266,6 +266,52 @@ and binds the synthesized `prob_bits_0` into the trainer's `prob`. (The q-orca side is the `composed_predictive_coder` fixture, which already runs in-tool via `q-orca run`.) +## Multi-Runtime Adoption + +Orca ships four runtimes (`runtime-python`, `runtime-ts`, `runtime-go`, +`runtime-rust`). The bridge is **runtime-agnostic by construction**: the entire +contract is three JSON envelopes (descriptor / invocation / result) plus a +`protocol_version` constant, carried over a **process boundary** — no shared AST, +no FFI, no language-specific types. Every language can serialize JSON and spawn a +subprocess, so any runtime can implement it; the protocol is not even +orca-specific (any tool that speaks the envelopes can join). + +**What a runtime needs** (the same shape everywhere — mirroring `runtime-python`): + +1. **Parser** — recognize `## returns` and the invoke `returns:` / `shots:` + modifiers (each runtime has its own parser). +2. **Envelopes** — serialize/deserialize the three shapes + a `protocol_version` + check (`encoding/json`, `serde_json`, `JSON.parse`, …). +3. **`dispatch_foreign`** — spawn the child runner, pipe the invocation envelope + to stdin, read the result envelope from stdout (`os/exec`, `std::process`, + `child_process`). +4. **One dispatch hook** — a foreign-runner registry consulted where the invoke + target is not a local sibling (in `runtime-python` this is the + `start_child_machine` foreign branch; every runtime has the equivalent). + +**Direction asymmetry.** *Outbound* (a runtime as the classical orchestrator +invoking a quantum child) is cheap and clean in any language — and is the +motivating case; the foreign child is essentially always a **q-orca (Python)** +process via `q-orca run --bridge`, since q-orca is the only quantum side. +*Inbound* (a runtime serving as the invoked child) is symmetric at the protocol +level, but orca machines are **reactive/event-driven**, so "run to completion as +a child" needs a per-runtime auto-driver — a real work item independent of +language. (`runtime-python` therefore scopes its first cut to outbound.) + +**Why do it in more than one runtime.** The contract is identical, so a **shared +conformance suite** — every runtime checked against the *same* fixture envelopes +and the same `1.0` constant — is what keeps four independent implementations from +silently drifting. Each runtime then gives its own users (Go/Rust/TS +orchestrators) the same hybrid classical↔quantum capability. + +**When it's worth it.** For parity, or a specific deployment (a Go service +dispatching quantum jobs; the Rust runtime's C/Fortran FFI callers reaching a +quantum child transitively). It may *not* be urgent where the real demand is "an +ML/training loop drives a quantum forward pass" — Python is the natural +orchestration language there, so `runtime-python` likely covers most actual +usage, and the others are completeness rather than need-driven until a concrete +consumer appears. + ## Open Questions 1. **How does the verifier know a local `.q.orca.md` child is diff --git a/packages/runtime-python/orca_runtime_python/__init__.py b/packages/runtime-python/orca_runtime_python/__init__.py index fcff796..0a5af9c 100644 --- a/packages/runtime-python/orca_runtime_python/__init__.py +++ b/packages/runtime-python/orca_runtime_python/__init__.py @@ -10,6 +10,8 @@ GuardDef, ActionSignature, EffectDef, + ReturnDef, + InvokeDef, MachineDef, StateValue, Context, @@ -29,6 +31,17 @@ from .parser import parse_orca_md, parse_orca_auto +from .bridge import ( + BRIDGE_PROTOCOL_VERSION, + BridgeError, + descriptor_for, + build_invocation, + make_result, + parse_result, + parse_invocation, + dispatch_foreign, +) + from .persistence import PersistenceAdapter, AsyncPersistenceAdapter, FilePersistence from .logging import LogSink, FileSink, ConsoleSink, MultiSink @@ -42,6 +55,8 @@ "GuardDef", "ActionSignature", "EffectDef", + "ReturnDef", + "InvokeDef", "MachineDef", "StateValue", "Context", @@ -60,6 +75,15 @@ # Parser "parse_orca_md", "parse_orca_auto", + # Bridge (cross-tool composition) + "BRIDGE_PROTOCOL_VERSION", + "BridgeError", + "descriptor_for", + "build_invocation", + "make_result", + "parse_result", + "parse_invocation", + "dispatch_foreign", # Persistence "PersistenceAdapter", "AsyncPersistenceAdapter", diff --git a/packages/runtime-python/orca_runtime_python/bridge.py b/packages/runtime-python/orca_runtime_python/bridge.py new file mode 100644 index 0000000..b91f840 --- /dev/null +++ b/packages/runtime-python/orca_runtime_python/bridge.py @@ -0,0 +1,153 @@ +"""Cross-tool composition bridge — orca's Python side. + +Mirrors the q-orca ``bridge-protocol`` contract so a classical orca orchestrator +can invoke a q-orca quantum child (and orca can be invoked by q-orca). Three +versioned JSON envelopes; transport is process + JSON over each tool's ``run`` +entry point. See orca-lang/docs/cross-tool-invoke-and-returns.md and q-orca's +openspec ``bridge-protocol`` spec. +""" + +from __future__ import annotations + +import json +import subprocess +from typing import Any + +from .types import MachineDef + +# Must match q-orca's BRIDGE_PROTOCOL_VERSION exactly. +BRIDGE_PROTOCOL_VERSION = "1.0" + +_DEFAULT_TIMEOUT_S = 30 + + +class BridgeError(Exception): + """A bridge/transport failure (distinct from a child error in the envelope). + + Raised for: unlaunchable runner, timeout, non-JSON output, or an + unsupported ``protocol_version``. + """ + + code = "BRIDGE_ERROR" + + +def _wire_type_from_value(value: Any) -> str: + """Infer a wire type from a context default value. + + orca's MachineDef.context stores ``{name: default}`` without the declared + type, so the descriptor infers it from the default (``any`` when unknown). + """ + if isinstance(value, bool): + return "bool" + if isinstance(value, int): + return "int" + if isinstance(value, float): + return "float" + if isinstance(value, str): + return "string" + if isinstance(value, list): + return "list" + return "any" + + +def descriptor_for(machine: MachineDef) -> dict: + """Emit a machine descriptor. orca machines are classical → measurement_bearing False.""" + return { + "protocol_version": BRIDGE_PROTOCOL_VERSION, + "name": machine.name, + "params": [ + {"name": name, "type": _wire_type_from_value(default)} + for name, default in machine.context.items() + ], + "returns": [ + {"name": r.name, "type": r.type, "statistics": list(r.statistics)} + for r in machine.returns + ], + "measurement_bearing": False, + } + + +def build_invocation(child: str, args: dict, shots: int | None, return_bindings: dict) -> dict: + return { + "protocol_version": BRIDGE_PROTOCOL_VERSION, + "child": child, + "args": dict(args), + "shots": shots, + "return_bindings": dict(return_bindings), + } + + +def make_result(final_state: str, returns: dict, error: dict | None = None) -> dict: + envelope = { + "protocol_version": BRIDGE_PROTOCOL_VERSION, + "final_state": final_state, + "returns": dict(returns), + } + if error is not None: + envelope["error"] = error + return envelope + + +def _check_version(envelope: dict, kind: str) -> None: + version = envelope.get("protocol_version") + if version != BRIDGE_PROTOCOL_VERSION: + raise BridgeError( + f"unsupported bridge {kind} protocol_version {version!r} " + f"(this tool speaks {BRIDGE_PROTOCOL_VERSION!r})" + ) + + +def _load(data: Any, kind: str) -> dict: + if isinstance(data, (str, bytes)): + try: + data = json.loads(data) + except (ValueError, TypeError) as exc: + raise BridgeError(f"{kind} envelope is not valid JSON: {exc}") from exc + if not isinstance(data, dict): + raise BridgeError(f"{kind} envelope must be a JSON object") + _check_version(data, kind) + return data + + +def parse_result(data: Any) -> dict: + env = _load(data, "result") + return { + "final_state": env.get("final_state", ""), + "returns": env.get("returns") or {}, + "error": env.get("error"), + } + + +def parse_invocation(data: Any) -> dict: + env = _load(data, "invocation") + if "child" not in env: + raise BridgeError("invocation envelope is missing 'child'") + return { + "child": env["child"], + "args": env.get("args") or {}, + "shots": env.get("shots"), + "return_bindings": env.get("return_bindings") or {}, + } + + +def dispatch_foreign(runner_argv: list[str], invocation: dict, timeout: float = _DEFAULT_TIMEOUT_S) -> dict: + """Run a foreign child via ``runner_argv``: invocation envelope on stdin, + result envelope on stdout. Raises ``BridgeError`` on any transport failure.""" + payload = json.dumps(invocation) + try: + proc = subprocess.run( + runner_argv, input=payload, capture_output=True, text=True, timeout=timeout + ) + except FileNotFoundError as exc: + raise BridgeError(f"foreign runner not found: {runner_argv[0]!r}") from exc + except subprocess.TimeoutExpired as exc: + raise BridgeError(f"foreign runner timed out after {timeout}s") from exc + + try: + return parse_result(proc.stdout) + except BridgeError: + if proc.returncode != 0: + raise BridgeError( + f"foreign runner exited {proc.returncode}: {proc.stderr.strip()[:200]}" + ) + raise diff --git a/packages/runtime-python/orca_runtime_python/machine.py b/packages/runtime-python/orca_runtime_python/machine.py index f678cbe..e931aae 100644 --- a/packages/runtime-python/orca_runtime_python/machine.py +++ b/packages/runtime-python/orca_runtime_python/machine.py @@ -35,6 +35,7 @@ ) from .bus import EventBus, Event, EventType, get_event_bus from .persistence import PersistenceAdapter, AsyncPersistenceAdapter +from .bridge import BridgeError, build_invocation, dispatch_foreign # Type alias for transition callback @@ -114,6 +115,9 @@ def __init__( # Child machine management self._child_machines: dict[str, OrcaMachine] = {} self._sibling_machines: dict[str, MachineDef] | None = None + # Cross-tool bridge: child name -> runner argv for a foreign (other-tool) + # child dispatched over the bridge instead of run as a local machine. + self._foreign_runners: dict[str, list[str]] = {} self._active_invoke: str | None = None def _get_initial_state(self) -> str: @@ -240,15 +244,26 @@ async def resume(self, snap: dict[str, Any]) -> None: for leaf in self._state.leaves(): self._start_timeout_for_state(leaf) + def register_foreign_runner(self, machine_name: str, runner_argv: list[str]) -> None: + """Register a foreign (other-tool) child, invoked over the bridge. + + When an invoke targets `machine_name` and it is not a local sibling, the + runtime dispatches it via `runner_argv` (e.g. ``["q-orca", "run", + "forward.q.orca.md", "--bridge"]``) instead of starting a local machine. + """ + self._foreign_runners[machine_name] = list(runner_argv) + def register_machines(self, machines: dict[str, MachineDef]) -> None: """Register sibling machines for invocation.""" self._sibling_machines = machines async def start_child_machine(self, state_name: str, invoke_def: InvokeDef) -> None: """Start a child machine as part of an invoke state.""" - if self._sibling_machines is None: - return - if invoke_def.machine not in self._sibling_machines: + siblings = self._sibling_machines or {} + if invoke_def.machine not in siblings: + # Foreign (other-tool) child → dispatch over the bridge. + if invoke_def.machine in self._foreign_runners: + await self._invoke_foreign(invoke_def) return child_def = self._sibling_machines[invoke_def.machine] @@ -291,6 +306,50 @@ async def on_transition_handler(old: StateValue, new: StateValue) -> None: child.on_transition = on_transition_handler await child.start() + async def _invoke_foreign(self, invoke_def: InvokeDef) -> None: + """Dispatch an invoke to a foreign (other-tool) child over the bridge. + + Builds the invocation envelope from the parent context (via `input`), + runs the foreign runner off the event loop, binds the child's declared + returns into the parent context, and emits `on_done` / `on_error`. + """ + args: dict[str, Any] = {} + if invoke_def.input: + for child_param, parent_expr in invoke_def.input.items(): + field_name = parent_expr.replace("ctx.", "") + args[child_param] = self.context.get(field_name) + envelope = build_invocation( + invoke_def.machine, args, invoke_def.shots, invoke_def.returns or {} + ) + runner = self._foreign_runners[invoke_def.machine] + + async def _emit_error(error: dict[str, Any]) -> None: + if invoke_def.on_error: + await self.send(invoke_def.on_error, {"child": invoke_def.machine, "error": error}) + + loop = asyncio.get_event_loop() + try: + result = await loop.run_in_executor( + None, dispatch_foreign, list(runner), envelope + ) + except BridgeError as exc: + await _emit_error({"code": exc.code, "message": str(exc)}) + return + if result.get("error"): + await _emit_error(result["error"]) + return + + if invoke_def.returns: + for parent_field, child_return in invoke_def.returns.items(): + if child_return in result["returns"]: + self.context[parent_field] = result["returns"][child_return] + if invoke_def.on_done: + await self.send(invoke_def.on_done, { + "child": invoke_def.machine, + "final_state": result["final_state"], + "returns": result["returns"], + }) + async def stop_child_machine(self, state_name: str) -> None: """Stop a child machine associated with a state.""" if self._active_invoke == state_name: diff --git a/packages/runtime-python/orca_runtime_python/parser.py b/packages/runtime-python/orca_runtime_python/parser.py index 3d80ece..42f23f1 100644 --- a/packages/runtime-python/orca_runtime_python/parser.py +++ b/packages/runtime-python/orca_runtime_python/parser.py @@ -31,8 +31,11 @@ RegionDef, ParallelDef, InvokeDef, + ReturnDef, ) +_RETURN_STATISTICS = frozenset({"expectation", "histogram", "variance"}) + class ParseError(Exception): """Raised when parsing fails.""" @@ -219,6 +222,20 @@ class _MdStateEntry: ignored_events: list[str] = field(default_factory=list) invoke: InvokeDef | None = None _pending_on_error: str | None = None # temp: on_error parsed before invoke + _pending_returns: dict[str, str] | None = None # temp: returns parsed before invoke + + +def _parse_brace_map(text: str, key: str) -> dict[str, str] | None: + """Parse `key: { a: b, c: d }` out of `text`; None if absent.""" + m = re.search(key + r":\s*\{([^}]*)\}", text) + if not m: + return None + out: dict[str, str] = {} + for pair in m.group(1).split(","): + if ":" in pair: + k, v = pair.split(":", 1) + out[k.strip()] = v.strip() + return out def _parse_md_state_bullet(entry: _MdStateEntry, text: str) -> None: @@ -260,26 +277,37 @@ def _parse_md_state_bullet(entry: _MdStateEntry, text: str) -> None: entry._pending_on_error = val if entry.invoke: entry.invoke.on_error = val + elif text.startswith("returns:") and entry.invoke is not None: + # Multi-line form: `- returns: { parent: child, ... }` after the invoke. + entry.invoke.returns = _parse_brace_map(text, "returns") + elif text.startswith("returns:"): + # returns bullet seen before the invoke bullet — stash it. + entry._pending_returns = _parse_brace_map(text, "returns") elif text.startswith("invoke:"): - rest = text[7:].strip() # "MachineName" or "MachineName input: { ... }" - machine_name = rest - input_map: dict[str, str] | None = None - - # Check for input mapping - input_match = re.search(r"input:\s*\{([^}]+)\}", rest) - if input_match: - machine_name = rest[:input_match.start()].strip() - input_str = input_match.group(1) - input_map = {} - for pair in input_str.split(","): - if ":" in pair: - key, value = pair.split(":", 1) - input_map[key.strip()] = value.strip() - - entry.invoke = InvokeDef(machine=machine_name, input=input_map) - # Apply pending on_error if we already parsed it + rest = text[7:].strip() + # Inline modifiers: `Child input: {...} shots: N returns: {...}`. + input_map = _parse_brace_map(rest, "input") + returns_map = _parse_brace_map(rest, "returns") + shots = None + shots_match = re.search(r"shots:\s*(\d+)", rest) + if shots_match: + shots = int(shots_match.group(1)) + # The machine name is the text before the first modifier keyword. + cut = len(rest) + for kw in ("input:", "returns:", "shots:"): + idx = rest.find(kw) + if idx != -1: + cut = min(cut, idx) + machine_name = rest[:cut].strip() + + entry.invoke = InvokeDef( + machine=machine_name, input=input_map, returns=returns_map, shots=shots + ) + # Apply anything parsed before the invoke bullet. if entry._pending_on_error: entry.invoke.on_error = entry._pending_on_error + if entry._pending_returns and entry.invoke.returns is None: + entry.invoke.returns = entry._pending_returns def _build_md_states_at_level( @@ -704,6 +732,7 @@ def _parse_machine_elements(elements: list[_MdElement]) -> MachineDef: transitions: list[Transition] = [] guards: dict[str, GuardExpression] = {} actions: list[ActionSignature] = [] + returns: list[ReturnDef] = [] effects: list[EffectDef] = [] state_entries: list[_MdStateEntry] = [] current_state_entry: _MdStateEntry | None = None @@ -727,7 +756,7 @@ def _parse_machine_elements(elements: list[_MdElement]) -> MachineDef: # Section headings section_name = el.text.lower() - if section_name in ("context", "events", "transitions", "guards", "actions", "effects"): + if section_name in ("context", "events", "transitions", "guards", "actions", "effects", "returns"): current_state_entry = None next_el = elements[i + 1] if i + 1 < len(elements) else None @@ -815,6 +844,25 @@ def _parse_machine_elements(elements: list[_MdElement]) -> MachineDef: i += 2 continue + elif section_name == "returns" and isinstance(next_el, _MdTable): + ni = _find_column_index(next_el.headers, "name") + ti = _find_column_index(next_el.headers, "type") + si = _find_column_index(next_el.headers, "statistics") + for row in next_el.rows: + name = _strip_backticks(row[ni].strip() if ni >= 0 and ni < len(row) else "") + if not name: + continue + type_str = _strip_backticks(row[ti].strip() if ti >= 0 and ti < len(row) else "") + stats: list[str] = [] + if si >= 0 and si < len(row): + for s in row[si].split(","): + s = _strip_backticks(s.strip()).strip().lower() + if s and s in _RETURN_STATISTICS: + stats.append(s) + returns.append(ReturnDef(name=name, type=type_str, statistics=stats)) + i += 2 + continue + i += 1 continue @@ -879,6 +927,7 @@ def _parse_machine_elements(elements: list[_MdElement]) -> MachineDef: guards=guards, actions=actions, effects=effects, + returns=returns, version=machine_version, ) _validate_machine_def(defn) diff --git a/packages/runtime-python/orca_runtime_python/types.py b/packages/runtime-python/orca_runtime_python/types.py index 321e4fe..5e846ba 100644 --- a/packages/runtime-python/orca_runtime_python/types.py +++ b/packages/runtime-python/orca_runtime_python/types.py @@ -33,6 +33,25 @@ class InvokeDef: input: dict[str, str] | None = None on_done: str | None = None on_error: str | None = None + # Cross-tool extensions (cross-tool-bridge-protocol): bind child returns into + # parent context (parent_field -> child_return), and a shots count for a + # measurement-bearing (foreign quantum) child. + returns: dict[str, str] | None = None + shots: int | None = None + + +@dataclass +class ReturnDef: + """A value a machine exposes to a caller via its ## returns section. + + `name` is a context field (or an indexed reference like ``bits[0]`` for a + bridged quantum child); `statistics` is a subset of + {expectation, histogram, variance}, valid only on a measurement-bearing + machine. + """ + name: str + type: str + statistics: list[str] = field(default_factory=list) @dataclass @@ -99,6 +118,7 @@ class MachineDef: guards: dict[str, GuardExpression] = field(default_factory=dict) actions: list[ActionSignature] = field(default_factory=list) effects: list[EffectDef] = field(default_factory=list) + returns: list[ReturnDef] = field(default_factory=list) version: str = "0.1.0" diff --git a/packages/runtime-python/tests/test_bridge.py b/packages/runtime-python/tests/test_bridge.py new file mode 100644 index 0000000..b7f268f --- /dev/null +++ b/packages/runtime-python/tests/test_bridge.py @@ -0,0 +1,168 @@ +"""Tests for the cross-tool bridge — orca's Python side. + +Mirrors q-orca's bridge-protocol contract (version 1.0). The runtime test drives +an orca classical orchestrator invoking a foreign quantum child over the bridge, +using a mock foreign runner (so the suite has no q-orca dependency). +""" + +import json +import sys + +import pytest + +from orca_runtime_python.bridge import ( + BRIDGE_PROTOCOL_VERSION, + BridgeError, + build_invocation, + descriptor_for, + dispatch_foreign, + make_result, + parse_invocation, + parse_result, +) +from orca_runtime_python.machine import OrcaMachine +from orca_runtime_python.parser import parse_orca_md + +# A mock foreign runner: reads the invocation envelope from stdin, echoes a +# fixed result envelope (a 73%-expectation forward pass) on stdout. +_MOCK_RESULT = { + "protocol_version": "1.0", + "final_state": "measured", + "returns": {"prob_bits_0": 0.73, "hist_bits_0": {"0": 270, "1": 730}}, +} +_MOCK_RUNNER = [ + sys.executable, "-c", + "import sys,json; json.load(sys.stdin); " + f"print(json.dumps({_MOCK_RESULT!r}))", +] +_FAILING_RUNNER = [sys.executable, "-c", "import sys; sys.stderr.write('boom'); sys.exit(1)"] + + +# --------------------------------------------------------------------------- +# Parser: ## returns + invoke returns/shots +# --------------------------------------------------------------------------- + +class TestReturnsParsing: + def test_returns_section(self): + src = ( + "# machine Counter\n## context\n| Field | Type |\n| n | int |\n" + "## returns\n| Name | Type | Statistics |\n| converged | bool | |\n" + "## state a [initial]\n## state b [final]\n" + "## transitions\n| Source | Event | Target |\n| a | go | b |\n" + ) + m = parse_orca_md(src) + assert len(m.returns) == 1 + assert m.returns[0].name == "converged" and m.returns[0].type == "bool" + + def test_invoke_inline_shots_and_returns(self): + src = ( + "# machine Trainer\n## context\n| Field | Type |\n| theta | float |\n" + "## state step [initial]\n" + "- invoke: QForward input: { theta: ctx.theta } shots: 1024 returns: { prob: prob_bits_0 }\n" + "- on_done: STEPPED\n" + "## state stepped [final]\n" + "## transitions\n| Source | Event | Target |\n| step | STEPPED | stepped |\n" + ) + inv = parse_orca_md(src).states[0].invoke + assert inv.machine == "QForward" and inv.shots == 1024 + assert inv.returns == {"prob": "prob_bits_0"} + assert inv.input == {"theta": "ctx.theta"} and inv.on_done == "STEPPED" + + def test_invoke_multiline_returns_bullet(self): + src = ( + "# machine Trainer\n## context\n| Field | Type |\n| theta | float |\n" + "## state step [initial]\n" + "- invoke: QForward input: { theta: ctx.theta } shots: 1024\n" + "- returns: { prob: prob_bits_0, hist: hist_bits_0 }\n" + "- on_done: STEPPED\n" + "## state stepped [final]\n" + "## transitions\n| Source | Event | Target |\n| step | STEPPED | stepped |\n" + ) + inv = parse_orca_md(src).states[0].invoke + assert inv.returns == {"prob": "prob_bits_0", "hist": "hist_bits_0"} + assert inv.shots == 1024 + + +# --------------------------------------------------------------------------- +# Protocol envelopes (conformance with q-orca's 1.0 contract) +# --------------------------------------------------------------------------- + +class TestProtocol: + def test_version_constant(self): + assert BRIDGE_PROTOCOL_VERSION == "1.0" + + def test_descriptor_classical(self): + m = parse_orca_md( + "# machine Counter\n## context\n| Field | Type |\n| n | int |\n" + "## returns\n| Name | Type | Statistics |\n| converged | bool | |\n" + "## state a [initial]\n## state b [final]\n" + "## transitions\n| Source | Event | Target |\n| a | go | b |\n" + ) + d = descriptor_for(m) + assert d["measurement_bearing"] is False + assert d["protocol_version"] == "1.0" + assert d["returns"][0]["name"] == "converged" + + def test_envelope_round_trip(self): + inv = build_invocation("QForward", {"theta": 0.5}, 1024, {"prob": "prob_bits_0"}) + assert parse_invocation(json.dumps(inv))["child"] == "QForward" + res = make_result("measured", {"prob_bits_0": 0.73}) + assert parse_result(json.dumps(res))["returns"]["prob_bits_0"] == 0.73 + + def test_version_mismatch_raises(self): + with pytest.raises(BridgeError): + parse_result({"protocol_version": "0.0", "final_state": "x", "returns": {}}) + + def test_non_json_raises(self): + with pytest.raises(BridgeError): + parse_result("not json {") + + +# --------------------------------------------------------------------------- +# Dispatch (outbound) over a real subprocess +# --------------------------------------------------------------------------- + +class TestDispatch: + def test_dispatch_foreign_round_trip(self): + inv = build_invocation("QForward", {"theta": 0.5}, 1024, {"prob": "prob_bits_0"}) + result = dispatch_foreign(_MOCK_RUNNER, inv) + assert result["returns"]["prob_bits_0"] == 0.73 + assert result["final_state"] == "measured" + + def test_bridge_error_on_unlaunchable_runner(self): + with pytest.raises(BridgeError): + dispatch_foreign(["orca-no-such-binary-xyz"], build_invocation("X", {}, None, {})) + + def test_bridge_error_on_failing_runner(self): + with pytest.raises(BridgeError): + dispatch_foreign(_FAILING_RUNNER, build_invocation("X", {}, None, {})) + + +# --------------------------------------------------------------------------- +# Runtime: an orca orchestrator invokes a foreign quantum child +# --------------------------------------------------------------------------- + +_TRAINER = """# machine Trainer +## context +| Field | Type | Default | +| theta | float | 0.5 | +| prob | float | 0.0 | +## state step [initial] +- invoke: QForward input: { theta: ctx.theta } shots: 512 returns: { prob: prob_bits_0 } +- on_done: STEPPED +## state stepped [final] +## transitions +| Source | Event | Target | +| step | STEPPED | stepped | +""" + + +async def test_orca_parent_invokes_foreign_quantum_child(): + machine = OrcaMachine(definition=parse_orca_md(_TRAINER)) + machine.register_foreign_runner("QForward", _MOCK_RUNNER) + await machine.start() + # The bridge bound the child's prob_bits_0 aggregate into the parent context, + # and on_done drove the transition to the final state. + assert machine.context["prob"] == 0.73 + assert machine.state.leaf() == "stepped" + await machine.stop() From 832eba9bb9595064a610b9ad9429aeedd4882461 Mon Sep 17 00:00:00 2001 From: Allan Scott Date: Fri, 29 May 2026 22:07:25 -0400 Subject: [PATCH 2/2] runtime-python bridge: address #13 review nits - dispatch_foreign: surface the foreign runner's stderr in BridgeError messages (both the non-zero-exit and exit-0-but-unusable-output cases). - Document the return-binding precedence: a bound value overwrites the parent field; a return the child did not produce is skipped (soft), leaving the field unchanged; a malformed envelope / bad version is a hard BridgeError. - Test: missing return field leaves the parent field at its default and on_done still drives the machine to final (13 bridge tests; full suite 106 passed). - Docs: add the binding-semantics note to the invoke-returns section. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/cross-tool-invoke-and-returns.md | 8 ++++++++ .../orca_runtime_python/bridge.py | 11 ++++++++--- .../orca_runtime_python/machine.py | 4 ++++ packages/runtime-python/tests/test_bridge.py | 19 +++++++++++++++++++ 4 files changed, 39 insertions(+), 3 deletions(-) diff --git a/docs/cross-tool-invoke-and-returns.md b/docs/cross-tool-invoke-and-returns.md index 51f3469..2db5ae0 100644 --- a/docs/cross-tool-invoke-and-returns.md +++ b/docs/cross-tool-invoke-and-returns.md @@ -115,6 +115,14 @@ Both forms parse to the same `InvokeDef`. AST: `InvokeDef` gains `returns?: Record` and `shots?: number`. +**Binding semantics.** A bound value **overwrites** the parent field named on the +binding's LHS (expected to be a declared parent context key). A return the child +**did not produce** is **skipped** — the binding is *soft*, leaving the parent +field at its current value rather than raising — so a child that, say, omits an +optional aggregate does not abort the parent run. A genuinely wrong shape (a +malformed result envelope, or a `protocol_version` the parent cannot read) is a +*hard* `BridgeError`, distinct from a missing field. + ### 3. Bridge implementation (the q-orca protocol) Implement orca's side of the three JSON envelopes from the q-orca spec: diff --git a/packages/runtime-python/orca_runtime_python/bridge.py b/packages/runtime-python/orca_runtime_python/bridge.py index b91f840..03fd7fb 100644 --- a/packages/runtime-python/orca_runtime_python/bridge.py +++ b/packages/runtime-python/orca_runtime_python/bridge.py @@ -145,9 +145,14 @@ def dispatch_foreign(runner_argv: list[str], invocation: dict, timeout: float = try: return parse_result(proc.stdout) - except BridgeError: + except BridgeError as exc: + stderr = proc.stderr.strip()[:300] if proc.returncode != 0: raise BridgeError( - f"foreign runner exited {proc.returncode}: {proc.stderr.strip()[:200]}" - ) + f"foreign runner exited {proc.returncode}" + + (f": {stderr}" if stderr else "") + ) from exc + # Exit 0 but unusable output — surface stderr to aid debugging. + if stderr: + raise BridgeError(f"{exc} (runner stderr: {stderr})") from exc raise diff --git a/packages/runtime-python/orca_runtime_python/machine.py b/packages/runtime-python/orca_runtime_python/machine.py index e931aae..540f30a 100644 --- a/packages/runtime-python/orca_runtime_python/machine.py +++ b/packages/runtime-python/orca_runtime_python/machine.py @@ -339,6 +339,10 @@ async def _emit_error(error: dict[str, Any]) -> None: await _emit_error(result["error"]) return + # Bind returns into the parent context. Precedence: a bound value + # overwrites the parent field named on the binding's LHS (the field is + # expected to be a declared parent context key). A return the child did + # not produce is skipped, leaving the parent field at its current value. if invoke_def.returns: for parent_field, child_return in invoke_def.returns.items(): if child_return in result["returns"]: diff --git a/packages/runtime-python/tests/test_bridge.py b/packages/runtime-python/tests/test_bridge.py index b7f268f..2d77bd6 100644 --- a/packages/runtime-python/tests/test_bridge.py +++ b/packages/runtime-python/tests/test_bridge.py @@ -166,3 +166,22 @@ async def test_orca_parent_invokes_foreign_quantum_child(): assert machine.context["prob"] == 0.73 assert machine.state.leaf() == "stepped" await machine.stop() + + +# A foreign runner that omits the bound return (empty returns map). +_EMPTY_RETURNS_RUNNER = [ + sys.executable, "-c", + "import sys,json; json.load(sys.stdin); " + "print(json.dumps({'protocol_version': '1.0', 'final_state': 'measured', 'returns': {}}))", +] + + +async def test_missing_return_field_leaves_parent_unchanged(): + # The child does not produce `prob_bits_0`; the parent's `prob` keeps its + # default, and on_done still drives the machine to its final state. + machine = OrcaMachine(definition=parse_orca_md(_TRAINER)) + machine.register_foreign_runner("QForward", _EMPTY_RETURNS_RUNNER) + await machine.start() + assert machine.context["prob"] == 0.0 # unchanged default + assert machine.state.leaf() == "stepped" + await machine.stop()