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

@@ -0,0 +1,92 @@
//! Surface traits the proc macros implement.
use crate::ir::{AffectTarget, NamedType, Transport};
use crate::runtime::{MizanError, RequestHandle};
use serde_json::Value;
use std::future::Future;
use std::pin::Pin;
/// A type that participates in the Mizan IR. Generated by `#[derive(Mizan)]`.
///
/// `TYPE_NAME` is a `const` (not a function) so it's usable in `static`
/// initializers — TypeEntry's `name` field reads it directly without an
/// init-time function call.
pub trait MizanType {
const TYPE_NAME: &'static str;
fn shape() -> NamedType;
fn type_name() -> &'static str {
Self::TYPE_NAME
}
}
/// A marker type for a Mizan context. Generated by `#[mizan::context]`.
pub trait ContextMarker {
const NAME: &'static str;
}
/// One Mizan-registered function. Generated by `#[mizan(...)]` on async fns.
///
/// Everything here is plain data except `dispatch`, which is the type-erased
/// runtime entry point used by the HTTP adapter.
pub trait FunctionSpec: Send + Sync {
fn name(&self) -> &'static str;
fn camel_name(&self) -> &'static str;
fn has_input(&self) -> bool;
fn input_type(&self) -> Option<&'static str>;
fn output_type(&self) -> &'static str;
fn output_nullable(&self) -> bool {
false
}
fn context(&self) -> Option<&'static str> {
None
}
fn affects(&self) -> &'static [AffectTarget] {
&[]
}
fn merge(&self) -> &'static [&'static str] {
&[]
}
fn transport(&self) -> Transport {
Transport::Http
}
fn private(&self) -> bool {
false
}
fn is_form(&self) -> bool {
false
}
fn form_name(&self) -> Option<&'static str> {
None
}
fn form_role(&self) -> Option<&'static str> {
None
}
/// Field-shape description of this function's Input parameters, used by
/// the context builder to compute shared-param elevation. Empty when
/// `has_input()` is false.
fn input_params(&self) -> &'static [InputParam] {
&[]
}
/// Type-erased dispatch. The HTTP adapter calls this with deserialized
/// JSON arguments; the macro-generated impl deserializes into the
/// function's typed input, awaits the body, and serializes the result.
fn dispatch<'a>(
&'a self,
req: RequestHandle<'a>,
args: Value,
) -> Pin<Box<dyn Future<Output = Result<Value, MizanError>> + Send + 'a>>;
}
/// One parameter of a function's synthesized Input. The macro emits a static
/// slice of these so the context builder can find shared params across
/// context members and produce the `context { param ... shared-by ... }`
/// section of the IR.
#[derive(Debug, Clone, Copy)]
pub struct InputParam {
pub name: &'static str,
pub primitive: crate::ir::Primitive,
pub required: bool,
}