Mizan-Rust backend adapter: server-side substrate + three-way parity

Adds first-class Rust-backed Mizan to sit alongside mizan-django and
mizan-fastapi. A Rust dev writes:

    #[derive(Mizan, Serialize, Deserialize)]
    pub struct ProfileOutput { pub user_id: i64, pub name: String }

    #[mizan::context("user")]
    pub struct UserCtx;

    #[mizan::client(context = UserCtx)]
    pub async fn user_profile(_req: &RequestHandle<'_>, user_id: i64) -> ProfileOutput { ... }

…and gets byte-identical KDL to the Python emitters, served over the
same wire protocol the React / Rust / Vue / Svelte kernels speak.

New crates:
- cores/mizan-rust/         (Cargo: mizan-core)     — IR types, KDL emitter, traits, registry,
                                                       runtime (compute_invalidation / compute_merges
                                                       ported from mizan-fastapi), graph_check with
                                                       structural type-matching
- cores/mizan-rust-macros/  (Cargo: mizan-macros)   — #[derive(Mizan)], #[mizan::context],
                                                       #[mizan::client] proc macros
- backends/mizan-rust-axum/ (Cargo: mizan-axum)     — axum HTTP adapter: /session/, /call/, /ctx/:name/
- tests/afi/rust_app/                                — AFI fixture port + server / export-ir binaries

Substrate-shape moves required by cross-language equivalence:
- IR canonicalization: functions / contexts / context-members / shared-by
  now sort alphabetically in both Python and Rust emitters. The IR is a
  contract; linkme doesn't preserve declaration order, so canonical sort
  is the only stable mapping. afi_ir.kdl + per-target baselines regenerated.
- MizanType::TYPE_NAME is a const (with a default type_name() reader) so
  it's usable in linkme TypeEntry static initializers.
- Tree-shaken type registry: #[derive(Mizan)] only emits the trait impl;
  the #[mizan::client] macro registers canonical-named entries from
  fn signatures, including Vec<T> element types for ref resolution.
- Merge resolution is structural (NamedType shape comparison) rather than
  by name — matches the Python types_match_for_merge semantics.

Three-way forcing functions:
- tests/afi/test_codegen_parity.py — Django ≡ FastAPI ≡ Rust on KDL bytes (3 pass)
- tests/rust/run_wire_parity.py    — 12/12 probes against FastAPI + Rust (EXIT=0)

Incidental fixes surfaced by the new tests:
- Stale `from .registry import validate_registry` import removed from
  mizan-django/setup/discovery.py (referenced a function that no longer
  exists; was masking codegen-parity).
- BASE_DIR added to tests/afi/django_app/project/settings.py.
- /session/ endpoint added to mizan-fastapi for protocol-shaped readiness
  probe parity (wire-parity harness now polls /api/mizan/session/ on both
  backends rather than FastAPI's /openapi.json).
- Root .gitignore picks up Rust target/ across the tree so new crates
  don't need per-crate gitignore.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-17 22:31:26 -04:00
parent 9900f8a36f
commit 45bde51166
47 changed files with 4187 additions and 147 deletions

View File

@@ -1,13 +1,20 @@
"""Drive the wire-parity check end-to-end.
"""Three-way wire-parity check.
1. Boot the FastAPI fixture app via uvicorn on a free port.
2. Poll /openapi.json until the server is up.
3. Run the Rust `drive_kernel` binary (raw kernel calls) against it.
4. Run the Rust `drive_emitted` binary (typed codegen functions) against
the same server.
5. Tear the server down.
For each backend (FastAPI, Rust axum):
1. Boot the fixture server on a free port.
2. Poll /api/mizan/session/ until the server responds.
3. Run the Rust `drive_kernel` binary (raw kernel calls) against it.
4. Run the Rust `drive_emitted` binary (typed codegen functions) against it.
5. Tear the server down.
Either non-zero driver exit propagates as the script's exit code.
Any non-zero driver exit propagates as the script's exit code. Adding the
Rust backend here proves that mizan-axum honors the same wire contract as
mizan-fastapi — same JSON shapes, same invalidate/merge semantics — beyond
the static IR equivalence the codegen-parity test gates.
Readiness probe is `/api/mizan/session/` (Mizan-protocol-shaped) rather
than `/openapi.json` (FastAPI-feature-shaped) so the harness reads the
same surface across backends.
"""
from __future__ import annotations
@@ -25,6 +32,7 @@ from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
AFI_DIR = REPO_ROOT / "tests" / "afi"
RUST_DIR = REPO_ROOT / "tests" / "rust"
RUST_APP_DIR = AFI_DIR / "rust_app"
BOOT_TIMEOUT_S = 15.0
POLL_INTERVAL_S = 0.25
@@ -35,24 +43,22 @@ def pick_free_port() -> int:
return s.getsockname()[1]
def wait_for_server(port: int, timeout_s: float) -> bool:
def wait_for_server(port: int, timeout_s: float, label: str) -> bool:
deadline = time.monotonic() + timeout_s
url = f"http://127.0.0.1:{port}/openapi.json"
url = f"http://127.0.0.1:{port}/api/mizan/session/"
while time.monotonic() < deadline:
try:
with urllib.request.urlopen(url, timeout=1.0) as resp:
if resp.status == 200:
return True
except (urllib.error.URLError, ConnectionError, OSError) as e:
# Surface the kind of failure so a stuck boot doesn't read
# as "silently waiting"; the loop continues until timeout.
sys.stderr.write(f"[wire_parity] waiting for server: {type(e).__name__}\n")
sys.stderr.write(f"[wire_parity:{label}] waiting: {type(e).__name__}\n")
time.sleep(POLL_INTERVAL_S)
return False
def run_driver(name: str, base_url: str) -> int:
sys.stdout.write(f"\n=== {name} ===\n")
def run_driver(name: str, base_url: str, label: str) -> int:
sys.stdout.write(f"\n=== {label} :: {name} ===\n")
sys.stdout.flush()
return subprocess.run(
["cargo", "run", "--quiet", "--bin", name, "--", base_url],
@@ -60,36 +66,47 @@ def run_driver(name: str, base_url: str) -> int:
).returncode
def main() -> int:
port = pick_free_port()
base_url = f"http://127.0.0.1:{port}/api/mizan"
server = subprocess.Popen(
["uv", "run", "uvicorn", "fastapi_app:make_app",
"--factory", "--port", str(port), "--log-level", "warning"],
def boot_fastapi(port: int) -> subprocess.Popen:
return subprocess.Popen(
[
"uv", "run", "uvicorn", "fastapi_app:make_app",
"--factory", "--port", str(port), "--log-level", "warning",
],
cwd=AFI_DIR,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
)
def boot_rust(port: int) -> subprocess.Popen:
return subprocess.Popen(
[
"cargo", "run", "--quiet", "--release",
"--manifest-path", str(RUST_APP_DIR / "Cargo.toml"),
"--bin", "server",
],
env={**os.environ, "PORT": str(port)},
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
)
def run_against(label: str, server: subprocess.Popen, port: int) -> int:
failures = 0
try:
if not wait_for_server(port, BOOT_TIMEOUT_S):
sys.stderr.write(
f"[wire_parity] server failed to start within {BOOT_TIMEOUT_S}s\n",
)
stderr_tail = server.stderr.read(4096) if server.stderr else b""
if stderr_tail:
sys.stderr.write(stderr_tail.decode("utf-8", errors="replace"))
if not wait_for_server(port, BOOT_TIMEOUT_S, label):
sys.stderr.write(f"[wire_parity:{label}] server boot timed out after {BOOT_TIMEOUT_S}s\n")
if server.stderr:
tail = server.stderr.read(4096)
if tail:
sys.stderr.write(tail.decode("utf-8", errors="replace"))
return 1
failures = 0
base_url = f"http://127.0.0.1:{port}/api/mizan"
for driver in ("drive_kernel", "drive_emitted"):
rc = run_driver(driver, base_url)
rc = run_driver(driver, base_url, label)
if rc != 0:
sys.stderr.write(f"[wire_parity] {driver} exited {rc}\n")
sys.stderr.write(f"[wire_parity:{label}] {driver} exited {rc}\n")
failures += 1
return 0 if failures == 0 else 1
finally:
server.terminate()
try:
@@ -97,6 +114,21 @@ def main() -> int:
except subprocess.TimeoutExpired:
server.kill()
server.wait()
return failures
def main() -> int:
total_failures = 0
fastapi_port = pick_free_port()
sys.stdout.write(f"[wire_parity] booting fastapi on port {fastapi_port}\n")
total_failures += run_against("fastapi", boot_fastapi(fastapi_port), fastapi_port)
rust_port = pick_free_port()
sys.stdout.write(f"[wire_parity] booting rust on port {rust_port}\n")
total_failures += run_against("rust", boot_rust(rust_port), rust_port)
return 0 if total_failures == 0 else 1
if __name__ == "__main__":