Skip to content

Commit 3e15b75

Browse files
committed
feat(rust): #259 slice 2 — own-lowered, typed Layer 2 replay/emitter
The typed Rust half of the frozen Layer 2 contract (LOWERED_VERSION 1): a strict data model of the normalized lowered representation plus the canonical emitter, replaying the Python-authored goldens byte-for-byte. No lowering, no OwnIR validation, no MOS, no analysis wiring — this slice proves Rust can carry and emit the exact surface before anything derives it. * crates/own-lowered — a LEAF data crate (own-diagnostics precedent; the DAG lock in own-diagnostics/tests/dag.rs is widened deliberately with the leaf entry and the rationale: the future own-bridge will CONSTRUCT these types, never the reverse). Model mirrors ownlang/lowered.py's frozen normalization decisions field-for-field: declaration order = canonical JSON order; always-written nullable fields are Option WITHOUT skip; the handle-entry allowlist keys are Option WITH skip, `handle` first; the closed Stmt vocabulary under the `stmt` tag; Rejected carries the fail-loud error text. Every shape is deny_unknown_fields — a Python-side surface change cannot slip past the typed replay. * tests/replay.rs — reads manifest.json (typed, strict), requires lowered_version == LOWERED_VERSION, both fixture halves on disk for every case, then for each rust_replay: true case parses the golden and asserts the canonical re-emit is byte-identical (>= 25 shared cases). The rust_replay: false set is asserted to be exactly the OD-2/#294 snapshot — Python-only by ledger decision, not silently skipped. serde_json's pretty printer reproduces Python json.dumps(indent=2, ensure_ascii=False) exactly on this surface — proven by the byte-equality suite, not assumed. cargo fmt/clippy clean, full workspace tests green (incl. the DAG fitness test), Python suite untouched and green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MdYFKUaygHz1T9H1qJ7BqK
1 parent 1a383ed commit 3e15b75

7 files changed

Lines changed: 406 additions & 1 deletion

File tree

‎rust/Cargo.lock‎

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎rust/Cargo.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
[workspace]
1010
resolver = "2"
11-
members = ["crates/own-ir", "crates/own-syntax", "crates/own-cfg", "crates/own-diagnostics", "crates/own-analysis"]
11+
members = ["crates/own-ir", "crates/own-syntax", "crates/own-cfg", "crates/own-diagnostics", "crates/own-analysis", "crates/own-lowered"]
1212

1313
[workspace.package]
1414
edition = "2021"

