From d5f3b6933c07b47f0897e55c50026143ecf98e3d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 08:37:20 +0000 Subject: [PATCH 1/5] =?UTF-8?q?feat(parity):=20MOS=20summaries=20dump=20ha?= =?UTF-8?q?rness=20=E2=80=94=20Python=E2=86=94Rust=20byte-exact=20(roadmap?= =?UTF-8?q?=20stage=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The summaries dump becomes a two-sided parity artifact, the summary-level diff the interprocedural roadmap names as stage 1's second role (and a P-022 cutover prerequisite for the inference layer): Python (authoritative): - tests/test_summaries_fixtures.py — new fixture family in the lowered-family style. Goldens pin the EXACT `python -m ownlang summaries` stdout bytes (json.dumps indent=2 sort_keys, default ensure_ascii, trailing newline). Two case sources, one golden tree: the frozen Layer 2 facts corpus swept automatically (minus the audited `excluded_lowered` ledger — an exclusion must still FAIL load() or the build goes red demanding promotion), plus 9 synthetic MOS cases (SCC cycles, return-forward chains/cycles, the extern-boundary log, overload-merge tie-breaks, sig-key vocabulary, the degraded branch, sink channels, explicit effects, non-ASCII escaping). INF-R1 is enforced per case: every dump re-runs with functions[] reversed. Rust: - own-bridge/src/mos.rs — the dump surface is now carried in full: param name/disposable, method file/line/source, and solve_with_log with the sorted unresolved-boundary log (extern param + return edges); solve() stays as the lowering-path wrapper. lookup gains the reference's non-disposable short-circuit; SCC members filter on disposable like the reference. - own-bridge/src/lower.rs — build_skeletons/merge_skeletons carry the INF-R1 tie-breaks: lexicographic-min merged param name, min (file, line) merged location. - own-bridge/src/dump.rs (new) — dump_summaries renders the document byte-identically to the CLI, with a Python-json.dumps-compatible emitter (sorted keys at every level, 2-space indent, \uXXXX ensure_ascii escaping with surrogate pairs — serde_json cannot produce that shape); a solver failure degrades to the reference's "ValueError: …" text (INF-F6). - own-bridge/tests/summaries.rs (new) — the harness: ledger/tree equality in every direction, byte-exact replay of all 35 goldens, determinism re-run, ≥35 case floor, the degraded branch pinned. tolerant_unknown_kind is the only exclusion (load()-door rejection, #294 OD-2 door placement — its rejection text stays pinned by the lowered family). Docs: spec/Inference.md §8 points at the harness; the roadmap's stage-1 parity bullet is marked shipped; stale "#294 stays open" note in own-bridge lib.rs corrected. Verified: python tests/run_tests.py (all suites incl. the new family: 35 cases, 1 degraded), ruff, mypy, cargo fmt --check, clippy (no new warnings), cargo test (29 suites / 116 tests green). Red-checked: a mutated golden fails the Rust harness with the byte-diff message. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015de4MezSeUnZBoWq1fFU5M --- docs/notes/interprocedural-roadmap.md | 8 +- rust/Cargo.lock | 1 + rust/crates/own-bridge/Cargo.toml | 4 + rust/crates/own-bridge/src/dump.rs | 192 ++++++++++++++ rust/crates/own-bridge/src/lib.rs | 30 ++- rust/crates/own-bridge/src/lower.rs | 43 ++- rust/crates/own-bridge/src/mos.rs | 108 ++++++-- rust/crates/own-bridge/tests/summaries.rs | 201 ++++++++++++++ spec/Inference.md | 8 + .../summaries/alias_join_cases.summaries.json | 28 ++ .../flow_kill_on_rebind.summaries.json | 18 ++ .../flow_unmapped_refs.summaries.json | 18 ++ .../fn_params_ordering.summaries.json | 60 +++++ .../handles_global_counters.summaries.json | 7 + .../handles_null_metadata.summaries.json | 7 + .../hoist_neg_early_return.summaries.json | 18 ++ .../hoist_neg_nested_depth.summaries.json | 18 ++ .../hoist_neg_while_body.summaries.json | 18 ++ .../summaries/hoist_pool_kind.summaries.json | 18 ++ .../hoist_positive_release.summaries.json | 18 ++ .../hoist_positive_use_only.summaries.json | 18 ++ .../summaries/lines_preserved.summaries.json | 18 ++ tests/fixtures/summaries/manifest.json | 47 ++++ ...s_call_channel_overload_sig.summaries.json | 69 +++++ .../mos_call_direct_consume.summaries.json | 35 +++ .../mos_call_unknown_drop.summaries.json | 18 ++ .../summaries/mos_fresh_mint.summaries.json | 28 ++ .../mos_killsite_toplevel.summaries.json | 35 +++ .../mos_untrack_inbranch.summaries.json | 35 +++ .../mosdump_degraded_duplicate_key.facts.json | 47 ++++ ...dump_degraded_duplicate_key.summaries.json | 7 + .../mosdump_explicit_effects.facts.json | 60 +++++ .../mosdump_explicit_effects.summaries.json | 76 ++++++ .../mosdump_nonascii_escaping.facts.json | 27 ++ .../mosdump_nonascii_escaping.summaries.json | 31 +++ ...osdump_overload_merge_tiebreaks.facts.json | 61 +++++ ...mp_overload_merge_tiebreaks.summaries.json | 31 +++ .../mosdump_partial_and_guarded.facts.json | 78 ++++++ ...mosdump_partial_and_guarded.summaries.json | 59 +++++ .../mosdump_return_chains.facts.json | 112 ++++++++ .../mosdump_return_chains.summaries.json | 70 +++++ .../summaries/mosdump_scc_cycles.facts.json | 101 +++++++ .../mosdump_scc_cycles.summaries.json | 78 ++++++ .../mosdump_sig_vocabulary.facts.json | 71 +++++ .../mosdump_sig_vocabulary.summaries.json | 86 ++++++ .../mosdump_sink_channels.facts.json | 66 +++++ .../mosdump_sink_channels.summaries.json | 61 +++++ .../routing_r1_unresolved.summaries.json | 7 + .../routing_r2_subscribe_self.summaries.json | 7 + .../routing_r3_capture_static.summaries.json | 7 + .../routing_r4_returned_fresh.summaries.json | 7 + .../routing_r5_di_capture.summaries.json | 7 + .../routing_r6_token_kinds.summaries.json | 7 + .../summaries/vocab_unknown_op.summaries.json | 18 ++ tests/test_summaries_fixtures.py | 249 ++++++++++++++++++ 55 files changed, 2524 insertions(+), 33 deletions(-) create mode 100644 rust/crates/own-bridge/src/dump.rs create mode 100644 rust/crates/own-bridge/tests/summaries.rs create mode 100644 tests/fixtures/summaries/alias_join_cases.summaries.json create mode 100644 tests/fixtures/summaries/flow_kill_on_rebind.summaries.json create mode 100644 tests/fixtures/summaries/flow_unmapped_refs.summaries.json create mode 100644 tests/fixtures/summaries/fn_params_ordering.summaries.json create mode 100644 tests/fixtures/summaries/handles_global_counters.summaries.json create mode 100644 tests/fixtures/summaries/handles_null_metadata.summaries.json create mode 100644 tests/fixtures/summaries/hoist_neg_early_return.summaries.json create mode 100644 tests/fixtures/summaries/hoist_neg_nested_depth.summaries.json create mode 100644 tests/fixtures/summaries/hoist_neg_while_body.summaries.json create mode 100644 tests/fixtures/summaries/hoist_pool_kind.summaries.json create mode 100644 tests/fixtures/summaries/hoist_positive_release.summaries.json create mode 100644 tests/fixtures/summaries/hoist_positive_use_only.summaries.json create mode 100644 tests/fixtures/summaries/lines_preserved.summaries.json create mode 100644 tests/fixtures/summaries/manifest.json create mode 100644 tests/fixtures/summaries/mos_call_channel_overload_sig.summaries.json create mode 100644 tests/fixtures/summaries/mos_call_direct_consume.summaries.json create mode 100644 tests/fixtures/summaries/mos_call_unknown_drop.summaries.json create mode 100644 tests/fixtures/summaries/mos_fresh_mint.summaries.json create mode 100644 tests/fixtures/summaries/mos_killsite_toplevel.summaries.json create mode 100644 tests/fixtures/summaries/mos_untrack_inbranch.summaries.json create mode 100644 tests/fixtures/summaries/mosdump_degraded_duplicate_key.facts.json create mode 100644 tests/fixtures/summaries/mosdump_degraded_duplicate_key.summaries.json create mode 100644 tests/fixtures/summaries/mosdump_explicit_effects.facts.json create mode 100644 tests/fixtures/summaries/mosdump_explicit_effects.summaries.json create mode 100644 tests/fixtures/summaries/mosdump_nonascii_escaping.facts.json create mode 100644 tests/fixtures/summaries/mosdump_nonascii_escaping.summaries.json create mode 100644 tests/fixtures/summaries/mosdump_overload_merge_tiebreaks.facts.json create mode 100644 tests/fixtures/summaries/mosdump_overload_merge_tiebreaks.summaries.json create mode 100644 tests/fixtures/summaries/mosdump_partial_and_guarded.facts.json create mode 100644 tests/fixtures/summaries/mosdump_partial_and_guarded.summaries.json create mode 100644 tests/fixtures/summaries/mosdump_return_chains.facts.json create mode 100644 tests/fixtures/summaries/mosdump_return_chains.summaries.json create mode 100644 tests/fixtures/summaries/mosdump_scc_cycles.facts.json create mode 100644 tests/fixtures/summaries/mosdump_scc_cycles.summaries.json create mode 100644 tests/fixtures/summaries/mosdump_sig_vocabulary.facts.json create mode 100644 tests/fixtures/summaries/mosdump_sig_vocabulary.summaries.json create mode 100644 tests/fixtures/summaries/mosdump_sink_channels.facts.json create mode 100644 tests/fixtures/summaries/mosdump_sink_channels.summaries.json create mode 100644 tests/fixtures/summaries/routing_r1_unresolved.summaries.json create mode 100644 tests/fixtures/summaries/routing_r2_subscribe_self.summaries.json create mode 100644 tests/fixtures/summaries/routing_r3_capture_static.summaries.json create mode 100644 tests/fixtures/summaries/routing_r4_returned_fresh.summaries.json create mode 100644 tests/fixtures/summaries/routing_r5_di_capture.summaries.json create mode 100644 tests/fixtures/summaries/routing_r6_token_kinds.summaries.json create mode 100644 tests/fixtures/summaries/vocab_unknown_op.summaries.json create mode 100644 tests/test_summaries_fixtures.py diff --git a/docs/notes/interprocedural-roadmap.md b/docs/notes/interprocedural-roadmap.md index 754b7e0a..e4f3c90c 100644 --- a/docs/notes/interprocedural-roadmap.md +++ b/docs/notes/interprocedural-roadmap.md @@ -163,7 +163,13 @@ the full must/may/escape evidence regardless of mode»), не хватает п **Критерий готовности:** на любом прогоне можно ответить «почему этот вызов не consume/borrow/fresh» без чтения кода моста. С началом Rust-порта у этапа появляется вторая роль (§2): нормализованный дамп сводок — паритетный артефакт, -по которому диффуются Python- и Rust-стороны инференции. +по которому диффуются Python- и Rust-стороны инференции. ✅ *(shipped)*: +харнесс `tests/test_summaries_fixtures.py` (Python пишет голдены — точные +байты stdout CLI) + `rust/crates/own-bridge/tests/summaries.rs` +(`own_bridge::dump_summaries` воспроизводит каждый голден байт-в-байт: +замороженный Layer-2 корпус фактов + синтетические MOS-кейсы — SCC-циклы, +цепочки/циклы forward-return, extern-лог, тай-брейки слияния перегрузок, +sig-ключи, `degraded`, non-ASCII экранирование). ## 5. Этап 2 — сигнатурный ключ: закрыть арность (1–2 недели) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index af76ed87..43428259 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -30,6 +30,7 @@ version = "0.1.0" dependencies = [ "own-ir", "own-lowered", + "serde", "serde_json", ] diff --git a/rust/crates/own-bridge/Cargo.toml b/rust/crates/own-bridge/Cargo.toml index 0406ed1a..66684a1d 100644 --- a/rust/crates/own-bridge/Cargo.toml +++ b/rust/crates/own-bridge/Cargo.toml @@ -16,5 +16,9 @@ own-ir = { path = "../own-ir" } own-lowered = { path = "../own-lowered" } serde_json.workspace = true +[dev-dependencies] +# The summaries-family manifest is parsed typed+strict in tests/summaries.rs. +serde.workspace = true + [lints] workspace = true diff --git a/rust/crates/own-bridge/src/dump.rs b/rust/crates/own-bridge/src/dump.rs new file mode 100644 index 00000000..8d78f8f8 --- /dev/null +++ b/rust/crates/own-bridge/src/dump.rs @@ -0,0 +1,192 @@ +//! The MOS parity dump — `ownlang/ownir.py::dump_summaries`, serialized as +//! the `python -m ownlang summaries` CLI prints it (spec/Inference.md §8). +//! +//! The output contract is BYTE-EXACT stdout parity with the reference: +//! `json.dumps(doc, indent=2, sort_keys=True)` plus the newline `print` +//! appends. That serialization keeps Python's `ensure_ascii` default (unlike +//! the Layer 2 surface, which pins `ensure_ascii=False`), so this module +//! carries its own emitter: sorted keys at every level, 2-space indent, and +//! `\uXXXX` escapes (surrogate pairs above the BMP) for everything outside +//! printable ASCII — `serde_json` cannot produce that shape. +//! +//! Determinism is the point (INF-R1): summaries sorted by method key, the +//! unresolved log sorted, fixed field vocabulary — the same facts yield +//! byte-identical output regardless of `functions[]` input order, which is +//! what makes the dump a parity artifact rather than a debug log. A failed +//! solve degrades exactly like the reference: empty `summaries`/`unresolved` +//! with the reason under `degraded` (INF-F6), never a crash. + +// `redundant_pub_crate` (nursery) conflicts with the workspace's DENY of +// `unreachable_pub` for items in private modules; pub(crate) is the honest +// visibility here (same stance as `mos.rs`). +#![allow(clippy::redundant_pub_crate)] + +use crate::lower::{as_list, build_skeletons, py_str}; +use crate::mos; +use crate::BridgeError; +use own_ir::{OwnIr, OWNIR_VERSION}; +use serde_json::{json, Value}; + +/// Render the summaries document for one `OwnIR` facts document — +/// byte-identical to `python -m ownlang summaries` on the same facts. +/// +/// # Errors +/// [`BridgeError`] only if the typed facts cannot be re-serialized to JSON +/// (not reachable for a document `OwnIr::from_json` accepted); a SOLVER +/// failure is not an error here — it is the `degraded` branch of the +/// document, same as the reference. +pub(crate) fn dump_summaries(facts: &OwnIr) -> Result { + let root = facts.to_value().map_err(|e| BridgeError(e.to_string()))?; + let root = root.as_object().cloned().unwrap_or_default(); + // Python: `str(facts.get("module", "?"))` / `facts.get("functions", [])` + // (a present non-list reads as empty). + let module = root.get("module").map_or_else(|| "?".to_owned(), py_str); + let raw_fns = as_list(root.get("functions")); + + let mut summaries: Vec = Vec::new(); + let mut unresolved: Vec = Vec::new(); + let mut degraded = Value::Null; + match mos::solve_with_log(build_skeletons(raw_fns)) { + Ok((mos, log)) => { + unresolved = log; + // `sorted(summaries)` — the map key IS `MethodSummary.key` in the + // reference, so sorting keys sorts the dump. + let mut keys: Vec<&String> = mos.keys().collect(); + keys.sort(); + for key in keys { + let Some(s) = mos.get(key) else { + continue; // unreachable: `keys` was collected from `mos` + }; + let params: Vec = s + .params + .iter() + .map(|p| { + json!({ + "index": p.index, + "name": p.name, + "disposable": p.disposable, + "transfer": p.transfer.as_str(), + }) + }) + .collect(); + // `escapes` is deliberately NOT serialized (INF-R2): no + // producer sets the axis, so emitting it would freeze an + // always-`false` lie into the parity artifact. + summaries.push(json!({ + "method": key, + "file": s.file, + "line": s.line, + "source": s.source, + "params": params, + "returns": {"owned": s.returns}, + })); + } + } + Err(e) => { + // Python: `except Exception as exc: f"{type(exc).__name__}: {exc}"`. + // The only failure a facts document can reach is the duplicate-key + // guard, a `ValueError` in the reference — mirror its type name. + degraded = Value::String(format!("ValueError: {e}")); + } + } + + let doc = json!({ + "module": module, + "ownir_version": OWNIR_VERSION, + "summaries": summaries, + "unresolved": unresolved, + "degraded": degraded, + }); + let mut out = String::new(); + emit(&doc, 0, &mut out); + out.push('\n'); // the newline `print` appends + Ok(out) +} + +/// One string, escaped exactly as Python's `json.dumps` default +/// (`ensure_ascii=True`) escapes it: printable ASCII (0x20–0x7E) literal +/// except `"` and `\`, the five short escapes, `\uXXXX` (lowercase hex) for +/// everything else, surrogate PAIRS for code points above the BMP. +fn escape_py(s: &str, out: &mut String) { + use std::fmt::Write as _; + out.push('"'); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + '\u{08}' => out.push_str("\\b"), + '\u{0c}' => out.push_str("\\f"), + ' '..='~' => out.push(c), + _ => { + let mut units = [0_u16; 2]; + for unit in c.encode_utf16(&mut units) { + // Writing into a String cannot fail; the lint-honest + // form still avoids unwrap. + let _ = write!(out, "\\u{unit:04x}"); + } + } + } + } + out.push('"'); +} + +/// `json.dumps(value, indent=2, sort_keys=True)`: 2-space indent, `": "` / +/// `","` separators, keys sorted at every level, empty containers inline. +fn emit(v: &Value, indent: usize, out: &mut String) { + match v { + Value::Null => out.push_str("null"), + Value::Bool(true) => out.push_str("true"), + Value::Bool(false) => out.push_str("false"), + // The document carries only i64s (lines, the version); a non-integer + // number cannot be constructed by `dump_summaries`. + Value::Number(n) => out.push_str(&n.to_string()), + Value::String(s) => escape_py(s, out), + Value::Array(items) => { + if items.is_empty() { + out.push_str("[]"); + return; + } + out.push('['); + for (i, item) in items.iter().enumerate() { + out.push_str(if i == 0 { "\n" } else { ",\n" }); + pad(indent.saturating_add(2), out); + emit(item, indent.saturating_add(2), out); + } + out.push('\n'); + pad(indent, out); + out.push(']'); + } + Value::Object(map) => { + if map.is_empty() { + out.push_str("{}"); + return; + } + let mut keys: Vec<&String> = map.keys().collect(); + keys.sort(); + out.push('{'); + for (i, key) in keys.iter().enumerate() { + out.push_str(if i == 0 { "\n" } else { ",\n" }); + pad(indent.saturating_add(2), out); + escape_py(key, out); + out.push_str(": "); + emit( + map.get(key.as_str()).unwrap_or(&Value::Null), + indent.saturating_add(2), + out, + ); + } + out.push('\n'); + pad(indent, out); + out.push('}'); + } + } +} + +fn pad(n: usize, out: &mut String) { + for _ in 0..n { + out.push(' '); + } +} diff --git a/rust/crates/own-bridge/src/lib.rs b/rust/crates/own-bridge/src/lib.rs index 36a60a5b..d359d7e3 100644 --- a/rust/crates/own-bridge/src/lib.rs +++ b/rust/crates/own-bridge/src/lib.rs @@ -12,16 +12,22 @@ //! **Pure transformation**: [`lower`] maps a typed [`own_ir::OwnIr`] document //! to an [`own_lowered::LoweredDocument`] (or a [`BridgeError`] whose message //! text is part of the parity surface — Python projects it as the `Rejected` -//! form). No filesystem, no CLI, no diagnostics, no analysis. The tolerant -//! door, `OwnIR` validation parity, MOS contract *changes*, and analysis -//! wiring are all out of scope (#294 stays open; the `tolerant_unknown_kind` -//! fixture stays Python-only). +//! form), and [`dump_summaries`] renders the MOS summaries document +//! byte-identically to `python -m ownlang summaries` (the inference layer's +//! parity artifact, spec/Inference.md §8). No filesystem, no CLI, no +//! diagnostics, no analysis. `OwnIR` validation parity, MOS contract +//! *changes*, and analysis wiring stay out of scope (#294 OD-2 landed: +//! IR4-everywhere — `tolerant_unknown_kind` is a shared `rust_replay` case +//! whose `Rejected` golden pins the fail-loud text on both sides). //! //! The oracle is byte-exact: for every `rust_replay: true` manifest case, //! `facts → OwnIr::from_json → lower → own_lowered::to_canonical_json` must -//! equal the committed Python golden (`tests/replay.rs`). The goldens are +//! equal the committed Python golden (`tests/replay.rs`), and every +//! summaries-family case must reproduce its `*.summaries.json` golden +//! through [`dump_summaries`] (`tests/summaries.rs`). The goldens are //! expected output ONLY — never an input to construction. +mod dump; mod lower; mod mos; @@ -50,3 +56,17 @@ impl std::error::Error for BridgeError {} pub fn lower(facts: &OwnIr) -> Result { lower::lower(facts) } + +/// Render the MOS summaries document for one `OwnIR` facts document. +/// +/// Byte-identical to `python -m ownlang summaries` on the same facts +/// (`json.dumps(doc, indent=2, sort_keys=True)` + the trailing newline). +/// A solver failure is not an error: it is the document's `degraded` branch, +/// exactly like the reference (INF-F6). +/// +/// # Errors +/// [`BridgeError`] only if the typed facts cannot be re-serialized to JSON +/// (not reachable for a document [`OwnIr::from_json`] accepted). +pub fn dump_summaries(facts: &OwnIr) -> Result { + dump::dump_summaries(facts) +} diff --git a/rust/crates/own-bridge/src/lower.rs b/rust/crates/own-bridge/src/lower.rs index de919365..ea76904a 100644 --- a/rust/crates/own-bridge/src/lower.rs +++ b/rust/crates/own-bridge/src/lower.rs @@ -56,7 +56,7 @@ fn py_truthy(v: Option<&Value>) -> bool { /// Python `str(v)` over the JSON values the facts carry. Containers are not /// reproduced (Python would repr them); no fixture nor real extractor puts a /// container where a scalar is read. -fn py_str(v: &Value) -> String { +pub(crate) fn py_str(v: &Value) -> String { match v { Value::String(s) => s.clone(), Value::Null => "None".to_owned(), @@ -84,7 +84,7 @@ fn as_int(v: Option<&Value>) -> i64 { } /// `n.get(key)` where a present non-list / absent key reads as empty. -fn as_list(v: Option<&Value>) -> &[Value] { +pub(crate) fn as_list(v: Option<&Value>) -> &[Value] { v.and_then(Value::as_array).map_or(&[], Vec::as_slice) } @@ -631,7 +631,10 @@ fn merge_returns(rets: &[&ReturnSkeleton]) -> ReturnSkeleton { } /// `_merge_skeletons`: collapse same-key overloads into ONE conservative -/// summary at (key, parameter-index) granularity. +/// summary at (key, parameter-index) granularity. The dump-only fields must +/// not depend on `functions[]` input order (INF-R1): the merged param NAME is +/// the lexicographic min across overloads carrying that index, the merged +/// location the smallest `(file, line)` pair. fn merge_skeletons(key: &str, group: &[MethodSkeleton]) -> MethodSkeleton { if let [single] = group { let mut sk = single.clone(); @@ -639,6 +642,7 @@ fn merge_skeletons(key: &str, group: &[MethodSkeleton]) -> MethodSkeleton { return sk; } let mut by_index: BTreeMap> = BTreeMap::new(); + let mut names: BTreeMap = BTreeMap::new(); for sk in group { for p in &sk.params { let paths = if p.paths.is_empty() { @@ -647,17 +651,37 @@ fn merge_skeletons(key: &str, group: &[MethodSkeleton]) -> MethodSkeleton { p.paths.clone() }; by_index.entry(p.index).or_default().extend(paths); + names + .entry(p.index) + .and_modify(|n| { + if p.name < *n { + p.name.clone_into(n); + } + }) + .or_insert_with(|| p.name.clone()); } } let params = by_index .into_iter() - .map(|(index, paths)| ParamSkeleton { index, paths }) + .map(|(index, paths)| ParamSkeleton { + index, + name: names.get(&index).cloned().unwrap_or_default(), + disposable: true, + paths, + }) .collect(); let rets: Vec<&ReturnSkeleton> = group.iter().map(|s| &s.ret).collect(); + let (file, line) = group + .iter() + .map(|sk| (sk.file.clone(), sk.line)) + .min() + .expect("merge group is non-empty"); MethodSkeleton { key: key.to_owned(), params, ret: merge_returns(&rets), + file, + line, } } @@ -682,8 +706,9 @@ fn forward_path_action( } /// `_build_skeletons`: one merged skeleton per bare name plus one per emitted -/// `name(sig)` overload group. -fn build_skeletons(raw_fns: &[Value]) -> Vec { +/// `name(sig)` overload group. `pub(crate)`: the MOS parity dump +/// (`dump.rs`) derives the same skeletons from the same raw `functions[]`. +pub(crate) fn build_skeletons(raw_fns: &[Value]) -> Vec { let mut counts: HashMap = HashMap::new(); for f in raw_fns.iter().filter_map(Value::as_object) { let name = str_or(f, "name", ""); @@ -786,6 +811,8 @@ fn build_skeletons(raw_fns: &[Value]) -> Vec { }; params.push(ParamSkeleton { index: i64::try_from(i).unwrap_or(i64::MAX), + name: cname, + disposable: true, paths, }); } @@ -795,10 +822,14 @@ fn build_skeletons(raw_fns: &[Value]) -> Vec { .map(|p| str_or(p, "name", "")) .collect(); let ret = infer_return_skeleton(body, &pnames, &first_party, &call_key); + // carry the declaration file so the summary dump is navigable + // (`functions[]` records carry no line of their own; line stays 0). let sk = MethodSkeleton { key: key.clone(), params, ret, + file: str_or(f, "file", "?"), + line: 0, }; if counts.get(&key).copied().unwrap_or(0) > 1 { if let Some(fsig) = call_sig(f) { diff --git a/rust/crates/own-bridge/src/mos.rs b/rust/crates/own-bridge/src/mos.rs index 52b43237..f1c05139 100644 --- a/rust/crates/own-bridge/src/mos.rs +++ b/rust/crates/own-bridge/src/mos.rs @@ -5,15 +5,16 @@ //! least fixpoint over the call graph's SCC condensation, and each method's //! owned-return kind by a memoized, cycle-safe chase along forward edges. //! -//! Deliberately NOT carried from the reference: the `adopt`/`return` path -//! kinds and the `aliasOf`/`aliased` return kinds (reserved in Python — the -//! production skeleton builder never emits them; they would contribute -//! `Transfer::Must` / terminal strings exactly like Python's), the `escapes` -//! axis (no producer sets it), and the unresolved-edge log (`solve_with_log`) -//! — none of them can influence a lowered document today. The summary also -//! drops the dump-only fields (`name`, `file`, `line`, `source`): the merged -//! name/location tie-breaks in `_merge_skeletons` affect only the detached -//! summaries artifact, never the lowering. +//! Since the MOS parity harness (`dump_summaries` / `tests/summaries.rs`), +//! the dump surface is carried in full: the summary fields the detached +//! `summaries[]` artifact serializes (`name`, `disposable`, `file`, `line`, +//! `source` — with the `_merge_skeletons` name/location tie-breaks in +//! `lower.rs`) and the unresolved-boundary log ([`solve_with_log`], the +//! `unresolved` key). Still deliberately NOT carried: the `adopt`/`return` +//! path kinds and the `aliasOf`/`aliased` return kinds (reserved in Python — +//! the production skeleton builder never emits them; they would contribute +//! `Transfer::Must` / terminal strings exactly like Python's) and the +//! `escapes` axis (no producer sets it, and INF-R2 keeps it out of the dump). // Solver internals index maps by invariant-backed keys (every key read was // inserted by the same pass); expect()/indexing over those invariants is the @@ -55,6 +56,18 @@ pub(crate) const fn join(a: Transfer, b: Transfer) -> Transfer { } } +impl Transfer { + /// The `Transfer` `StrEnum` value the dump serializes. + pub(crate) const fn as_str(self) -> &'static str { + match self { + Self::No => "no", + Self::Must => "must", + Self::May => "may", + Self::Unknown => "unknown", + } + } +} + /// One thing a method body does with a parameter on one normal-return path. /// The production builder emits exactly these three kinds. #[derive(Debug, Clone)] @@ -86,6 +99,12 @@ pub(crate) enum ReturnSkeleton { pub(crate) struct ParamSkeleton { /// The logical parameter index calls resolve by (never tuple offset). pub index: i64, + /// The C# parameter name (dump-only; `_merge_skeletons` takes the + /// lexicographic min across overloads so the dump is order-independent). + pub name: String, + /// The production builder always sets `true`; carried because the + /// solver's `lookup` short-circuits a non-disposable to `no`. + pub disposable: bool, /// Empty = nothing happens to it -> kept (`no`). pub paths: Vec, } @@ -96,11 +115,18 @@ pub(crate) struct MethodSkeleton { pub key: String, pub params: Vec, pub ret: ReturnSkeleton, + /// Declaration file (dump-only; merged overloads take the min + /// `(file, line)` pair). `functions[]` records carry no line of their + /// own, so `line` stays 0 from the production builder. + pub file: String, + pub line: i64, } #[derive(Debug, Clone)] pub(crate) struct ParamSummary { pub index: i64, + pub name: String, + pub disposable: bool, pub transfer: Transfer, } @@ -110,6 +136,11 @@ pub(crate) struct MethodSummary { /// `"fresh" | "none" | "unknown"` — the terminal forms the production /// skeletons can reach. Only `== "fresh"` is read by the lowering. pub returns: String, + pub file: String, + pub line: i64, + /// `inferred | bcl | annotation | heuristic` in the reference; only the + /// default `inferred` tier has a producer today. + pub source: &'static str, } /// The lowering consumes summaries through this map (`mos` in Python). @@ -209,10 +240,13 @@ fn sccs(adj: &BTreeMap>) -> Vec> { type ParamKey = (String, i64); -/// Resolve every method's MOS. A duplicate skeleton key is an error the -/// caller degrades on (Python: `ValueError` → the bridge drops to an empty -/// MOS rather than corrupting the call graph). -pub(crate) fn solve(skeletons: Vec) -> Result { +/// Resolve every method's MOS, plus the unresolved-boundary log: every +/// forward — param or return — that crossed an extern (unsummarized) +/// boundary, sorted (Python `solve_with_log`; the dump's `unresolved` key). +/// A duplicate skeleton key is an error the caller degrades on (Python: +/// `ValueError` → the bridge drops to an empty MOS / the dump goes +/// `degraded` rather than corrupting the call graph). +pub(crate) fn solve_with_log(skeletons: Vec) -> Result<(Mos, Vec), String> { let mut sk: BTreeMap = BTreeMap::new(); for s in skeletons { if sk.contains_key(&s.key) { @@ -221,6 +255,9 @@ pub(crate) fn solve(skeletons: Vec) -> Result { sk.insert(s.key.clone(), s); } + // `set` + end-of-solve sort in Python; a BTreeSet iterates the same order + // (str `<` compares code points, which is exactly UTF-8 byte order). + let mut unresolved: BTreeSet = BTreeSet::new(); let mut param_val: HashMap = HashMap::new(); // --- param transfers: bottom-up, per-SCC least fixpoint on the lattice -- @@ -229,7 +266,9 @@ pub(crate) fn solve(skeletons: Vec) -> Result { let mut members: Vec = Vec::new(); for k in &comp { for p in &sk[k].params { - members.push((k.clone(), p.index)); + if p.disposable { + members.push((k.clone(), p.index)); + } } } if members.is_empty() { @@ -242,13 +281,18 @@ pub(crate) fn solve(skeletons: Vec) -> Result { let lookup = |callee: &str, arg: i64, param_val: &HashMap, - cur: &HashMap>| + cur: &HashMap>, + unresolved: &mut BTreeSet| -> Option { let Some(skel) = sk.get(callee) else { + unresolved.insert(format!("{callee}#{arg} (extern, no summary)")); return Some(Transfer::Unknown); // extern, no summary }; - if !skel.params.iter().any(|q| q.index == arg) { + let Some(p) = skel.params.iter().find(|q| q.index == arg) else { return Some(Transfer::Unknown); // no such logical param + }; + if !p.disposable { + return Some(Transfer::No); } let keyp = (callee.to_owned(), arg); if let Some(v) = param_val.get(&keyp) { @@ -278,7 +322,7 @@ pub(crate) fn solve(skeletons: Vec) -> Result { PathAction::Dispose => Some(Transfer::Must), PathAction::Borrow => Some(Transfer::No), PathAction::Forward { callee, arg } => { - lookup(callee, *arg, ¶m_val, &cur) + lookup(callee, *arg, ¶m_val, &cur, &mut unresolved) } }; acc = match (acc, contrib) { @@ -304,7 +348,10 @@ pub(crate) fn solve(skeletons: Vec) -> Result { // --- returns: iterative, memoized, cycle-safe chase along forward edges -- let mut ret_val: HashMap = HashMap::new(); - let resolve_return = |start: &str, ret_val: &mut HashMap| -> String { + let resolve_return = |start: &str, + ret_val: &mut HashMap, + unresolved: &mut BTreeSet| + -> String { if let Some(v) = ret_val.get(start) { return v.clone(); } @@ -335,6 +382,7 @@ pub(crate) fn solve(skeletons: Vec) -> Result { } ReturnSkeleton::Forward { callee } => { if !sk.contains_key(callee) { + unresolved.insert(format!("return {callee} (extern, no summary)")); val = "unknown".to_owned(); // extern, no summary ret_val.insert(key.clone(), val.clone()); break; @@ -367,14 +415,32 @@ pub(crate) fn solve(skeletons: Vec) -> Result { .iter() .map(|p| ParamSummary { index: p.index, + name: p.name.clone(), + disposable: p.disposable, transfer: param_val .get(&(key.clone(), p.index)) .copied() .unwrap_or(Transfer::No), }) .collect(); - let returns = resolve_return(&key, &mut ret_val); - out.insert(key.clone(), MethodSummary { params, returns }); + let returns = resolve_return(&key, &mut ret_val, &mut unresolved); + let skel = &sk[&key]; + out.insert( + key.clone(), + MethodSummary { + params, + returns, + file: skel.file.clone(), + line: skel.line, + source: "inferred", + }, + ); } - Ok(out) + Ok((out, unresolved.into_iter().collect())) +} + +/// Convenience wrapper around [`solve_with_log`] dropping the unresolved log +/// (the lowering path — the log is a dump-only surface). +pub(crate) fn solve(skeletons: Vec) -> Result { + solve_with_log(skeletons).map(|(mos, _)| mos) } diff --git a/rust/crates/own-bridge/tests/summaries.rs b/rust/crates/own-bridge/tests/summaries.rs new file mode 100644 index 00000000..663f84d1 --- /dev/null +++ b/rust/crates/own-bridge/tests/summaries.rs @@ -0,0 +1,201 @@ +//! The MOS parity harness (P-022, roadmap stage 1): for every case of the +//! summaries fixture family, +//! +//! ```text +//! facts.json → own_ir::OwnIr::from_json → own_bridge::dump_summaries +//! == .summaries.json (byte-exact) +//! ``` +//! +//! The goldens are the EXACT stdout bytes of `python -m ownlang summaries` +//! (`tests/test_summaries_fixtures.py --write` regenerates them), so this +//! suite diffs the Rust inference port against the reference at the SUMMARY +//! level — finer than diffing final diagnostics, where an inference bug can +//! hide behind an unrelated silence (spec/Inference.md §8, INF-R1/R2). +//! +//! Two case sources, independently re-derived here (not outsourced to +//! Python): the frozen Layer 2 facts corpus (`tests/fixtures/lowered/`, +//! swept automatically minus the manifest's audited `excluded_lowered` +//! ledger) and the synthetic MOS cases beside the manifest. Ledger/tree +//! equality is enforced in every direction — a golden cannot be missing, +//! stale, orphaned, or silently skipped; an exclusion must name a real +//! lowered case. `tolerant_unknown_kind` is the ONLY exclusion today: +//! Python's `load()` door rejects its unknown resource kind before the dump +//! runs (exit 2 — no document to pin), while the Rust twin of that rejection +//! lives in `lower()` and is pinned by the lowered family's `Rejected` +//! golden (#294 OD-2 door placement). + +#![allow(clippy::panic, clippy::expect_used)] + +use serde::Deserialize; +use std::collections::BTreeSet; + +const FIXDIR: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../tests/fixtures/summaries" +); +const LOWDIR: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../tests/fixtures/lowered" +); + +/// The summaries-family ledger (strict: an unknown field is a contract +/// change this suite must be taught, not skip). +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Manifest { + ownir_version: i64, + excluded_lowered: Vec, + cases: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Exclusion { + name: String, + reason: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Case { + name: String, + rules: Vec, +} + +fn read(dir: &str, name: &str) -> String { + let path = format!("{dir}/{name}"); + std::fs::read_to_string(&path).unwrap_or_else(|e| { + panic!( + "cannot read {path}: {e} — regenerate: python tests/test_summaries_fixtures.py --write" + ) + }) +} + +fn stems(dir: &str, suffix: &str) -> BTreeSet { + let mut out = BTreeSet::new(); + for entry in std::fs::read_dir(dir).expect("fixture directory is readable") { + let file = entry.expect("directory entry").file_name(); + let file = file.to_str().expect("fixture filenames are UTF-8"); + if let Some(stem) = file.strip_suffix(suffix) { + out.insert(stem.to_owned()); + } + } + out +} + +/// facts text → the summaries document bytes, through the Rust pipeline only. +fn dump_bytes(facts_text: &str, case: &str) -> String { + let facts = own_ir::OwnIr::from_json(facts_text) + .unwrap_or_else(|e| panic!("{case}: own-ir rejected the shared facts: {e}")); + own_bridge::dump_summaries(&facts) + .unwrap_or_else(|e| panic!("{case}: dump_summaries failed: {e}")) +} + +#[test] +fn dumps_every_case_to_its_golden() { + let manifest: Manifest = serde_json::from_str(&read(FIXDIR, "manifest.json")) + .expect("manifest.json parses (typed, strict)"); + assert_eq!( + manifest.ownir_version, + own_ir::OWNIR_VERSION, + "manifest ownir_version must match OWNIR_VERSION" + ); + + // --- ledger/tree equality, independently of Python ----------------------- + let lowered_facts = stems(LOWDIR, ".facts.json"); + let mut excluded = BTreeSet::new(); + for e in &manifest.excluded_lowered { + assert!( + !e.reason.is_empty(), + "exclusion '{}' must carry its audit reason", + e.name + ); + assert!( + lowered_facts.contains(&e.name), + "excluded_lowered names '{}', which has no facts file in the \ + lowered corpus", + e.name + ); + assert!( + excluded.insert(e.name.clone()), + "excluded_lowered lists '{}' twice", + e.name + ); + } + assert_eq!( + excluded, + BTreeSet::from(["tolerant_unknown_kind".to_owned()]), + "the exclusion ledger changed — a new Python-only door divergence is \ + a deliberate contract decision, not a drive-by" + ); + + let mut synthetic = BTreeSet::new(); + for c in &manifest.cases { + assert!( + !c.rules.is_empty(), + "case '{}' must name the INF rules it pins", + c.name + ); + assert!( + synthetic.insert(c.name.clone()), + "duplicate manifest case name: {}", + c.name + ); + assert!( + !lowered_facts.contains(&c.name), + "synthetic case '{}' shadows a lowered corpus case name", + c.name + ); + } + assert_eq!( + synthetic, + stems(FIXDIR, ".facts.json"), + "manifest case names != *.facts.json under fixtures/summaries" + ); + + let mut planned: BTreeSet = lowered_facts.difference(&excluded).cloned().collect(); + planned.extend(synthetic.iter().cloned()); + assert_eq!( + planned, + stems(FIXDIR, ".summaries.json"), + "planned cases != *.summaries.json goldens on disk (missing or \ + orphaned golden)" + ); + + // --- the byte-exact replay ---------------------------------------------- + let mut dumped = 0_u32; + let mut degraded = 0_u32; + for case in &planned { + let dir = if synthetic.contains(case) { + FIXDIR + } else { + LOWDIR + }; + let facts_text = read(dir, &format!("{case}.facts.json")); + let golden = read(FIXDIR, &format!("{case}.summaries.json")); + let emitted = dump_bytes(&facts_text, case); + assert!( + emitted == golden, + "{case}: Rust dump is not byte-identical to the Python golden.\n\ + --- emitted ---\n{emitted}\n--- golden ---\n{golden}", + ); + // determinism: the same facts must dump byte-identically on re-run. + assert_eq!( + dump_bytes(&facts_text, case), + emitted, + "{case}: dump is not deterministic" + ); + if emitted.contains("\"degraded\": \"") { + degraded = degraded.checked_add(1).expect("count fits u32"); + } + dumped = dumped.checked_add(1).expect("case count fits u32"); + } + assert!( + dumped >= 35, + "expected at least 35 summaries cases dumped from facts, got {dumped}" + ); + assert!( + degraded >= 1, + "the degraded branch (INF-F6) must stay pinned by at least one case" + ); +} diff --git a/spec/Inference.md b/spec/Inference.md index b43b4628..def01fa6 100644 --- a/spec/Inference.md +++ b/spec/Inference.md @@ -297,6 +297,14 @@ reason on solve failure. serialized — emitting an always-`False` field would freeze a lie into the parity surface. Serialize it the day a producer lands, together with the producer. +The parity harness holds both sides to this dump byte-for-byte: the goldens +under `tests/fixtures/summaries/` pin the exact CLI stdout for the frozen +Layer 2 facts corpus plus MOS-specific synthetic cases +(`tests/test_summaries_fixtures.py`, Python-authoritative `--write`), and the +Rust port reproduces every golden through `own_bridge::dump_summaries` +(`rust/crates/own-bridge/tests/summaries.rs`) — the summary-level +Python↔Rust diff the roadmap's stage 1 names. + ## 9. Scope / non-goals First-party (Tier A) + curated BCL (Tier B) + the per-call-site channel only. **No diff --git a/tests/fixtures/summaries/alias_join_cases.summaries.json b/tests/fixtures/summaries/alias_join_cases.summaries.json new file mode 100644 index 00000000..eda3733e --- /dev/null +++ b/tests/fixtures/summaries/alias_join_cases.summaries.json @@ -0,0 +1,28 @@ +{ + "degraded": null, + "module": "Alias", + "ownir_version": 0, + "summaries": [ + { + "file": "F.cs", + "line": 0, + "method": "NoClaim", + "params": [], + "returns": { + "owned": "none" + }, + "source": "inferred" + }, + { + "file": "F.cs", + "line": 0, + "method": "Wrap", + "params": [], + "returns": { + "owned": "none" + }, + "source": "inferred" + } + ], + "unresolved": [] +} diff --git a/tests/fixtures/summaries/flow_kill_on_rebind.summaries.json b/tests/fixtures/summaries/flow_kill_on_rebind.summaries.json new file mode 100644 index 00000000..da03ea93 --- /dev/null +++ b/tests/fixtures/summaries/flow_kill_on_rebind.summaries.json @@ -0,0 +1,18 @@ +{ + "degraded": null, + "module": "Rebind", + "ownir_version": 0, + "summaries": [ + { + "file": "F.cs", + "line": 0, + "method": "M", + "params": [], + "returns": { + "owned": "none" + }, + "source": "inferred" + } + ], + "unresolved": [] +} diff --git a/tests/fixtures/summaries/flow_unmapped_refs.summaries.json b/tests/fixtures/summaries/flow_unmapped_refs.summaries.json new file mode 100644 index 00000000..9f741fa7 --- /dev/null +++ b/tests/fixtures/summaries/flow_unmapped_refs.summaries.json @@ -0,0 +1,18 @@ +{ + "degraded": null, + "module": "Unmapped", + "ownir_version": 0, + "summaries": [ + { + "file": "F.cs", + "line": 0, + "method": "M", + "params": [], + "returns": { + "owned": "none" + }, + "source": "inferred" + } + ], + "unresolved": [] +} diff --git a/tests/fixtures/summaries/fn_params_ordering.summaries.json b/tests/fixtures/summaries/fn_params_ordering.summaries.json new file mode 100644 index 00000000..7aae7b6d --- /dev/null +++ b/tests/fixtures/summaries/fn_params_ordering.summaries.json @@ -0,0 +1,60 @@ +{ + "degraded": null, + "module": "Params", + "ownir_version": 0, + "summaries": [ + { + "file": "F.cs", + "line": 0, + "method": "Second", + "params": [ + { + "disposable": true, + "index": 0, + "name": "p", + "transfer": "no" + } + ], + "returns": { + "owned": "none" + }, + "source": "inferred" + }, + { + "file": "F.cs", + "line": 0, + "method": "Sink", + "params": [ + { + "disposable": true, + "index": 0, + "name": "a", + "transfer": "must" + }, + { + "disposable": true, + "index": 1, + "name": "b", + "transfer": "no" + }, + { + "disposable": true, + "index": 2, + "name": "c", + "transfer": "no" + }, + { + "disposable": true, + "index": 3, + "name": "d", + "transfer": "no" + } + ], + "returns": { + "owned": "none" + }, + "source": "inferred" + } + ], + "unresolved": [] +} diff --git a/tests/fixtures/summaries/handles_global_counters.summaries.json b/tests/fixtures/summaries/handles_global_counters.summaries.json new file mode 100644 index 00000000..2b544d52 --- /dev/null +++ b/tests/fixtures/summaries/handles_global_counters.summaries.json @@ -0,0 +1,7 @@ +{ + "degraded": null, + "module": "Counters", + "ownir_version": 0, + "summaries": [], + "unresolved": [] +} diff --git a/tests/fixtures/summaries/handles_null_metadata.summaries.json b/tests/fixtures/summaries/handles_null_metadata.summaries.json new file mode 100644 index 00000000..87650e56 --- /dev/null +++ b/tests/fixtures/summaries/handles_null_metadata.summaries.json @@ -0,0 +1,7 @@ +{ + "degraded": null, + "module": "NullMeta", + "ownir_version": 0, + "summaries": [], + "unresolved": [] +} diff --git a/tests/fixtures/summaries/hoist_neg_early_return.summaries.json b/tests/fixtures/summaries/hoist_neg_early_return.summaries.json new file mode 100644 index 00000000..0317f80e --- /dev/null +++ b/tests/fixtures/summaries/hoist_neg_early_return.summaries.json @@ -0,0 +1,18 @@ +{ + "degraded": null, + "module": "HoistNeg3", + "ownir_version": 0, + "summaries": [ + { + "file": "F.cs", + "line": 0, + "method": "M", + "params": [], + "returns": { + "owned": "none" + }, + "source": "inferred" + } + ], + "unresolved": [] +} diff --git a/tests/fixtures/summaries/hoist_neg_nested_depth.summaries.json b/tests/fixtures/summaries/hoist_neg_nested_depth.summaries.json new file mode 100644 index 00000000..451932a3 --- /dev/null +++ b/tests/fixtures/summaries/hoist_neg_nested_depth.summaries.json @@ -0,0 +1,18 @@ +{ + "degraded": null, + "module": "HoistNeg1", + "ownir_version": 0, + "summaries": [ + { + "file": "F.cs", + "line": 0, + "method": "M", + "params": [], + "returns": { + "owned": "none" + }, + "source": "inferred" + } + ], + "unresolved": [] +} diff --git a/tests/fixtures/summaries/hoist_neg_while_body.summaries.json b/tests/fixtures/summaries/hoist_neg_while_body.summaries.json new file mode 100644 index 00000000..4c91de72 --- /dev/null +++ b/tests/fixtures/summaries/hoist_neg_while_body.summaries.json @@ -0,0 +1,18 @@ +{ + "degraded": null, + "module": "HoistNeg2", + "ownir_version": 0, + "summaries": [ + { + "file": "F.cs", + "line": 0, + "method": "M", + "params": [], + "returns": { + "owned": "none" + }, + "source": "inferred" + } + ], + "unresolved": [] +} diff --git a/tests/fixtures/summaries/hoist_pool_kind.summaries.json b/tests/fixtures/summaries/hoist_pool_kind.summaries.json new file mode 100644 index 00000000..20699714 --- /dev/null +++ b/tests/fixtures/summaries/hoist_pool_kind.summaries.json @@ -0,0 +1,18 @@ +{ + "degraded": null, + "module": "HoistPool", + "ownir_version": 0, + "summaries": [ + { + "file": "F.cs", + "line": 0, + "method": "M", + "params": [], + "returns": { + "owned": "none" + }, + "source": "inferred" + } + ], + "unresolved": [] +} diff --git a/tests/fixtures/summaries/hoist_positive_release.summaries.json b/tests/fixtures/summaries/hoist_positive_release.summaries.json new file mode 100644 index 00000000..afb77def --- /dev/null +++ b/tests/fixtures/summaries/hoist_positive_release.summaries.json @@ -0,0 +1,18 @@ +{ + "degraded": null, + "module": "Hoist", + "ownir_version": 0, + "summaries": [ + { + "file": "F.cs", + "line": 0, + "method": "M", + "params": [], + "returns": { + "owned": "none" + }, + "source": "inferred" + } + ], + "unresolved": [] +} diff --git a/tests/fixtures/summaries/hoist_positive_use_only.summaries.json b/tests/fixtures/summaries/hoist_positive_use_only.summaries.json new file mode 100644 index 00000000..0a6f8c13 --- /dev/null +++ b/tests/fixtures/summaries/hoist_positive_use_only.summaries.json @@ -0,0 +1,18 @@ +{ + "degraded": null, + "module": "HoistUse", + "ownir_version": 0, + "summaries": [ + { + "file": "F.cs", + "line": 0, + "method": "M", + "params": [], + "returns": { + "owned": "none" + }, + "source": "inferred" + } + ], + "unresolved": [] +} diff --git a/tests/fixtures/summaries/lines_preserved.summaries.json b/tests/fixtures/summaries/lines_preserved.summaries.json new file mode 100644 index 00000000..43141d2a --- /dev/null +++ b/tests/fixtures/summaries/lines_preserved.summaries.json @@ -0,0 +1,18 @@ +{ + "degraded": null, + "module": "Lines", + "ownir_version": 0, + "summaries": [ + { + "file": "F.cs", + "line": 0, + "method": "M", + "params": [], + "returns": { + "owned": "none" + }, + "source": "inferred" + } + ], + "unresolved": [] +} diff --git a/tests/fixtures/summaries/manifest.json b/tests/fixtures/summaries/manifest.json new file mode 100644 index 00000000..b501515a --- /dev/null +++ b/tests/fixtures/summaries/manifest.json @@ -0,0 +1,47 @@ +{ + "ownir_version": 0, + "excluded_lowered": [ + { + "name": "tolerant_unknown_kind", + "reason": "load() rejects the facts (unknown resource kind 'mutex') before dump_summaries can run — `python -m ownlang summaries` exits 2, so there is no document to pin. The rejection text itself is pinned byte-for-byte by the lowered family's Rejected golden (#294 OD-2: Python fails at the load door, Rust at the lower door)." + } + ], + "cases": [ + { + "name": "mosdump_scc_cycles", + "rules": ["INF-F1", "INF-F2", "INF-F3", "INF-F7", "INF-L2", "INF-S4"] + }, + { + "name": "mosdump_return_chains", + "rules": ["INF-R3", "INF-R4", "INF-F5", "INF-F7"] + }, + { + "name": "mosdump_overload_merge_tiebreaks", + "rules": ["INF-M1", "INF-M2", "INF-M3", "INF-R1"] + }, + { + "name": "mosdump_sig_vocabulary", + "rules": ["INF-M1", "INF-F1", "INF-R1"] + }, + { + "name": "mosdump_degraded_duplicate_key", + "rules": ["INF-F4", "INF-F6"] + }, + { + "name": "mosdump_nonascii_escaping", + "rules": ["INF-R1"] + }, + { + "name": "mosdump_sink_channels", + "rules": ["INF-S6", "INF-F7"] + }, + { + "name": "mosdump_explicit_effects", + "rules": ["INF-S1"] + }, + { + "name": "mosdump_partial_and_guarded", + "rules": ["INF-S2", "INF-S3", "INF-L1"] + } + ] +} diff --git a/tests/fixtures/summaries/mos_call_channel_overload_sig.summaries.json b/tests/fixtures/summaries/mos_call_channel_overload_sig.summaries.json new file mode 100644 index 00000000..33999609 --- /dev/null +++ b/tests/fixtures/summaries/mos_call_channel_overload_sig.summaries.json @@ -0,0 +1,69 @@ +{ + "degraded": null, + "module": "Overloads", + "ownir_version": 0, + "summaries": [ + { + "file": "F.cs", + "line": 0, + "method": "M", + "params": [], + "returns": { + "owned": "none" + }, + "source": "inferred" + }, + { + "file": "F.cs", + "line": 0, + "method": "Take", + "params": [ + { + "disposable": true, + "index": 0, + "name": "p", + "transfer": "may" + } + ], + "returns": { + "owned": "none" + }, + "source": "inferred" + }, + { + "file": "F.cs", + "line": 0, + "method": "Take(System.IO.Stream)", + "params": [ + { + "disposable": true, + "index": 0, + "name": "p", + "transfer": "must" + } + ], + "returns": { + "owned": "none" + }, + "source": "inferred" + }, + { + "file": "F.cs", + "line": 0, + "method": "Take(System.String)", + "params": [ + { + "disposable": true, + "index": 0, + "name": "p", + "transfer": "no" + } + ], + "returns": { + "owned": "none" + }, + "source": "inferred" + } + ], + "unresolved": [] +} diff --git a/tests/fixtures/summaries/mos_call_direct_consume.summaries.json b/tests/fixtures/summaries/mos_call_direct_consume.summaries.json new file mode 100644 index 00000000..70ccc5e7 --- /dev/null +++ b/tests/fixtures/summaries/mos_call_direct_consume.summaries.json @@ -0,0 +1,35 @@ +{ + "degraded": null, + "module": "Direct", + "ownir_version": 0, + "summaries": [ + { + "file": "F.cs", + "line": 0, + "method": "M", + "params": [], + "returns": { + "owned": "none" + }, + "source": "inferred" + }, + { + "file": "F.cs", + "line": 0, + "method": "Sink", + "params": [ + { + "disposable": true, + "index": 0, + "name": "p", + "transfer": "must" + } + ], + "returns": { + "owned": "none" + }, + "source": "inferred" + } + ], + "unresolved": [] +} diff --git a/tests/fixtures/summaries/mos_call_unknown_drop.summaries.json b/tests/fixtures/summaries/mos_call_unknown_drop.summaries.json new file mode 100644 index 00000000..7641318e --- /dev/null +++ b/tests/fixtures/summaries/mos_call_unknown_drop.summaries.json @@ -0,0 +1,18 @@ +{ + "degraded": null, + "module": "Drop", + "ownir_version": 0, + "summaries": [ + { + "file": "F.cs", + "line": 0, + "method": "M", + "params": [], + "returns": { + "owned": "none" + }, + "source": "inferred" + } + ], + "unresolved": [] +} diff --git a/tests/fixtures/summaries/mos_fresh_mint.summaries.json b/tests/fixtures/summaries/mos_fresh_mint.summaries.json new file mode 100644 index 00000000..901bce6e --- /dev/null +++ b/tests/fixtures/summaries/mos_fresh_mint.summaries.json @@ -0,0 +1,28 @@ +{ + "degraded": null, + "module": "Fresh", + "ownir_version": 0, + "summaries": [ + { + "file": "F.cs", + "line": 0, + "method": "M", + "params": [], + "returns": { + "owned": "none" + }, + "source": "inferred" + }, + { + "file": "F.cs", + "line": 0, + "method": "Make", + "params": [], + "returns": { + "owned": "fresh" + }, + "source": "inferred" + } + ], + "unresolved": [] +} diff --git a/tests/fixtures/summaries/mos_killsite_toplevel.summaries.json b/tests/fixtures/summaries/mos_killsite_toplevel.summaries.json new file mode 100644 index 00000000..8fb5b39c --- /dev/null +++ b/tests/fixtures/summaries/mos_killsite_toplevel.summaries.json @@ -0,0 +1,35 @@ +{ + "degraded": null, + "module": "KillSite", + "ownir_version": 0, + "summaries": [ + { + "file": "F.cs", + "line": 0, + "method": "M", + "params": [], + "returns": { + "owned": "none" + }, + "source": "inferred" + }, + { + "file": "F.cs", + "line": 0, + "method": "MayTake", + "params": [ + { + "disposable": true, + "index": 0, + "name": "p", + "transfer": "may" + } + ], + "returns": { + "owned": "none" + }, + "source": "inferred" + } + ], + "unresolved": [] +} diff --git a/tests/fixtures/summaries/mos_untrack_inbranch.summaries.json b/tests/fixtures/summaries/mos_untrack_inbranch.summaries.json new file mode 100644 index 00000000..b48ee3f9 --- /dev/null +++ b/tests/fixtures/summaries/mos_untrack_inbranch.summaries.json @@ -0,0 +1,35 @@ +{ + "degraded": null, + "module": "Untrack", + "ownir_version": 0, + "summaries": [ + { + "file": "F.cs", + "line": 0, + "method": "M", + "params": [], + "returns": { + "owned": "none" + }, + "source": "inferred" + }, + { + "file": "F.cs", + "line": 0, + "method": "MayTake", + "params": [ + { + "disposable": true, + "index": 0, + "name": "p", + "transfer": "may" + } + ], + "returns": { + "owned": "none" + }, + "source": "inferred" + } + ], + "unresolved": [] +} diff --git a/tests/fixtures/summaries/mosdump_degraded_duplicate_key.facts.json b/tests/fixtures/summaries/mosdump_degraded_duplicate_key.facts.json new file mode 100644 index 00000000..341995c0 --- /dev/null +++ b/tests/fixtures/summaries/mosdump_degraded_duplicate_key.facts.json @@ -0,0 +1,47 @@ +{ + "ownir_version": 0, + "module": "Degraded", + "functions": [ + { + "name": "Take", + "sig": "System.IO.Stream", + "file": "t1.cs", + "params": [ + { + "name": "p", + "line": 1 + } + ], + "body": [ + { + "op": "release", + "var": "p", + "line": 2 + } + ] + }, + { + "name": "Take", + "sig": "System.String", + "file": "t2.cs", + "params": [ + { + "name": "p", + "line": 5 + } + ], + "body": [ + { + "op": "use", + "var": "p", + "line": 6 + } + ] + }, + { + "name": "Take(System.IO.Stream)", + "file": "clash.cs", + "body": [] + } + ] +} diff --git a/tests/fixtures/summaries/mosdump_degraded_duplicate_key.summaries.json b/tests/fixtures/summaries/mosdump_degraded_duplicate_key.summaries.json new file mode 100644 index 00000000..9e783bcc --- /dev/null +++ b/tests/fixtures/summaries/mosdump_degraded_duplicate_key.summaries.json @@ -0,0 +1,7 @@ +{ + "degraded": "ValueError: duplicate MethodSkeleton key: Take(System.IO.Stream)", + "module": "Degraded", + "ownir_version": 0, + "summaries": [], + "unresolved": [] +} diff --git a/tests/fixtures/summaries/mosdump_explicit_effects.facts.json b/tests/fixtures/summaries/mosdump_explicit_effects.facts.json new file mode 100644 index 00000000..e0801598 --- /dev/null +++ b/tests/fixtures/summaries/mosdump_explicit_effects.facts.json @@ -0,0 +1,60 @@ +{ + "ownir_version": 0, + "module": "Effects", + "functions": [ + { + "name": "Ef.C", + "file": "ef.cs", + "params": [ + { + "name": "a", + "line": 1, + "effect": "consume" + } + ], + "body": [] + }, + { + "name": "Ef.B", + "file": "ef.cs", + "params": [ + { + "name": "b", + "line": 4, + "effect": "borrow" + } + ], + "body": [] + }, + { + "name": "Ef.M", + "file": "ef.cs", + "params": [ + { + "name": "c", + "line": 7, + "effect": "borrow_mut" + } + ], + "body": [] + }, + { + "name": "Ef.P", + "file": "ef.cs", + "params": [ + { + "name": "d", + "line": 10, + "effect": "plain" + } + ], + "body": [ + { + "op": "release", + "var": "d", + "line": 11 + } + ] + } + ] +} diff --git a/tests/fixtures/summaries/mosdump_explicit_effects.summaries.json b/tests/fixtures/summaries/mosdump_explicit_effects.summaries.json new file mode 100644 index 00000000..159a7fe9 --- /dev/null +++ b/tests/fixtures/summaries/mosdump_explicit_effects.summaries.json @@ -0,0 +1,76 @@ +{ + "degraded": null, + "module": "Effects", + "ownir_version": 0, + "summaries": [ + { + "file": "ef.cs", + "line": 0, + "method": "Ef.B", + "params": [ + { + "disposable": true, + "index": 0, + "name": "b", + "transfer": "no" + } + ], + "returns": { + "owned": "none" + }, + "source": "inferred" + }, + { + "file": "ef.cs", + "line": 0, + "method": "Ef.C", + "params": [ + { + "disposable": true, + "index": 0, + "name": "a", + "transfer": "must" + } + ], + "returns": { + "owned": "none" + }, + "source": "inferred" + }, + { + "file": "ef.cs", + "line": 0, + "method": "Ef.M", + "params": [ + { + "disposable": true, + "index": 0, + "name": "c", + "transfer": "no" + } + ], + "returns": { + "owned": "none" + }, + "source": "inferred" + }, + { + "file": "ef.cs", + "line": 0, + "method": "Ef.P", + "params": [ + { + "disposable": true, + "index": 0, + "name": "d", + "transfer": "no" + } + ], + "returns": { + "owned": "none" + }, + "source": "inferred" + } + ], + "unresolved": [] +} diff --git a/tests/fixtures/summaries/mosdump_nonascii_escaping.facts.json b/tests/fixtures/summaries/mosdump_nonascii_escaping.facts.json new file mode 100644 index 00000000..848b9b08 --- /dev/null +++ b/tests/fixtures/summaries/mosdump_nonascii_escaping.facts.json @@ -0,0 +1,27 @@ +{ + "ownir_version": 0, + "module": "Юникод—М😀", + "functions": [ + { + "name": "Ü.Mëthod", + "file": "файл😀.cs", + "params": [ + { + "name": "π", + "line": 1 + }, + { + "name": "q\"uote\\", + "line": 1 + } + ], + "body": [ + { + "op": "use", + "var": "π", + "line": 2 + } + ] + } + ] +} diff --git a/tests/fixtures/summaries/mosdump_nonascii_escaping.summaries.json b/tests/fixtures/summaries/mosdump_nonascii_escaping.summaries.json new file mode 100644 index 00000000..2e68de6a --- /dev/null +++ b/tests/fixtures/summaries/mosdump_nonascii_escaping.summaries.json @@ -0,0 +1,31 @@ +{ + "degraded": null, + "module": "\u042e\u043d\u0438\u043a\u043e\u0434\u2014\u041c\ud83d\ude00", + "ownir_version": 0, + "summaries": [ + { + "file": "\u0444\u0430\u0439\u043b\ud83d\ude00.cs", + "line": 0, + "method": "\u00dc.M\u00ebthod", + "params": [ + { + "disposable": true, + "index": 0, + "name": "\u03c0", + "transfer": "no" + }, + { + "disposable": true, + "index": 1, + "name": "q\"uote\\", + "transfer": "no" + } + ], + "returns": { + "owned": "none" + }, + "source": "inferred" + } + ], + "unresolved": [] +} diff --git a/tests/fixtures/summaries/mosdump_overload_merge_tiebreaks.facts.json b/tests/fixtures/summaries/mosdump_overload_merge_tiebreaks.facts.json new file mode 100644 index 00000000..f76f3169 --- /dev/null +++ b/tests/fixtures/summaries/mosdump_overload_merge_tiebreaks.facts.json @@ -0,0 +1,61 @@ +{ + "ownir_version": 0, + "module": "MergeTie", + "functions": [ + { + "name": "Ov.M", + "file": "z.cs", + "params": [ + { + "name": "zz", + "line": 1 + } + ], + "body": [ + { + "op": "release", + "var": "zz", + "line": 2 + } + ] + }, + { + "name": "Ov.M", + "file": "a.cs", + "params": [ + { + "name": "aa", + "line": 5 + } + ], + "body": [ + { + "op": "use", + "var": "aa", + "line": 6 + } + ] + }, + { + "name": "Ov.M", + "file": "m.cs", + "params": [ + { + "name": "k0", + "line": 9 + }, + { + "name": "k1", + "line": 9 + } + ], + "body": [ + { + "op": "release", + "var": "k1", + "line": 10 + } + ] + } + ] +} diff --git a/tests/fixtures/summaries/mosdump_overload_merge_tiebreaks.summaries.json b/tests/fixtures/summaries/mosdump_overload_merge_tiebreaks.summaries.json new file mode 100644 index 00000000..e540bba6 --- /dev/null +++ b/tests/fixtures/summaries/mosdump_overload_merge_tiebreaks.summaries.json @@ -0,0 +1,31 @@ +{ + "degraded": null, + "module": "MergeTie", + "ownir_version": 0, + "summaries": [ + { + "file": "a.cs", + "line": 0, + "method": "Ov.M", + "params": [ + { + "disposable": true, + "index": 0, + "name": "aa", + "transfer": "may" + }, + { + "disposable": true, + "index": 1, + "name": "k1", + "transfer": "must" + } + ], + "returns": { + "owned": "none" + }, + "source": "inferred" + } + ], + "unresolved": [] +} diff --git a/tests/fixtures/summaries/mosdump_partial_and_guarded.facts.json b/tests/fixtures/summaries/mosdump_partial_and_guarded.facts.json new file mode 100644 index 00000000..ae488676 --- /dev/null +++ b/tests/fixtures/summaries/mosdump_partial_and_guarded.facts.json @@ -0,0 +1,78 @@ +{ + "ownir_version": 0, + "module": "Guarded", + "functions": [ + { + "name": "Pg.Partial", + "file": "pg.cs", + "params": [ + { + "name": "p", + "line": 1 + } + ], + "body": [ + { + "op": "if", + "line": 2, + "then": [ + { + "op": "release", + "var": "p", + "line": 3 + } + ], + "else": [] + } + ] + }, + { + "name": "Pg.Early", + "file": "pg.cs", + "params": [ + { + "name": "q", + "line": 7 + } + ], + "body": [ + { + "op": "if", + "line": 8, + "then": [ + { + "op": "return", + "line": 9 + } + ], + "else": [] + }, + { + "op": "call", + "callee": "Pg.SinkAll", + "args": [ + "q" + ], + "line": 11 + } + ] + }, + { + "name": "Pg.SinkAll", + "file": "pg.cs", + "params": [ + { + "name": "r", + "line": 14 + } + ], + "body": [ + { + "op": "release", + "var": "r", + "line": 15 + } + ] + } + ] +} diff --git a/tests/fixtures/summaries/mosdump_partial_and_guarded.summaries.json b/tests/fixtures/summaries/mosdump_partial_and_guarded.summaries.json new file mode 100644 index 00000000..ef87eff4 --- /dev/null +++ b/tests/fixtures/summaries/mosdump_partial_and_guarded.summaries.json @@ -0,0 +1,59 @@ +{ + "degraded": null, + "module": "Guarded", + "ownir_version": 0, + "summaries": [ + { + "file": "pg.cs", + "line": 0, + "method": "Pg.Early", + "params": [ + { + "disposable": true, + "index": 0, + "name": "q", + "transfer": "may" + } + ], + "returns": { + "owned": "none" + }, + "source": "inferred" + }, + { + "file": "pg.cs", + "line": 0, + "method": "Pg.Partial", + "params": [ + { + "disposable": true, + "index": 0, + "name": "p", + "transfer": "may" + } + ], + "returns": { + "owned": "none" + }, + "source": "inferred" + }, + { + "file": "pg.cs", + "line": 0, + "method": "Pg.SinkAll", + "params": [ + { + "disposable": true, + "index": 0, + "name": "r", + "transfer": "must" + } + ], + "returns": { + "owned": "none" + }, + "source": "inferred" + } + ], + "unresolved": [] +} diff --git a/tests/fixtures/summaries/mosdump_return_chains.facts.json b/tests/fixtures/summaries/mosdump_return_chains.facts.json new file mode 100644 index 00000000..b9d4304a --- /dev/null +++ b/tests/fixtures/summaries/mosdump_return_chains.facts.json @@ -0,0 +1,112 @@ +{ + "ownir_version": 0, + "module": "ReturnChains", + "functions": [ + { + "name": "Mk.Base", + "file": "mk.cs", + "body": [ + { + "op": "acquire", + "var": "r", + "line": 2 + }, + { + "op": "return", + "var": "r", + "line": 3 + } + ] + }, + { + "name": "Mk.Wrap1", + "file": "mk.cs", + "body": [ + { + "op": "call", + "callee": "Mk.Base", + "args": [], + "result": "x", + "line": 7 + }, + { + "op": "return", + "var": "x", + "line": 8 + } + ] + }, + { + "name": "Mk.Wrap2", + "file": "mk.cs", + "body": [ + { + "op": "call", + "callee": "Mk.Wrap1", + "args": [], + "result": "y", + "line": 12 + }, + { + "op": "return", + "var": "y", + "line": 13 + } + ] + }, + { + "name": "Cyc.P", + "file": "cyc.cs", + "body": [ + { + "op": "call", + "callee": "Cyc.Q", + "args": [], + "result": "a", + "line": 17 + }, + { + "op": "return", + "var": "a", + "line": 18 + } + ] + }, + { + "name": "Cyc.Q", + "file": "cyc.cs", + "body": [ + { + "op": "call", + "callee": "Cyc.P", + "args": [], + "result": "b", + "line": 22 + }, + { + "op": "return", + "var": "b", + "line": 23 + } + ] + }, + { + "name": "Ext.W", + "file": "ext.cs", + "body": [ + { + "op": "call", + "callee": "Vendor.Make", + "args": [], + "result": "c", + "line": 27 + }, + { + "op": "return", + "var": "c", + "line": 28 + } + ] + } + ] +} diff --git a/tests/fixtures/summaries/mosdump_return_chains.summaries.json b/tests/fixtures/summaries/mosdump_return_chains.summaries.json new file mode 100644 index 00000000..9f026dfa --- /dev/null +++ b/tests/fixtures/summaries/mosdump_return_chains.summaries.json @@ -0,0 +1,70 @@ +{ + "degraded": null, + "module": "ReturnChains", + "ownir_version": 0, + "summaries": [ + { + "file": "cyc.cs", + "line": 0, + "method": "Cyc.P", + "params": [], + "returns": { + "owned": "unknown" + }, + "source": "inferred" + }, + { + "file": "cyc.cs", + "line": 0, + "method": "Cyc.Q", + "params": [], + "returns": { + "owned": "unknown" + }, + "source": "inferred" + }, + { + "file": "ext.cs", + "line": 0, + "method": "Ext.W", + "params": [], + "returns": { + "owned": "unknown" + }, + "source": "inferred" + }, + { + "file": "mk.cs", + "line": 0, + "method": "Mk.Base", + "params": [], + "returns": { + "owned": "fresh" + }, + "source": "inferred" + }, + { + "file": "mk.cs", + "line": 0, + "method": "Mk.Wrap1", + "params": [], + "returns": { + "owned": "fresh" + }, + "source": "inferred" + }, + { + "file": "mk.cs", + "line": 0, + "method": "Mk.Wrap2", + "params": [], + "returns": { + "owned": "fresh" + }, + "source": "inferred" + } + ], + "unresolved": [ + "return Vendor.Make (extern, no summary)" + ] +} diff --git a/tests/fixtures/summaries/mosdump_scc_cycles.facts.json b/tests/fixtures/summaries/mosdump_scc_cycles.facts.json new file mode 100644 index 00000000..b0ab94e2 --- /dev/null +++ b/tests/fixtures/summaries/mosdump_scc_cycles.facts.json @@ -0,0 +1,101 @@ +{ + "ownir_version": 0, + "module": "SccCycles", + "functions": [ + { + "name": "Cy.A", + "file": "cy.cs", + "params": [ + { + "name": "s", + "line": 1 + } + ], + "body": [ + { + "op": "call", + "callee": "Cy.B", + "args": [ + "s" + ], + "line": 2 + } + ] + }, + { + "name": "Cy.B", + "file": "cy.cs", + "params": [ + { + "name": "t", + "line": 5 + } + ], + "body": [ + { + "op": "call", + "callee": "Cy.A", + "args": [ + "t" + ], + "line": 6 + } + ] + }, + { + "name": "Cy.C", + "file": "cy.cs", + "params": [ + { + "name": "u", + "line": 9 + } + ], + "body": [ + { + "op": "if", + "line": 10, + "then": [ + { + "op": "call", + "callee": "Cy.D", + "args": [ + "u" + ], + "line": 11 + } + ], + "else": [] + } + ] + }, + { + "name": "Cy.D", + "file": "cy.cs", + "params": [ + { + "name": "v", + "line": 14 + } + ], + "body": [ + { + "op": "call", + "callee": "Cy.C", + "args": [ + "v" + ], + "line": 15 + }, + { + "op": "call", + "callee": "Ext.Gone", + "args": [ + "v" + ], + "line": 16 + } + ] + } + ] +} diff --git a/tests/fixtures/summaries/mosdump_scc_cycles.summaries.json b/tests/fixtures/summaries/mosdump_scc_cycles.summaries.json new file mode 100644 index 00000000..d314be74 --- /dev/null +++ b/tests/fixtures/summaries/mosdump_scc_cycles.summaries.json @@ -0,0 +1,78 @@ +{ + "degraded": null, + "module": "SccCycles", + "ownir_version": 0, + "summaries": [ + { + "file": "cy.cs", + "line": 0, + "method": "Cy.A", + "params": [ + { + "disposable": true, + "index": 0, + "name": "s", + "transfer": "no" + } + ], + "returns": { + "owned": "none" + }, + "source": "inferred" + }, + { + "file": "cy.cs", + "line": 0, + "method": "Cy.B", + "params": [ + { + "disposable": true, + "index": 0, + "name": "t", + "transfer": "no" + } + ], + "returns": { + "owned": "none" + }, + "source": "inferred" + }, + { + "file": "cy.cs", + "line": 0, + "method": "Cy.C", + "params": [ + { + "disposable": true, + "index": 0, + "name": "u", + "transfer": "unknown" + } + ], + "returns": { + "owned": "none" + }, + "source": "inferred" + }, + { + "file": "cy.cs", + "line": 0, + "method": "Cy.D", + "params": [ + { + "disposable": true, + "index": 0, + "name": "v", + "transfer": "unknown" + } + ], + "returns": { + "owned": "none" + }, + "source": "inferred" + } + ], + "unresolved": [ + "Ext.Gone#0 (extern, no summary)" + ] +} diff --git a/tests/fixtures/summaries/mosdump_sig_vocabulary.facts.json b/tests/fixtures/summaries/mosdump_sig_vocabulary.facts.json new file mode 100644 index 00000000..b8213acf --- /dev/null +++ b/tests/fixtures/summaries/mosdump_sig_vocabulary.facts.json @@ -0,0 +1,71 @@ +{ + "ownir_version": 0, + "module": "SigVocab", + "functions": [ + { + "name": "C.S", + "sig": "System.IO.Stream", + "file": "s1.cs", + "params": [ + { + "name": "p", + "line": 1 + } + ], + "body": [ + { + "op": "release", + "var": "p", + "line": 2 + } + ] + }, + { + "name": "C.S", + "sig": "", + "file": "s0.cs", + "body": [] + }, + { + "name": "C.F", + "file": "f.cs", + "params": [ + { + "name": "q", + "line": 8 + } + ], + "body": [ + { + "op": "call", + "callee": "C.S", + "sig": "System.IO.Stream", + "args": [ + "q" + ], + "line": 9 + } + ] + }, + { + "name": "C.G", + "file": "g.cs", + "params": [ + { + "name": "w", + "line": 12 + } + ], + "body": [ + { + "op": "call", + "callee": "global::C.S", + "args": [ + "w" + ], + "line": 13 + } + ] + } + ] +} diff --git a/tests/fixtures/summaries/mosdump_sig_vocabulary.summaries.json b/tests/fixtures/summaries/mosdump_sig_vocabulary.summaries.json new file mode 100644 index 00000000..7e1db564 --- /dev/null +++ b/tests/fixtures/summaries/mosdump_sig_vocabulary.summaries.json @@ -0,0 +1,86 @@ +{ + "degraded": null, + "module": "SigVocab", + "ownir_version": 0, + "summaries": [ + { + "file": "f.cs", + "line": 0, + "method": "C.F", + "params": [ + { + "disposable": true, + "index": 0, + "name": "q", + "transfer": "must" + } + ], + "returns": { + "owned": "none" + }, + "source": "inferred" + }, + { + "file": "g.cs", + "line": 0, + "method": "C.G", + "params": [ + { + "disposable": true, + "index": 0, + "name": "w", + "transfer": "must" + } + ], + "returns": { + "owned": "none" + }, + "source": "inferred" + }, + { + "file": "s0.cs", + "line": 0, + "method": "C.S", + "params": [ + { + "disposable": true, + "index": 0, + "name": "p", + "transfer": "must" + } + ], + "returns": { + "owned": "none" + }, + "source": "inferred" + }, + { + "file": "s0.cs", + "line": 0, + "method": "C.S()", + "params": [], + "returns": { + "owned": "none" + }, + "source": "inferred" + }, + { + "file": "s1.cs", + "line": 0, + "method": "C.S(System.IO.Stream)", + "params": [ + { + "disposable": true, + "index": 0, + "name": "p", + "transfer": "must" + } + ], + "returns": { + "owned": "none" + }, + "source": "inferred" + } + ], + "unresolved": [] +} diff --git a/tests/fixtures/summaries/mosdump_sink_channels.facts.json b/tests/fixtures/summaries/mosdump_sink_channels.facts.json new file mode 100644 index 00000000..cb5cebfe --- /dev/null +++ b/tests/fixtures/summaries/mosdump_sink_channels.facts.json @@ -0,0 +1,66 @@ +{ + "ownir_version": 0, + "module": "Sinks", + "functions": [ + { + "name": "Sk.C", + "file": "sk.cs", + "params": [ + { + "name": "a", + "line": 1 + } + ], + "body": [ + { + "op": "call", + "callee": "$consume", + "args": [ + "a" + ], + "line": 2 + } + ] + }, + { + "name": "Sk.B", + "file": "sk.cs", + "params": [ + { + "name": "b", + "line": 5 + } + ], + "body": [ + { + "op": "call", + "callee": "$borrow", + "args": [ + "b" + ], + "line": 6 + } + ] + }, + { + "name": "Sk.M", + "file": "sk.cs", + "params": [ + { + "name": "c", + "line": 9 + } + ], + "body": [ + { + "op": "call", + "callee": "$borrow_mut", + "args": [ + "c" + ], + "line": 10 + } + ] + } + ] +} diff --git a/tests/fixtures/summaries/mosdump_sink_channels.summaries.json b/tests/fixtures/summaries/mosdump_sink_channels.summaries.json new file mode 100644 index 00000000..144e0edc --- /dev/null +++ b/tests/fixtures/summaries/mosdump_sink_channels.summaries.json @@ -0,0 +1,61 @@ +{ + "degraded": null, + "module": "Sinks", + "ownir_version": 0, + "summaries": [ + { + "file": "sk.cs", + "line": 0, + "method": "Sk.B", + "params": [ + { + "disposable": true, + "index": 0, + "name": "b", + "transfer": "no" + } + ], + "returns": { + "owned": "none" + }, + "source": "inferred" + }, + { + "file": "sk.cs", + "line": 0, + "method": "Sk.C", + "params": [ + { + "disposable": true, + "index": 0, + "name": "a", + "transfer": "must" + } + ], + "returns": { + "owned": "none" + }, + "source": "inferred" + }, + { + "file": "sk.cs", + "line": 0, + "method": "Sk.M", + "params": [ + { + "disposable": true, + "index": 0, + "name": "c", + "transfer": "unknown" + } + ], + "returns": { + "owned": "none" + }, + "source": "inferred" + } + ], + "unresolved": [ + "$borrow_mut#0 (extern, no summary)" + ] +} diff --git a/tests/fixtures/summaries/routing_r1_unresolved.summaries.json b/tests/fixtures/summaries/routing_r1_unresolved.summaries.json new file mode 100644 index 00000000..0cd0417f --- /dev/null +++ b/tests/fixtures/summaries/routing_r1_unresolved.summaries.json @@ -0,0 +1,7 @@ +{ + "degraded": null, + "module": "R1", + "ownir_version": 0, + "summaries": [], + "unresolved": [] +} diff --git a/tests/fixtures/summaries/routing_r2_subscribe_self.summaries.json b/tests/fixtures/summaries/routing_r2_subscribe_self.summaries.json new file mode 100644 index 00000000..b9f992f0 --- /dev/null +++ b/tests/fixtures/summaries/routing_r2_subscribe_self.summaries.json @@ -0,0 +1,7 @@ +{ + "degraded": null, + "module": "R2", + "ownir_version": 0, + "summaries": [], + "unresolved": [] +} diff --git a/tests/fixtures/summaries/routing_r3_capture_static.summaries.json b/tests/fixtures/summaries/routing_r3_capture_static.summaries.json new file mode 100644 index 00000000..0e379113 --- /dev/null +++ b/tests/fixtures/summaries/routing_r3_capture_static.summaries.json @@ -0,0 +1,7 @@ +{ + "degraded": null, + "module": "R3", + "ownir_version": 0, + "summaries": [], + "unresolved": [] +} diff --git a/tests/fixtures/summaries/routing_r4_returned_fresh.summaries.json b/tests/fixtures/summaries/routing_r4_returned_fresh.summaries.json new file mode 100644 index 00000000..25eed67b --- /dev/null +++ b/tests/fixtures/summaries/routing_r4_returned_fresh.summaries.json @@ -0,0 +1,7 @@ +{ + "degraded": null, + "module": "R4", + "ownir_version": 0, + "summaries": [], + "unresolved": [] +} diff --git a/tests/fixtures/summaries/routing_r5_di_capture.summaries.json b/tests/fixtures/summaries/routing_r5_di_capture.summaries.json new file mode 100644 index 00000000..f186c178 --- /dev/null +++ b/tests/fixtures/summaries/routing_r5_di_capture.summaries.json @@ -0,0 +1,7 @@ +{ + "degraded": null, + "module": "R5", + "ownir_version": 0, + "summaries": [], + "unresolved": [] +} diff --git a/tests/fixtures/summaries/routing_r6_token_kinds.summaries.json b/tests/fixtures/summaries/routing_r6_token_kinds.summaries.json new file mode 100644 index 00000000..27e7f1de --- /dev/null +++ b/tests/fixtures/summaries/routing_r6_token_kinds.summaries.json @@ -0,0 +1,7 @@ +{ + "degraded": null, + "module": "R6", + "ownir_version": 0, + "summaries": [], + "unresolved": [] +} diff --git a/tests/fixtures/summaries/vocab_unknown_op.summaries.json b/tests/fixtures/summaries/vocab_unknown_op.summaries.json new file mode 100644 index 00000000..20400fcc --- /dev/null +++ b/tests/fixtures/summaries/vocab_unknown_op.summaries.json @@ -0,0 +1,18 @@ +{ + "degraded": null, + "module": "Vocab", + "ownir_version": 0, + "summaries": [ + { + "file": "F.cs", + "line": 0, + "method": "M", + "params": [], + "returns": { + "owned": "none" + }, + "source": "inferred" + } + ], + "unresolved": [] +} diff --git a/tests/test_summaries_fixtures.py b/tests/test_summaries_fixtures.py new file mode 100644 index 00000000..d3006d61 --- /dev/null +++ b/tests/test_summaries_fixtures.py @@ -0,0 +1,249 @@ +#!/usr/bin/env python3 +"""MOS summaries parity fixtures (P-022, roadmap stage 1) — the `summaries` dump. + +Each golden `tests/fixtures/summaries/.summaries.json` pins the EXACT +stdout bytes of `python -m ownlang summaries ` (the document from +`ownlang.ownir.dump_summaries`, serialized `json.dumps(indent=2, +sort_keys=True)` + the trailing newline `print` appends). This is the parity +artifact spec/Inference.md §8 names: the Rust port of the inference layer is +diffed against it at the SUMMARY level — finer than diffing final diagnostics, +where an inference bug can hide behind an unrelated silence. + +Two case sources, one golden tree: + +* **The frozen facts corpus** — every `tests/fixtures/lowered/.facts.json` + is swept automatically (no per-case listing to forget), EXCEPT the names in + the manifest's `excluded_lowered` ledger. An exclusion must both exist in the + lowered corpus and still FAIL `load()` (the summaries door): the day the door + starts accepting it, this suite goes red demanding promotion to a golden — + an exclusion cannot silently rot into a coverage hole. Today the only entry + is `tolerant_unknown_kind` (its facts carry an unknown resource kind, which + Python rejects at `load()` while Rust rejects at `lower()` — the #294 door + placement; the lowered family pins that rejection text byte-for-byte). +* **Synthetic MOS cases** — `tests/fixtures/summaries/.facts.json`, listed + exhaustively in the manifest's `cases` ledger (name + the INF rules pinned), + targeting solver/dump behavior the lowered corpus does not reach: SCC cycles, + return-forward chains and cycles, the extern-boundary log, overload-merge + tie-breaks, the sig-key vocabulary, the `degraded` branch, sink channels, + explicit effects, and non-ASCII escaping (the dump serializes with + `ensure_ascii`, unlike the Layer 2 surface). + +* Python is authoritative: `python tests/test_summaries_fixtures.py --write` + regenerates every golden. Regeneration is deterministic, and verify mode + re-dumps each case with `functions[]` REVERSED — INF-R1 (byte-identical + under input permutation) is checked on every case, not on a sample. +* The Rust side holds up its half in + `rust/crates/own-bridge/tests/summaries.rs`: `own_bridge::dump_summaries` + must reproduce every golden byte-for-byte from the same facts, without + Python present (the zero-Python steady state). + +Run: python tests/test_summaries_fixtures.py (verify) + python tests/test_summaries_fixtures.py --write (regenerate) + python tests/run_tests.py (runs it in the suite) +""" + +from __future__ import annotations + +import copy +import json +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from ownlang.ownir import OWNIR_VERSION, OwnIRError, dump_summaries, load + +FIXDIR = os.path.join(os.path.dirname(__file__), "fixtures", "summaries") +LOWDIR = os.path.join(os.path.dirname(__file__), "fixtures", "lowered") +MANIFEST = os.path.join(FIXDIR, "manifest.json") + + +def _render(facts: dict) -> str: + """The golden bytes: exactly what `python -m ownlang summaries` prints — + `json.dumps(dump_summaries(facts), indent=2, sort_keys=True)` plus the + newline `print` appends. `sort_keys` (with the default `ensure_ascii`) + is the CLI's frozen output contract — NOT the Layer 2 family's + `ensure_ascii=False` convention — so non-ASCII pins as `\\uXXXX`.""" + return json.dumps(dump_summaries(facts), indent=2, sort_keys=True) + "\n" + + +def _manifest() -> tuple[list[str], dict[str, str], list[str]]: + """(synthetic case names sorted, excluded_lowered name->reason, problems).""" + problems: list[str] = [] + if not os.path.exists(MANIFEST): + return [], {}, [f"manifest missing: {MANIFEST}"] + with open(MANIFEST, encoding="utf-8") as f: + data = json.load(f) + if data.get("ownir_version") != OWNIR_VERSION: + problems.append( + f"manifest ownir_version {data.get('ownir_version')!r} != " + f"OWNIR_VERSION {OWNIR_VERSION}") + excluded: dict[str, str] = {} + for e in data.get("excluded_lowered", []): + name, reason = e.get("name"), e.get("reason") + if not (isinstance(name, str) and name + and isinstance(reason, str) and reason): + problems.append(f"excluded_lowered entry needs a non-empty name " + f"and reason: {e!r}") + continue + if name in excluded: + problems.append(f"excluded_lowered lists '{name}' twice") + excluded[name] = reason + names: list[str] = [] + for c in data.get("cases", []): + name = c.get("name") + if not isinstance(name, str) or not name: + problems.append(f"manifest case without a name: {c!r}") + continue + rules = c.get("rules") + if not (isinstance(rules, list) and rules + and all(isinstance(r, str) and r for r in rules)): + problems.append(f"manifest case '{name}': 'rules' must be a " + f"non-empty array of non-empty strings") + names.append(name) + if len(set(names)) != len(names): + problems.append("manifest contains duplicate case names") + return sorted(names), excluded, problems + + +def _disk_cases(directory: str, suffix: str) -> list[str]: + if not os.path.isdir(directory): + return [] + return sorted(n[:-len(suffix)] for n in os.listdir(directory) + if n.endswith(suffix)) + + +def _plan() -> tuple[dict[str, str], list[str]]: + """The full case plan: name -> facts path, plus ledger problems. The + lowered corpus is swept automatically (minus the audited exclusions); the + synthetic cases must match the manifest ledger exactly; the two name + spaces must not collide (one golden tree serves both).""" + synthetic, excluded, problems = _manifest() + lowered = _disk_cases(LOWDIR, ".facts.json") + local = _disk_cases(FIXDIR, ".facts.json") + for phantom in sorted(set(excluded) - set(lowered)): + problems.append(f"excluded_lowered names '{phantom}', which has no " + f"facts file in the lowered corpus") + for name in sorted(excluded): + if name not in lowered: + continue + try: + load(os.path.join(LOWDIR, f"{name}.facts.json")) + except OwnIRError: + pass # still rejected at the door — the exclusion holds + else: + problems.append( + f"'{name}' is excluded_lowered but load() now ACCEPTS its " + f"facts — the exclusion has rotted; promote the case to a " + f"golden (remove the exclusion and --write)") + for missing in sorted(set(synthetic) - set(local)): + problems.append(f"manifest case '{missing}' has no facts file " + f"({missing}.facts.json) under fixtures/summaries") + for unlisted in sorted(set(local) - set(synthetic)): + problems.append(f"'{unlisted}.facts.json' is not in manifest.json — " + f"add the case to the ledger (name, rules)") + plan: dict[str, str] = {} + for name in lowered: + if name not in excluded: + plan[name] = os.path.join(LOWDIR, f"{name}.facts.json") + for name in synthetic: + if name in plan: + problems.append(f"case name '{name}' exists in BOTH the lowered " + f"corpus and fixtures/summaries — one golden tree " + f"serves both, so names must be unique") + elif name in _disk_cases(LOWDIR, ".facts.json"): + problems.append(f"synthetic case '{name}' shadows a lowered " + f"corpus case name") + else: + plan[name] = os.path.join(FIXDIR, f"{name}.facts.json") + return plan, problems + + +def _goldens() -> set[str]: + if not os.path.isdir(FIXDIR): + return set() + return {n[:-len(".summaries.json")] for n in os.listdir(FIXDIR) + if n.endswith(".summaries.json")} + + +def _project(facts_path: str) -> tuple[str, str]: + """(golden bytes, permuted-input bytes) — the second dump runs with + `functions[]` reversed, so INF-R1 (byte-identical under input + permutation) is enforced per case, not per sample.""" + facts = load(facts_path) + expected = _render(facts) + permuted = copy.deepcopy(facts) + fns = permuted.get("functions") + if isinstance(fns, list): + fns.reverse() + return expected, _render(permuted) + + +def run() -> int: + plan, fails = _plan() + if not plan and not fails: + fails.append(f"no cases planned (no facts under {LOWDIR} / {FIXDIR})") + for case, facts_path in sorted(plan.items()): + golden_path = os.path.join(FIXDIR, f"{case}.summaries.json") + try: + expected, permuted = _project(facts_path) + except OwnIRError as e: + fails.append(f"{case}: load() rejected its facts ({e}) — a " + f"rejected case must be in excluded_lowered, not " + f"silently skipped") + continue + if permuted != expected: + fails.append(f"{case}: dump is not byte-identical under " + f"functions[] permutation (INF-R1)") + continue + if not os.path.exists(golden_path): + fails.append(f"{case}: golden missing; regenerate with " + f"'python tests/test_summaries_fixtures.py --write'") + continue + with open(golden_path, encoding="utf-8") as f: + actual = f.read() + if actual != expected: + fails.append(f"{case}: golden is stale (the solver or the dump " + f"changed); regenerate with " + f"'python tests/test_summaries_fixtures.py --write'") + for orphan in sorted(_goldens() - set(plan)): + fails.append(f"{orphan}: orphaned golden (not a planned case); remove " + f"it or restore the case (manifest/facts)") + if fails: + for f_ in fails: + print(f"FAIL: summaries fixture {f_}") + return 1 + n_degraded = 0 + for facts_path in plan.values(): + if json.loads(_project(facts_path)[0]).get("degraded") is not None: + n_degraded += 1 + print(f"summaries (MOS parity) fixtures OK: {len(plan)} cases " + f"({n_degraded} degraded, {len(plan) - n_degraded} solved) " + f"verified in sync") + return 0 + + +def write() -> int: + """Regenerate goldens for every planned case. Regeneration never accepts + a shrunken or inconsistent ledger (same rule as the lowered family).""" + plan, problems = _plan() + if problems: + for p in problems: + print(f"ERROR: {p}") + return 1 + for case, facts_path in sorted(plan.items()): + out = os.path.join(FIXDIR, f"{case}.summaries.json") + with open(out, "w", encoding="utf-8") as f: + f.write(_project(facts_path)[0]) + print(f"wrote {out}") + for orphan in sorted(_goldens() - set(plan)): + path = os.path.join(FIXDIR, f"{orphan}.summaries.json") + os.remove(path) + print(f"removed orphaned {path}") + return 0 + + +if __name__ == "__main__": + if "--write" in sys.argv[1:]: + raise SystemExit(write()) + raise SystemExit(run()) From 1c94d8954992940108faa54b77b6fe979ed31c44 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 09:20:39 +0000 Subject: [PATCH 2/5] docs(bridge): scope the dump parity claim to scalar metadata (Codex P2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reference str()-ifies raw metadata (module, functions[].name/file) without type validation on either door; a container there renders as Python repr vs JSON text here — a shape with no producer that cannot be emulated faithfully for dicts (serde_json re-sorts map keys, Python str() keeps insertion order). Keep it out of the parity contract explicitly: the harness pins scalar-metadata corpora only and goes red on the byte diff if such a fixture ever lands, rather than diverging silently. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015de4MezSeUnZBoWq1fFU5M --- rust/crates/own-bridge/src/dump.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/rust/crates/own-bridge/src/dump.rs b/rust/crates/own-bridge/src/dump.rs index 8d78f8f8..108a2736 100644 --- a/rust/crates/own-bridge/src/dump.rs +++ b/rust/crates/own-bridge/src/dump.rs @@ -30,6 +30,17 @@ use serde_json::{json, Value}; /// Render the summaries document for one `OwnIR` facts document — /// byte-identical to `python -m ownlang summaries` on the same facts. /// +/// Scope note (the `py_str` caveat, same as the Layer 2 surface): the +/// reference `str()`-ifies raw metadata scalars (`module`, and the +/// per-record `name`/`file` the skeleton builder reads), and neither door +/// validates their TYPE — a container placed where a scalar belongs would +/// render as Python `repr` there and as JSON text here. That shape has no +/// producer, cannot be reproduced faithfully for dicts at all (serde_json's +/// map re-sorts keys; Python `str()` keeps insertion order), and is kept +/// OUT of the parity contract rather than half-emulated: the harness pins +/// only scalar-metadata corpora, and any future fixture that smuggles a +/// container in goes red on the byte diff instead of diverging silently. +/// /// # Errors /// [`BridgeError`] only if the typed facts cannot be re-serialized to JSON /// (not reachable for a document `OwnIr::from_json` accepted); a SOLVER From 2a34c7aeee03fee1f70ede0bec9bd067ae95d50a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 09:21:20 +0000 Subject: [PATCH 3/5] docs(bridge): backtick serde_json in the scope note (clippy doc_markdown) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015de4MezSeUnZBoWq1fFU5M --- rust/crates/own-bridge/src/dump.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/rust/crates/own-bridge/src/dump.rs b/rust/crates/own-bridge/src/dump.rs index 108a2736..00a21f5a 100644 --- a/rust/crates/own-bridge/src/dump.rs +++ b/rust/crates/own-bridge/src/dump.rs @@ -35,11 +35,12 @@ use serde_json::{json, Value}; /// per-record `name`/`file` the skeleton builder reads), and neither door /// validates their TYPE — a container placed where a scalar belongs would /// render as Python `repr` there and as JSON text here. That shape has no -/// producer, cannot be reproduced faithfully for dicts at all (serde_json's -/// map re-sorts keys; Python `str()` keeps insertion order), and is kept -/// OUT of the parity contract rather than half-emulated: the harness pins -/// only scalar-metadata corpora, and any future fixture that smuggles a -/// container in goes red on the byte diff instead of diverging silently. +/// producer, cannot be reproduced faithfully for dicts at all +/// (`serde_json`'s map re-sorts keys; Python `str()` keeps insertion +/// order), and is kept OUT of the parity contract rather than +/// half-emulated: the harness pins only scalar-metadata corpora, and any +/// future fixture that smuggles a container in goes red on the byte diff +/// instead of diverging silently. /// /// # Errors /// [`BridgeError`] only if the typed facts cannot be re-serialized to JSON From 92e85072bf2117e03b438c2fb518eef2d270b913 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 09:26:11 +0000 Subject: [PATCH 4/5] perf(tests): fold the degraded count into the verify loop; reuse the lowered set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both CodeRabbit nitpicks on the summaries fixture harness: the degraded counter re-ran _project (two solver+render passes) per case after the main loop had already produced the same bytes — derive it from the verified dump instead; and the synthetic shadow check re-listed the lowered directory per case where the already-computed set serves. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015de4MezSeUnZBoWq1fFU5M --- tests/test_summaries_fixtures.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/tests/test_summaries_fixtures.py b/tests/test_summaries_fixtures.py index d3006d61..0dbe51d9 100644 --- a/tests/test_summaries_fixtures.py +++ b/tests/test_summaries_fixtures.py @@ -151,7 +151,7 @@ def _plan() -> tuple[dict[str, str], list[str]]: problems.append(f"case name '{name}' exists in BOTH the lowered " f"corpus and fixtures/summaries — one golden tree " f"serves both, so names must be unique") - elif name in _disk_cases(LOWDIR, ".facts.json"): + elif name in lowered: # shadows an EXCLUDED lowered case problems.append(f"synthetic case '{name}' shadows a lowered " f"corpus case name") else: @@ -183,6 +183,10 @@ def run() -> int: plan, fails = _plan() if not plan and not fails: fails.append(f"no cases planned (no facts under {LOWDIR} / {FIXDIR})") + # `degraded` is counted from the dump already produced by the main loop — + # a second `_project` pass per case would double the solver/render work + # just to read one field (CodeRabbit). + n_degraded = 0 for case, facts_path in sorted(plan.items()): golden_path = os.path.join(FIXDIR, f"{case}.summaries.json") try: @@ -206,6 +210,9 @@ def run() -> int: fails.append(f"{case}: golden is stale (the solver or the dump " f"changed); regenerate with " f"'python tests/test_summaries_fixtures.py --write'") + continue + if json.loads(expected).get("degraded") is not None: + n_degraded += 1 for orphan in sorted(_goldens() - set(plan)): fails.append(f"{orphan}: orphaned golden (not a planned case); remove " f"it or restore the case (manifest/facts)") @@ -213,10 +220,6 @@ def run() -> int: for f_ in fails: print(f"FAIL: summaries fixture {f_}") return 1 - n_degraded = 0 - for facts_path in plan.values(): - if json.loads(_project(facts_path)[0]).get("degraded") is not None: - n_degraded += 1 print(f"summaries (MOS parity) fixtures OK: {len(plan)} cases " f"({n_degraded} degraded, {len(plan) - n_degraded} solved) " f"verified in sync") From 59af5f4e6d6b890de382edb714ac3ce8e9e2e482 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 09:48:59 +0000 Subject: [PATCH 5/5] docs(bridge): state the scalar-metadata parity domain at the PUBLIC door MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Arbiter P2 follow-up on the Codex finding: the crate-level doc and the public dump_summaries promised unqualified byte-parity 'on the same facts' while the domain boundary lived only on the private implementation — the public reader had to excavate a private module to learn what 'same' excludes. The public contract now states the shared parity domain itself (metadata consumed through Python str() must be JSON scalars; container-valued metadata diverges without a runtime error and stays a #294-class door decision), and the dump.rs header's BYTE-EXACT claim carries the same qualifier. No implementation change: rejecting containers Rust-side would open a new door asymmetry (Python's dump accepts and reprs them), the opposite of what #294 closed. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015de4MezSeUnZBoWq1fFU5M --- rust/crates/own-bridge/src/dump.rs | 8 ++++++-- rust/crates/own-bridge/src/lib.rs | 24 ++++++++++++++++++------ 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/rust/crates/own-bridge/src/dump.rs b/rust/crates/own-bridge/src/dump.rs index 00a21f5a..3461426a 100644 --- a/rust/crates/own-bridge/src/dump.rs +++ b/rust/crates/own-bridge/src/dump.rs @@ -1,7 +1,9 @@ //! The MOS parity dump — `ownlang/ownir.py::dump_summaries`, serialized as //! the `python -m ownlang summaries` CLI prints it (spec/Inference.md §8). //! -//! The output contract is BYTE-EXACT stdout parity with the reference: +//! The output contract is BYTE-EXACT stdout parity with the reference over +//! the shared parity domain (scalar-metadata facts — the boundary is stated +//! on [`dump_summaries`] and in the public API doc): //! `json.dumps(doc, indent=2, sort_keys=True)` plus the newline `print` //! appends. That serialization keeps Python's `ensure_ascii` default (unlike //! the Layer 2 surface, which pins `ensure_ascii=False`), so this module @@ -28,7 +30,9 @@ use own_ir::{OwnIr, OWNIR_VERSION}; use serde_json::{json, Value}; /// Render the summaries document for one `OwnIR` facts document — -/// byte-identical to `python -m ownlang summaries` on the same facts. +/// byte-identical to `python -m ownlang summaries` for the shared parity +/// domain (scalar-metadata facts; the public doc on the crate-level +/// re-export states the same boundary). /// /// Scope note (the `py_str` caveat, same as the Layer 2 surface): the /// reference `str()`-ifies raw metadata scalars (`module`, and the diff --git a/rust/crates/own-bridge/src/lib.rs b/rust/crates/own-bridge/src/lib.rs index d359d7e3..31a358d3 100644 --- a/rust/crates/own-bridge/src/lib.rs +++ b/rust/crates/own-bridge/src/lib.rs @@ -13,8 +13,10 @@ //! to an [`own_lowered::LoweredDocument`] (or a [`BridgeError`] whose message //! text is part of the parity surface — Python projects it as the `Rejected` //! form), and [`dump_summaries`] renders the MOS summaries document -//! byte-identically to `python -m ownlang summaries` (the inference layer's -//! parity artifact, spec/Inference.md §8). No filesystem, no CLI, no +//! byte-identically to `python -m ownlang summaries` over the shared +//! scalar-metadata parity domain (the inference layer's parity artifact, +//! spec/Inference.md §8; see the function's contract for the domain +//! boundary). No filesystem, no CLI, no //! diagnostics, no analysis. `OwnIR` validation parity, MOS contract //! *changes*, and analysis wiring stay out of scope (#294 OD-2 landed: //! IR4-everywhere — `tolerant_unknown_kind` is a shared `rust_replay` case @@ -59,10 +61,20 @@ pub fn lower(facts: &OwnIr) -> Result { /// Render the MOS summaries document for one `OwnIR` facts document. /// -/// Byte-identical to `python -m ownlang summaries` on the same facts -/// (`json.dumps(doc, indent=2, sort_keys=True)` + the trailing newline). -/// A solver failure is not an error: it is the document's `degraded` branch, -/// exactly like the reference (INF-F6). +/// Byte-identical to `python -m ownlang summaries` (`json.dumps(doc, +/// indent=2, sort_keys=True)` + the trailing newline) **for the shared +/// parity domain**: facts whose metadata fields consumed through Python +/// `str()` — `module`, `functions[].name`/`file`, call `callee`/`sig` — are +/// JSON scalars. Every real producer emits scalars there, and the fixture +/// corpus pins only such documents. Container-valued metadata is OUTSIDE +/// this stage-1 contract: both doors accept it, but the reference renders +/// it as Python `repr` while this port renders JSON text, so the outputs +/// diverge without a runtime error. Door-wide type validation (or exact +/// container-repr parity) is a separate #294-class door decision — see the +/// scope note on the implementation for why neither is folded in here. +/// +/// A solver failure is not an error: it is the document's `degraded` +/// branch, exactly like the reference (INF-F6). /// /// # Errors /// [`BridgeError`] only if the typed facts cannot be re-serialized to JSON