Skip to content

Commit 87e86b8

Browse files
avrabeclaude
andcommitted
feat(p3_stream): LS-R-11 layer-2 per-edge precise type-mismatch
Promotes the v0.13.0 stream-typed-import heuristic to a per-edge precise check for the common path. New `export_stream_elements` walks `comp.component_func_defs[export.index]` to resolve a Func export to its underlying type: - `Import` reuses the importer-side typeref walker - `Lift` looks up CanonicalEntry::Lift's type_index and walks the function signature directly - `InstanceExportAlias` is deferred → falls back to the layer-1 heuristic (LS-R-11 limits block updated) For each resolved import edge where both endpoints resolve to non- empty stream-element lists, multisets are compared directly; mismatches emit TypeMismatch keyed to the exact (producer_component=exporter, consumer_component=importer) pair with real element types from each side's signature. Layer-1 still fires for connections not precisely checked; the v0.13.0 heuristic remains the safety net. 4 new regression tests: - export_stream_elements_walks_lift_function_signature - export_stream_elements_returns_empty_for_alias_export - ls_r_11_per_edge_lift_export_mismatch_raises - per_edge_matching_lift_export_does_not_raise Tier-5 registration of provenance.rs split out to a follow-up PR because anthropics/claude-code-action requires .github/workflows/mythos-auto.yml to be byte-identical to main for PR runs (workflow self-reference would break the AI scan auth flow even though my edit doesn't touch its logic). Tests: 285 lib + 22 p3_stream tests all green. Clippy clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent c35f745 commit 87e86b8

3 files changed

Lines changed: 360 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,28 @@ All notable changes to this project will be documented in this file.
44

55
## [Unreleased]
66

7+
### Added
8+
9+
- **LS-R-11 layer-2 per-edge precise stream type-mismatch**
10+
(`meld-core/src/p3_stream.rs`). Promotes the v0.13.0 heuristic
11+
to a per-edge precise check for the common path. New
12+
`export_stream_elements` walks `comp.component_func_defs[export.index]`
13+
to resolve a `Func` export to its underlying type:
14+
`ComponentFuncDef::Import` reuses the importer-side typeref
15+
walker; `ComponentFuncDef::Lift` looks up the `CanonicalEntry::Lift`'s
16+
`type_index` and walks the function signature directly.
17+
`ComponentFuncDef::InstanceExportAlias` is deferred (would
18+
require chasing alias chains through nested instance types) and
19+
falls back to the layer-1 role-list heuristic. For each resolved
20+
import edge where both endpoints resolve to non-empty stream-
21+
element lists, the multisets are compared directly; mismatches
22+
emit `TypeMismatch` keyed to the exact
23+
`(producer_component=exporter, consumer_component=importer)`
24+
pair with the real element types from each side's signature.
25+
4 new regression tests pin the new behavior. Layer-1 only fires
26+
for connections not precisely checked, so the v0.13.0 heuristic
27+
stays in place as a safety net.
28+
729
## [0.14.0] - 2026-05-27
830

931
### Added

meld-core/src/p3_stream.rs

Lines changed: 313 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,11 @@
3131
//! `stream<u8>` and a `stream<s32>` between the same two components are
3232
//! two different streams. See ADR-3.
3333
34-
use crate::parser::{CanonicalEntry, ComponentTypeKind, ComponentValType, ParsedComponent};
34+
use crate::parser::{
35+
CanonicalEntry, ComponentFuncDef, ComponentTypeKind, ComponentValType, ParsedComponent,
36+
};
3537
use std::collections::HashMap;
38+
use wasmparser::ComponentExternalKind;
3639

3740
/// The element type carried by a `stream<T>`, parsed from the
3841
/// component-type descriptor the parser records.
@@ -491,24 +494,152 @@ pub fn pair_has_stream_typed_import(
491494
false
492495
}
493496

497+
/// Resolve a component-level `Func` export by name to the stream
498+
/// element types its signature carries.
499+
///
500+
/// Walks `comp.component_func_defs[export.index]` to find the source
501+
/// of the exported function:
502+
///
503+
/// - [`ComponentFuncDef::Import`] → look up `comp.imports[idx].ty`
504+
/// and reuse [`stream_elements_in_typeref`].
505+
/// - [`ComponentFuncDef::Lift`] → look up the `CanonicalEntry::Lift`
506+
/// entry, extract its `type_index`, and walk it as
507+
/// `ComponentTypeRef::Func(type_index)`.
508+
/// - [`ComponentFuncDef::InstanceExportAlias`] → returns empty for
509+
/// now; alias chains require chasing through nested instance
510+
/// types and are deferred. The common-case stream-bearing exports
511+
/// are Lift entries (component-defined functions) or Import
512+
/// re-exports (forwarding pattern), both covered above.
513+
///
514+
/// Returns an empty Vec for non-Func exports, unresolved indices,
515+
/// or alias-export chains — same "this edge carries no streams"
516+
/// signal as the importer-side walker.
517+
pub fn export_stream_elements(comp: &ParsedComponent, export_name: &str) -> Vec<StreamElement> {
518+
let Some(export) = comp.exports.iter().find(|e| e.name == export_name) else {
519+
return Vec::new();
520+
};
521+
if !matches!(export.kind, ComponentExternalKind::Func) {
522+
return Vec::new();
523+
}
524+
let Some(def) = comp.component_func_defs.get(export.index as usize) else {
525+
return Vec::new();
526+
};
527+
match def {
528+
ComponentFuncDef::Import(import_idx) => comp
529+
.imports
530+
.get(*import_idx)
531+
.map(|imp| stream_elements_in_typeref(comp, &imp.ty))
532+
.unwrap_or_default(),
533+
ComponentFuncDef::Lift(canon_idx) => {
534+
let Some(entry) = comp.canonical_functions.get(*canon_idx) else {
535+
return Vec::new();
536+
};
537+
let CanonicalEntry::Lift { type_index, .. } = entry else {
538+
return Vec::new();
539+
};
540+
stream_elements_in_typeref(comp, &wasmparser::ComponentTypeRef::Func(*type_index))
541+
}
542+
ComponentFuncDef::InstanceExportAlias(_) => {
543+
// Deferred — would require following the alias chain
544+
// through `comp.component_aliases[idx]` and then
545+
// resolving the alias's target instance type. Tracked
546+
// in LS-R-11's "limits" block.
547+
Vec::new()
548+
}
549+
}
550+
}
551+
494552
/// Run #142's static validation passes over a built [`StreamPairGraph`].
495553
///
496554
/// Returns an empty vec when no issues were found. Caller decides
497555
/// whether to surface as warnings or hard errors (the resolver hoists
498556
/// each issue into [`crate::error::Error::StreamValidation`]).
557+
///
558+
/// **Check (i) precision**: per-edge type comparison. For each
559+
/// resolved import where both sides carry stream<T> references,
560+
/// the importer-side and exporter-side element types are compared
561+
/// directly. Falls back to the role-list heuristic only when the
562+
/// exporter side is unresolvable (alias-chain exports, which
563+
/// haven't been threaded through `component_func_defs` walking
564+
/// yet). The previous v0.13.0 release shipped only the heuristic;
565+
/// v0.15.0 promotes most fusion connections to the precise check.
499566
pub fn validate_stream_pair_graph(
500567
components: &[ParsedComponent],
501568
resolved_imports: &HashMap<(usize, String), (usize, String)>,
502569
graph: &StreamPairGraph,
503570
) -> Vec<StreamValidationIssue> {
504571
let roles: Vec<Vec<(StreamElement, StreamRole)>> =
505572
components.iter().map(component_stream_roles).collect();
506-
let connections = fusion_connections(resolved_imports);
507573

508574
let mut issues = Vec::new();
575+
let mut precise_pairs: std::collections::HashSet<(usize, usize)> =
576+
std::collections::HashSet::new();
577+
578+
// Check (i), layer 2 (per-edge precise): walk every resolved
579+
// import. If the importer's import is stream-typed AND the
580+
// exporter's matching export is stream-typed AND the element-
581+
// type multisets differ, emit a TypeMismatch keyed to the
582+
// exact (importer, exporter) component pair. Once a pair has
583+
// been precisely checked (whether or not it raised), suppress
584+
// the layer-1 heuristic for that pair to avoid double-counting.
585+
for ((importer, import_name), (exporter, export_name)) in resolved_imports {
586+
if importer == exporter {
587+
continue;
588+
}
589+
let Some(importer_comp) = components.get(*importer) else {
590+
continue;
591+
};
592+
let Some(import) = importer_comp
593+
.imports
594+
.iter()
595+
.find(|i| &i.name == import_name)
596+
else {
597+
continue;
598+
};
599+
let imp_elems = stream_elements_in_typeref(importer_comp, &import.ty);
600+
if imp_elems.is_empty() {
601+
continue;
602+
}
603+
let Some(exporter_comp) = components.get(*exporter) else {
604+
continue;
605+
};
606+
let exp_elems = export_stream_elements(exporter_comp, export_name);
607+
if exp_elems.is_empty() {
608+
// Exporter side unresolved (alias chain, missing
609+
// export, etc.). Leave to the layer-1 heuristic.
610+
continue;
611+
}
612+
precise_pairs.insert((*importer, *exporter));
613+
614+
// Compare as multisets. Sort + equality is fine for the
615+
// small list sizes typical of stream-bearing function
616+
// signatures (usually 1 stream per signature).
617+
let mut imp_sorted: Vec<_> = imp_elems.iter().collect();
618+
let mut exp_sorted: Vec<_> = exp_elems.iter().collect();
619+
imp_sorted.sort_by_key(|e| format!("{e:?}"));
620+
exp_sorted.sort_by_key(|e| format!("{e:?}"));
621+
if imp_sorted != exp_sorted {
622+
let issue = StreamValidationIssue::TypeMismatch {
623+
producer_component: *exporter,
624+
consumer_component: *importer,
625+
producer_types: exp_elems.clone(),
626+
consumer_types: imp_elems.clone(),
627+
};
628+
if !issues.contains(&issue) {
629+
issues.push(issue);
630+
}
631+
}
632+
}
509633

510-
// Check (i): type-mismatch, filtered by stream-typed-import presence.
634+
// Check (i), layer 1 (role-list heuristic, filtered): only fire
635+
// on fusion connections that DID NOT get a precise check above.
636+
// The filter still gates by stream-typed-import presence so
637+
// sync-only connections with unrelated streams stay silent.
638+
let connections = fusion_connections(resolved_imports);
511639
for &(a, b) in &connections {
640+
if precise_pairs.contains(&(a, b)) || precise_pairs.contains(&(b, a)) {
641+
continue;
642+
}
512643
if !pair_has_stream_typed_import(components, resolved_imports, a, b) {
513644
continue;
514645
}
@@ -988,6 +1119,16 @@ mod tests {
9881119
exports: Vec<ComponentExport>,
9891120
types: Vec<ComponentType>,
9901121
canonical_functions: Vec<CanonicalEntry>,
1122+
) -> ParsedComponent {
1123+
make_component_with_func_defs(imports, exports, types, canonical_functions, vec![])
1124+
}
1125+
1126+
fn make_component_with_func_defs(
1127+
imports: Vec<ComponentImport>,
1128+
exports: Vec<ComponentExport>,
1129+
types: Vec<ComponentType>,
1130+
canonical_functions: Vec<CanonicalEntry>,
1131+
component_func_defs: Vec<ComponentFuncDef>,
9911132
) -> ParsedComponent {
9921133
ParsedComponent {
9931134
name: None,
@@ -1001,7 +1142,7 @@ mod tests {
10011142
component_aliases: vec![],
10021143
component_instances: vec![],
10031144
core_entity_order: vec![],
1004-
component_func_defs: vec![],
1145+
component_func_defs,
10051146
component_instance_defs: vec![],
10061147
component_type_defs: vec![],
10071148
original_size: 0,
@@ -1235,4 +1376,172 @@ mod tests {
12351376
"sync function must not surface stream elements; got {elems:?}"
12361377
);
12371378
}
1379+
1380+
// ─── LS-R-11 layer-2 (precise per-edge) tests ─────────────────────
1381+
1382+
/// Direct unit test of [`export_stream_elements`] for the
1383+
/// `ComponentFuncDef::Lift` resolution path. A component that
1384+
/// exports a `canon lift` of a function taking `stream<U8>` must
1385+
/// surface that stream element type via the export-side walker —
1386+
/// this is the path the layer-2 precise check relies on.
1387+
#[test]
1388+
fn export_stream_elements_walks_lift_function_signature() {
1389+
let comp = make_component_with_func_defs(
1390+
vec![],
1391+
vec![ComponentExport {
1392+
name: "produce".into(),
1393+
kind: ComponentExternalKind::Func,
1394+
index: 0,
1395+
}],
1396+
vec![
1397+
stream_type("U8"), // types[0]: stream<U8>
1398+
func_type_taking_stream(0), // types[1]: fn(stream<U8>)
1399+
],
1400+
vec![CanonicalEntry::Lift {
1401+
core_func_index: 0,
1402+
type_index: 1,
1403+
options: options(),
1404+
}],
1405+
// component_func_defs[0] = Lift(0) — the exported function
1406+
// came from canonical_functions[0].
1407+
vec![ComponentFuncDef::Lift(0)],
1408+
);
1409+
let elems = export_stream_elements(&comp, "produce");
1410+
assert_eq!(elems, vec![typed("U8")]);
1411+
}
1412+
1413+
/// `export_stream_elements` must return empty for an export
1414+
/// resolved through an alias chain (`InstanceExportAlias`) —
1415+
/// that path is deferred per the LS-R-11 limits documentation,
1416+
/// and the precise check falls back to the layer-1 heuristic
1417+
/// for those edges.
1418+
#[test]
1419+
fn export_stream_elements_returns_empty_for_alias_export() {
1420+
let comp = make_component_with_func_defs(
1421+
vec![],
1422+
vec![ComponentExport {
1423+
name: "aliased".into(),
1424+
kind: ComponentExternalKind::Func,
1425+
index: 0,
1426+
}],
1427+
vec![],
1428+
vec![],
1429+
// component_func_defs[0] = InstanceExportAlias(0). The
1430+
// alias index doesn't matter for this test — the walker
1431+
// returns empty without inspecting it.
1432+
vec![ComponentFuncDef::InstanceExportAlias(0)],
1433+
);
1434+
let elems = export_stream_elements(&comp, "aliased");
1435+
assert!(
1436+
elems.is_empty(),
1437+
"alias-export path should return empty until alias chains are walked; got {elems:?}"
1438+
);
1439+
}
1440+
1441+
/// LS-R-11 layer-2 regression: when the importer's
1442+
/// `stream<u8>` import is resolved to an exporter's Lift-defined
1443+
/// `stream<s32>` export, the per-edge precise check MUST raise
1444+
/// a TypeMismatch with the actual element types — not just
1445+
/// "no element type pairs".
1446+
#[test]
1447+
fn ls_r_11_per_edge_lift_export_mismatch_raises() {
1448+
let comp_a = make_component_with_func_defs(
1449+
vec![ComponentImport {
1450+
name: "data".into(),
1451+
ty: wasmparser::ComponentTypeRef::Func(1),
1452+
}],
1453+
vec![],
1454+
vec![stream_type("U8"), func_type_taking_stream(0)],
1455+
vec![],
1456+
vec![ComponentFuncDef::Import(0)],
1457+
);
1458+
let comp_b = make_component_with_func_defs(
1459+
vec![],
1460+
vec![ComponentExport {
1461+
name: "data".into(),
1462+
kind: ComponentExternalKind::Func,
1463+
index: 0,
1464+
}],
1465+
vec![
1466+
stream_type("S32"), // types[0]: stream<S32> ≠ A's stream<U8>
1467+
func_type_taking_stream(0), // types[1]: fn(stream<S32>)
1468+
],
1469+
vec![CanonicalEntry::Lift {
1470+
core_func_index: 0,
1471+
type_index: 1,
1472+
options: options(),
1473+
}],
1474+
vec![ComponentFuncDef::Lift(0)],
1475+
);
1476+
let components = vec![comp_a, comp_b];
1477+
let mut resolved: HashMap<(usize, String), (usize, String)> = HashMap::new();
1478+
resolved.insert((0, "data".into()), (1, "data".into()));
1479+
let graph = StreamPairGraph::default();
1480+
1481+
let issues = validate_stream_pair_graph(&components, &resolved, &graph);
1482+
let mismatch = issues.iter().find_map(|i| match i {
1483+
StreamValidationIssue::TypeMismatch {
1484+
producer_component,
1485+
consumer_component,
1486+
producer_types,
1487+
consumer_types,
1488+
} => Some((
1489+
*producer_component,
1490+
*consumer_component,
1491+
producer_types.clone(),
1492+
consumer_types.clone(),
1493+
)),
1494+
_ => None,
1495+
});
1496+
let mismatch = mismatch.expect("expected exactly one TypeMismatch");
1497+
// Per-edge: producer is the exporter (comp 1), consumer is
1498+
// the importer (comp 0). Types are the precise element types
1499+
// from each side's signature, not role-list multisets.
1500+
assert_eq!(mismatch.0, 1, "producer_component should be the exporter");
1501+
assert_eq!(mismatch.1, 0, "consumer_component should be the importer");
1502+
assert_eq!(mismatch.2, vec![typed("S32")]);
1503+
assert_eq!(mismatch.3, vec![typed("U8")]);
1504+
}
1505+
1506+
/// LS-R-11 layer-2: matching stream types on a resolved edge
1507+
/// must NOT raise (precise check path, not the heuristic). Pins
1508+
/// the no-false-positive property of the precise check.
1509+
#[test]
1510+
fn per_edge_matching_lift_export_does_not_raise() {
1511+
let comp_a = make_component_with_func_defs(
1512+
vec![ComponentImport {
1513+
name: "data".into(),
1514+
ty: wasmparser::ComponentTypeRef::Func(1),
1515+
}],
1516+
vec![],
1517+
vec![stream_type("U8"), func_type_taking_stream(0)],
1518+
vec![],
1519+
vec![ComponentFuncDef::Import(0)],
1520+
);
1521+
let comp_b = make_component_with_func_defs(
1522+
vec![],
1523+
vec![ComponentExport {
1524+
name: "data".into(),
1525+
kind: ComponentExternalKind::Func,
1526+
index: 0,
1527+
}],
1528+
vec![stream_type("U8"), func_type_taking_stream(0)],
1529+
vec![CanonicalEntry::Lift {
1530+
core_func_index: 0,
1531+
type_index: 1,
1532+
options: options(),
1533+
}],
1534+
vec![ComponentFuncDef::Lift(0)],
1535+
);
1536+
let components = vec![comp_a, comp_b];
1537+
let mut resolved: HashMap<(usize, String), (usize, String)> = HashMap::new();
1538+
resolved.insert((0, "data".into()), (1, "data".into()));
1539+
let graph = StreamPairGraph::default();
1540+
1541+
let issues = validate_stream_pair_graph(&components, &resolved, &graph);
1542+
assert!(
1543+
issues.is_empty(),
1544+
"matching stream types on resolved edge must not raise; got {issues:?}"
1545+
);
1546+
}
12381547
}

0 commit comments

Comments
 (0)