‎rust/crates/own-diagnostics/tests/dag.rs‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,13 @@ fn allowed_edges() -> HashMap<&'static str, BTreeSet<&'static str>> {
3333
m.insert("own-cfg", ["own-ir", "own-syntax"].into_iter().collect());
3434
// The invariant #214 is about: only the span leaf, never the solver/parser.
3535
m.insert("own-diagnostics", std::iter::once("own-ir").collect());
36+
// The Layer 2 parity surface (#259): a DATA leaf like own-diagnostics —
37+
// the typed model + canonical emitter of the normalized lowered
38+
// representation. It deliberately depends on NO workspace crate: the
39+
// future own-bridge will CONSTRUCT these types (own-bridge → own-lowered),
40+
// never the reverse, and the surface must stay implementable without the
41+
// lowering that fills it.
42+
m.insert("own-lowered", BTreeSet::new());
3643
// own-analysis CONSTRUCTS diagnostics and consumes the cfg lowering. It reads
3744
// the effect type through `own_cfg::Effect`, NOT the parser — so there is no
3845
// production own-syntax edge (own-syntax is a dev-only edge for its tests).

‎rust/crates/own-lowered/Cargo.toml‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
[package]
2+
name = "own-lowered"
3+
description = "Layer 2 normalized-lowered-representation surface: typed model + canonical emitter replaying the Python-authored goldens (tests/fixtures/lowered, LOWERED_VERSION 1) — the data half of the own-bridge parity contract (P-022 #259); no lowering, no MOS, no analysis wiring"
4+
edition.workspace = true
5+
rust-version.workspace = true
6+
license.workspace = true
7+
publish.workspace = true
8+
version = "0.1.0"
9+
10+
# A LEAF data crate, like own-diagnostics: it models the Layer 2 JSON surface
11+
# (spec/Bridge.md §6, ownlang/lowered.py is the authoritative Python twin) and
12+
# re-emits it byte-for-byte. It deliberately depends on NO other own-* crate —
13+
# the future own-bridge will construct these types from real lowering; this
14+
# crate must stay implementable without it (typed replay first, derivation
15+
# later). serde_json is a REGULAR dependency: the canonical emitter is the
16+
# crate's purpose, not a test aid.
17+
[dependencies]
18+
serde = { workspace = true }
19+
serde_json = { workspace = true }
20+
21+
[lints]
22+
workspace = true

‎rust/crates/own-lowered/src/lib.rs‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
//! The Layer 2 parity surface, typed (P-022 #259, spec/Bridge.md §6).
2+
//!
3+
//! `ownlang/lowered.py` is the authoritative Python emitter of the normalized
4+
//! lowered representation; `tests/fixtures/lowered/` holds its frozen
5+
//! facts/golden pairs under `manifest.json`. This crate is the **typed Rust
6+
//! half of that contract**: a strict (`deny_unknown_fields`) data model of the
7+
//! surface plus the canonical emitter that re-serializes it **byte-for-byte**
8+
//! (2-space indent, fixed field order, raw UTF-8, trailing newline).
9+
//!
10+
//! Deliberately NOT here (next slices, gated separately): deriving these
11+
//! documents from `OwnIR` facts (the lowering itself), `OwnIR` validation,
12+
//! MOS inference, and any analysis wiring. A `rust_replay: false` manifest
13+
//! case is a Python-only behavior snapshot pinning an open decision (#294)
14+
//! and is not replayed by this crate's parity suite.
15+
//!
16+
//! Every shape here mirrors the frozen normalization decisions in the Python
17+
//! emitter's docstring; a field added there without a matching change here (or
18+
//! vice-versa) fails the replay suite, and `LOWERED_VERSION` must move in
19+
//! lockstep on both sides.
20+
21+
mod model;
22+
23+
pub use model::{
24+
parse_document, to_canonical_json, Extern, ExternParam, Function, HandleEntry, Lifetime,
25+
LoweredDocument, Manifest, ManifestCase, Param, Rejected, Resource, ResourceMember, Stmt,
26+
Surface, TypeShape, LOWERED_VERSION,
27+
};
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
//! Typed model of the Layer 2 document and its manifest ledger.
2+
//!
3+
//! Field ORDER in every struct is normative: serde serializes declaration
4+
//! order, and the canonical emitter must reproduce `ownlang/lowered.py`'s
5+
//! construction order byte-for-byte. Optional keys exist in exactly two
6+
//! flavours, mirroring the Python emitter: fields Python always writes
7+
//! (possibly `null`) are `Option<T>` WITHOUT skip; the handle-entry allowlist
8+
//! keys Python writes only-when-present are `Option<T>` with
9+
//! `skip_serializing_if`.
10+
11+
use serde::{Deserialize, Serialize};
12+
13+
/// The Layer 2 surface version — must equal `ownlang/lowered.py`'s
14+
/// `LOWERED_VERSION` and `manifest.json`'s `lowered_version`.
15+
pub const LOWERED_VERSION: u32 = 1;
16+
17+
/// One parsed golden: either a full lowered document or a fail-loud rejection.
18+
#[derive(Debug, Clone, PartialEq, Eq)]
19+
pub enum Surface {
20+
/// A lowered Module + handle map.
21+
Lowered(LoweredDocument),
22+
/// An `OwnIRError` rejection whose message text is part of the surface.
23+
Rejected(Rejected),
24+
}
25+
26+
/// `{"lowered_version": ..., "error": ...}` — a vocabulary-skew rejection.
27+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28+
#[serde(deny_unknown_fields)]
29+
pub struct Rejected {
30+
pub lowered_version: u32,
31+
pub error: String,
32+
}
33+
34+
/// The full Layer 2 document (field order is the canonical JSON order).
35+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36+
#[serde(deny_unknown_fields)]
37+
pub struct LoweredDocument {
38+
pub lowered_version: u32,
39+
pub module: String,
40+
pub resources: Vec<Resource>,
41+
pub externs: Vec<Extern>,
42+
pub lifetimes: Vec<Lifetime>,
43+
pub functions: Vec<Function>,
44+
pub handles: Vec<HandleEntry>,
45+
}
46+
47+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48+
#[serde(deny_unknown_fields)]
49+
pub struct Resource {
50+
pub name: String,
51+
/// Always present, possibly `null` (the human `[resource: ...]` tag).
52+
pub kind: Option<String>,
53+
pub members: Vec<ResourceMember>,
54+
}
55+
56+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57+
#[serde(deny_unknown_fields)]
58+
pub struct ResourceMember {
59+
pub role: String,
60+
pub name: String,
61+
}
62+
63+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64+
#[serde(deny_unknown_fields)]
65+
pub struct Extern {
66+
pub name: String,
67+
pub params: Vec<ExternParam>,
68+
}
69+
70+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71+
#[serde(deny_unknown_fields)]
72+
pub struct ExternParam {
73+
pub effect: String,
74+
#[serde(rename = "type")]
75+
pub type_name: String,
76+
}
77+
78+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79+
#[serde(deny_unknown_fields)]
80+
pub struct Lifetime {
81+
pub name: String,
82+
/// The strictly-longer region, or `null` for a root region.
83+
pub longer: Option<String>,
84+
}
85+
86+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87+
#[serde(deny_unknown_fields)]
88+
pub struct Function {
89+
pub name: String,
90+
/// The subscriber region, or `null` when no capture was minted.
91+
pub lifetime: Option<String>,
92+
pub params: Vec<Param>,
93+
/// The synthesized owned return type, or `null` for a void body.
94+
pub ret: Option<TypeShape>,
95+
pub body: Vec<Stmt>,
96+
}
97+
98+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
99+
#[serde(deny_unknown_fields)]
100+
pub struct Param {
101+
pub handle: String,
102+
#[serde(rename = "type")]
103+
pub type_shape: Option<TypeShape>,
104+
pub line: i64,
105+
pub lifetime: Option<String>,
106+
}
107+
108+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109+
#[serde(deny_unknown_fields)]
110+
pub struct TypeShape {
111+
pub name: String,
112+
pub borrowed: bool,
113+
pub mutable: bool,
114+
}
115+
116+
/// The closed statement vocabulary under the `stmt` discriminator. Adding a
117+
/// variant is a Layer 2 contract change (version bump on both sides).
118+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
119+
#[serde(tag = "stmt", rename_all = "snake_case", deny_unknown_fields)]
120+
pub enum Stmt {
121+
Acquire {
122+
handle: String,
123+
resource: String,
124+
line: i64,
125+
},
126+
Release {
127+
handle: String,
128+
line: i64,
129+
},
130+
Use {
131+
handle: String,
132+
line: i64,
133+
},
134+
Overspan {
135+
handle: String,
136+
line: i64,
137+
},
138+
Return {
139+
/// `null` = a bare return (no owned value).
140+
handle: Option<String>,
141+
line: i64,
142+
},
143+
AliasJoin {
144+
handle: String,
145+
src: String,
146+
line: i64,
147+
},
148+
Call {
149+
callee: String,
150+
args: Vec<String>,
151+
line: i64,
152+
},
153+
Subscribe {
154+
source: String,
155+
line: i64,
156+
},
157+
If {
158+
cond: String,
159+
then: Vec<Self>,
160+
#[serde(rename = "else")]
161+
r#else: Vec<Self>,
162+
line: i64,
163+
},
164+
While {
165+
cond: String,
166+
body: Vec<Self>,
167+
line: i64,
168+
},
169+
}
170+
171+
/// One normalized handle-map entry: `handle` first, then the allowlist keys in
172+
/// fixed order, each present only when the underlying record carried it.
173+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
174+
#[serde(deny_unknown_fields)]
175+
pub struct HandleEntry {
176+
pub handle: String,
177+
#[serde(skip_serializing_if = "Option::is_none")]
178+
pub component: Option<String>,
179+
#[serde(skip_serializing_if = "Option::is_none")]
180+
pub file: Option<String>,
181+
#[serde(skip_serializing_if = "Option::is_none")]
182+
pub line: Option<i64>,
183+
#[serde(skip_serializing_if = "Option::is_none")]
184+
pub event: Option<String>,
185+
#[serde(skip_serializing_if = "Option::is_none")]
186+
pub handler: Option<String>,
187+
#[serde(skip_serializing_if = "Option::is_none")]
188+
pub resource: Option<String>,
189+
#[serde(skip_serializing_if = "Option::is_none")]
190+
pub released: Option<bool>,
191+
#[serde(skip_serializing_if = "Option::is_none")]
192+
pub source: Option<String>,
193+
#[serde(skip_serializing_if = "Option::is_none")]
194+
pub source_type: Option<String>,
195+
#[serde(skip_serializing_if = "Option::is_none")]
196+
pub di_source_life: Option<String>,
197+
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
198+
pub type_name: Option<String>,
199+
#[serde(skip_serializing_if = "Option::is_none")]
200+
pub ever_released: Option<bool>,
201+
#[serde(skip_serializing_if = "Option::is_none")]
202+
pub pool: Option<bool>,
203+
}
204+
205+
/// `tests/fixtures/lowered/manifest.json` — the frozen case ledger.
206+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
207+
#[serde(deny_unknown_fields)]
208+
pub struct Manifest {
209+
pub comment: String,
210+
pub lowered_version: u32,
211+
pub cases: Vec<ManifestCase>,
212+
}
213+
214+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
215+
#[serde(deny_unknown_fields)]
216+
pub struct ManifestCase {
217+
pub name: String,
218+
pub rules: Vec<String>,
219+
pub rust_replay: bool,
220+
#[serde(skip_serializing_if = "Option::is_none")]
221+
pub decision: Option<String>,
222+
}
223+
224+
/// Parse one golden document, strictly typed.
225+
///
226+
/// A JSON object carrying `error` is a [`Rejected`], anything else must be a
227+
/// full [`LoweredDocument`] — unknown fields fail in both shapes, so a
228+
/// Python-side surface change cannot slip past the typed replay.
229+
///
230+
/// # Errors
231+
/// Returns the underlying `serde_json` error when the text is not valid JSON
232+
/// or does not match the closed Layer 2 shapes.
233+
pub fn parse_document(text: &str) -> Result<Surface, serde_json::Error> {
234+
let value: serde_json::Value = serde_json::from_str(text)?;
235+
if value.get("error").is_some() {
236+
return serde_json::from_value::<Rejected>(value).map(Surface::Rejected);
237+
}
238+
serde_json::from_value::<LoweredDocument>(value).map(Surface::Lowered)
239+
}
240+
241+
/// The canonical serialized form — byte-identical to the Python emitter's
242+
/// `render_lowered`: 2-space pretty JSON, declaration field order, raw UTF-8,
243+
/// one trailing newline.
244+
///
245+
/// # Errors
246+
/// Returns the underlying `serde_json` error if serialization fails (it
247+
/// cannot for these closed types, but the emitter refuses to panic).
248+
pub fn to_canonical_json(surface: &Surface) -> Result<String, serde_json::Error> {
249+
let mut out = match surface {
250+
Surface::Lowered(doc) => serde_json::to_string_pretty(doc)?,
251+
Surface::Rejected(rej) => serde_json::to_string_pretty(rej)?,
252+
};
253+
out.push('\n');
254+
Ok(out)
255+
}

0 commit comments

Comments
 (0)