From ce1825aaaba13e92c81613f3c883c063e48b48a7 Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Thu, 16 Jul 2026 12:25:24 -0700 Subject: [PATCH] fix(lpc-engine): bus channel kind comes from the well-known registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A channel's kind is stamped by the first registered binding touching it, and binding_kind_for_slot guessed from the slot NAME with a Color fallback — only the time-family names were listed, so an unfilled `trigger` channel (readers only) showed as COLOR in the bus pane while the registry (and the picker) say Instant. binding_kind() now prefers the well-known registry kind for bus endpoints and keeps the slot-name heuristic only for unregistered channels. Also adds server-level regression tests for the M4 bind gesture (the exact studio mutation batch): authored bindings reach the binding graph live and post-commit, and an authored binding to the slot's own default channel suppresses the default and reports Authored. Makes the engine binding-graph probe and Project::runtime_read_parts public for host-level tests. Co-Authored-By: Claude Opus 4.8 --- lp-app/lpa-server/src/project.rs | 5 +- lp-app/lpa-server/tests/binding_apply_live.rs | 250 ++++++++++++++++++ .../lpc-engine/src/engine/project_loader.rs | 131 +++++++-- .../src/engine/project_read_probes.rs | 6 +- 4 files changed, 361 insertions(+), 31 deletions(-) create mode 100644 lp-app/lpa-server/tests/binding_apply_live.rs diff --git a/lp-app/lpa-server/src/project.rs b/lp-app/lpa-server/src/project.rs index bcca6bd15..68aaf179e 100644 --- a/lp-app/lpa-server/src/project.rs +++ b/lp-app/lpa-server/src/project.rs @@ -142,7 +142,10 @@ impl Project { &mut self.registry } - pub(crate) fn runtime_read_parts(&mut self) -> (&mut Engine, &ProjectRegistry) { + /// Split borrow for read paths that walk the engine against the + /// registry (probes need `&mut Engine` for the resolver while reading + /// registry state). Public so host-level tests can drive probes. + pub fn runtime_read_parts(&mut self) -> (&mut Engine, &ProjectRegistry) { let runtime = self .runtime .as_mut() diff --git a/lp-app/lpa-server/tests/binding_apply_live.rs b/lp-app/lpa-server/tests/binding_apply_live.rs new file mode 100644 index 000000000..cdfc45d11 --- /dev/null +++ b/lp-app/lpa-server/tests/binding_apply_live.rs @@ -0,0 +1,250 @@ +//! Authored bindings apply live (incremental binding apply, Option C): +//! an overlay mutation that adds a `bindings` map entry must reach the +//! binding graph probe without a commit — the M4 bind gesture's whole +//! contract ("bind clock delta_seconds → bus:delta-t, bus card appears +//! in a tick"). Regression coverage for the 2026-07-16 report that new +//! bindings never take effect, even after save. + +extern crate alloc; + +use alloc::boxed::Box; +use alloc::rc::Rc; +use alloc::string::ToString; +use alloc::sync::Arc; +use alloc::vec; +use core::cell::RefCell; + +use lp_gfx_lpvm::TargetLpvmGraphics; +use lpa_server::{LpGraphics, LpServer, Project}; +use lpc_model::{ + ArtifactLocation, AsLpPath, LpPathBuf, LpValue, MutationCmd, MutationCmdBatch, MutationCmdId, + MutationOp, SlotEdit, SlotPath, +}; +use lpc_shared::output::MemoryOutputProvider; +use lpc_wire::{ + BindingGraphProbeRequest, BindingGraphProbeResult, WireBindingEndpoint, WireBindingOrigin, + WireOverlayCommitRequest, WireOverlayMutationRequest, WireProjectHandle, +}; +use lpfs::LpFsMemory; + +#[test] +fn authored_binding_reaches_the_graph_without_commit() { + let (mut server, project_path) = server_with_clock_project("binding-live"); + let handle = server.load_project(project_path.as_path()).expect("load"); + server.advance_frame(16).expect("tick"); + + let project = project_mut(&mut server, handle); + project + .mutate_overlay(bind_delta_seconds_mutation()) + .expect("mutate overlay"); + + assert_delta_binding(project, "after overlay mutation (live apply)"); +} + +#[test] +fn authored_binding_survives_commit() { + let (mut server, project_path) = server_with_clock_project("binding-commit"); + let handle = server.load_project(project_path.as_path()).expect("load"); + server.advance_frame(16).expect("tick"); + + let project = project_mut(&mut server, handle); + project + .mutate_overlay(bind_delta_seconds_mutation()) + .expect("mutate overlay"); + project + .commit_overlay(WireOverlayCommitRequest) + .expect("commit overlay"); + + assert_delta_binding(project, "after commit (save)"); +} + +#[test] +fn authored_binding_to_the_default_channel_wins_over_the_default() { + // Clock `seconds` default-publishes bus:time. Authoring an explicit + // seconds → bus:time entry must suppress the default and report the + // binding as AUTHORED — otherwise the doc holds a real entry the UI + // can never see or unbind (2026-07-16 report: popup kept the default + // presentation and offered no Unbind). + let (mut server, project_path) = server_with_clock_project("binding-same-channel"); + let handle = server.load_project(project_path.as_path()).expect("load"); + server.advance_frame(16).expect("tick"); + + let project = project_mut(&mut server, handle); + project + .mutate_overlay(bind_mutation("seconds", "bus:time")) + .expect("mutate overlay"); + + let (engine, registry) = project.runtime_read_parts(); + let result = engine.read_project_binding_graph_probe( + registry, + BindingGraphProbeRequest { + include_values: false, + }, + ); + let BindingGraphProbeResult::Graph(graph) = result else { + panic!("expected graph result"); + }; + let time_writers: alloc::vec::Vec<_> = graph + .bindings + .iter() + .filter(|binding| { + matches!( + &binding.endpoint, + WireBindingEndpoint::Bus { channel } if channel == "time" + ) && binding.direction == lpc_wire::WireBindingDirection::Publishes + }) + .collect(); + assert_eq!( + time_writers.len(), + 1, + "exactly one seconds → time writer (authored suppresses the default): {time_writers:?}" + ); + assert_eq!( + time_writers[0].origin, + WireBindingOrigin::Authored, + "the surviving writer is the authored one" + ); +} + +/// The M4 bind gesture exactly as the studio dispatches it: ensure the +/// bindings entry, ensure the target endpoint option, set the bus ref. +fn bind_delta_seconds_mutation() -> WireOverlayMutationRequest { + bind_mutation("delta_seconds", "bus:delta-t") +} + +fn bind_mutation(slot: &str, bus_ref: &str) -> WireOverlayMutationRequest { + let artifact = ArtifactLocation::file("/clock.json"); + let endpoint = + SlotPath::parse(&alloc::format!("bindings[{slot}].target.some")).expect("endpoint path"); + WireOverlayMutationRequest::new(MutationCmdBatch::new(vec![ + MutationCmd { + id: MutationCmdId::new(1), + mutation: MutationOp::PutSlotEdit { + artifact: artifact.clone(), + edit: SlotEdit::ensure_present( + SlotPath::parse(&alloc::format!("bindings[{slot}]")).expect("entry path"), + ), + }, + }, + MutationCmd { + id: MutationCmdId::new(2), + mutation: MutationOp::PutSlotEdit { + artifact: artifact.clone(), + edit: SlotEdit::ensure_present(endpoint.clone()), + }, + }, + MutationCmd { + id: MutationCmdId::new(3), + mutation: MutationOp::PutSlotEdit { + artifact, + edit: SlotEdit::assign_value(endpoint, LpValue::String(bus_ref.to_string())), + }, + }, + ])) +} + +fn assert_delta_binding(project: &mut Project, when: &str) { + let (engine, registry) = project.runtime_read_parts(); + let result = engine.read_project_binding_graph_probe( + registry, + BindingGraphProbeRequest { + include_values: false, + }, + ); + let BindingGraphProbeResult::Graph(graph) = result else { + panic!("expected graph result {when}"); + }; + + let authored = graph + .bindings + .iter() + .find(|binding| { + matches!( + &binding.endpoint, + WireBindingEndpoint::Bus { channel } if channel == "delta-t" + ) + }) + .unwrap_or_else(|| { + panic!( + "no binding to bus:delta-t {when}; bindings: {:?}", + graph + .bindings + .iter() + .map(|binding| &binding.endpoint) + .collect::>() + ) + }); + assert_eq!( + authored.origin, + WireBindingOrigin::Authored, + "delta-t binding is authored {when}" + ); + assert!( + graph + .channels + .iter() + .any(|channel| channel.name == "delta-t"), + "delta-t channel appears in the graph {when}; channels: {:?}", + graph + .channels + .iter() + .map(|channel| &channel.name) + .collect::>() + ); +} + +fn project_mut(server: &mut LpServer, handle: WireProjectHandle) -> &mut Project { + server + .project_manager_mut() + .get_project_mut(handle) + .expect("loaded project") +} + +fn server_with_clock_project(name: &str) -> (LpServer, LpPathBuf) { + let output_provider = Rc::new(RefCell::new(MemoryOutputProvider::new())); + let graphics: Arc = + Arc::new(TargetLpvmGraphics::new(lpa_server::DEVICE_SHADER_FRONTEND)); + let mut server = LpServer::new( + output_provider, + Box::new(LpFsMemory::new()), + "projects".as_path(), + None, + None, + graphics, + ); + let project_path = LpPathBuf::from("/projects").join(name); + + server + .base_fs_mut() + .write_file( + project_path.join("project.json").as_path(), + br#" +{ + "kind": "Project", + "format": 1, + "nodes": { + "clock": { + "ref": "./clock.json" + } + } +} +"#, + ) + .expect("write project"); + server + .base_fs_mut() + .write_file( + project_path.join("clock.json").as_path(), + br#" +{ + "kind": "Clock", + "controls": { + "rate": 1.0 + } +} +"#, + ) + .expect("write clock"); + + (server, project_path) +} diff --git a/lp-core/lpc-engine/src/engine/project_loader.rs b/lp-core/lpc-engine/src/engine/project_loader.rs index 22d261534..c8ae09bed 100644 --- a/lp-core/lpc-engine/src/engine/project_loader.rs +++ b/lp-core/lpc-engine/src/engine/project_loader.rs @@ -13,7 +13,7 @@ use lpc_model::{ MappingConfig, NodeDef, NodeId, NodeName, PlaylistDef, ProjectNodeOrigin, ProjectNodePlacement, Revision, SlotPath, }; -use lpc_model::{SlotDirection, SlotPathSegment, SlotShape, StaticSlotShape}; +use lpc_model::{SlotDirection, SlotPathSegment, SlotShape, StaticSlotShape, well_known_channel}; use lpc_registry::{AssetText, ParseCtx, ProjectRegistry}; use lpc_wire::{NodeRuntimeStatus, WireChildKind, WireSlotIndex}; use lpfs::LpFs; @@ -1316,16 +1316,17 @@ fn register_node_bindings( .map(|target| binding_target_endpoint(projected_nodes, node, target)) .transpose()? { + let source = BindingSource::ProducedSlot { + node: node.id, + slot: playlist_output_path(), + }; runtime .add_binding( BindingDraft { - source: BindingSource::ProducedSlot { - node: node.id, - slot: playlist_output_path(), - }, + kind: binding_kind(&source, &target, "output"), + source, target, priority: BindingPriority::authored(), - kind: binding_kind_for_slot("output"), owner: node.id, }, frame, @@ -1444,28 +1445,32 @@ fn register_default_bind( { return Ok(()); } + let source = BindingSource::ProducedSlot { + node: current.id, + slot, + }; + let target = BindingTarget::BusChannel(channel); BindingDraft { - source: BindingSource::ProducedSlot { - node: current.id, - slot, - }, - target: BindingTarget::BusChannel(channel), + kind: binding_kind(&source, &target, name), + source, + target, priority: BindingPriority::default_fallback(), - kind: binding_kind_for_slot(name), owner: current.id, } } else { if binding_source(bindings, name).is_some() { return Ok(()); } + let source = BindingSource::BusChannel(channel); + let target = BindingTarget::ConsumedSlot { + node: current.id, + slot, + }; BindingDraft { - source: BindingSource::BusChannel(channel), - target: BindingTarget::ConsumedSlot { - node: current.id, - slot, - }, + kind: binding_kind(&source, &target, name), + source, + target, priority: BindingPriority::default_fallback(), - kind: binding_kind_for_slot(name), owner: current.id, } }; @@ -1563,14 +1568,15 @@ fn register_source_binding_at_path( target_slot: SlotPath, frame: Revision, ) -> Result<(), ProjectLoadError> { + let target = BindingTarget::ConsumedSlot { + node: current.id, + slot: target_slot, + }; let draft = BindingDraft { + kind: binding_kind(&source, &target, binding_slot_name), source, - target: BindingTarget::ConsumedSlot { - node: current.id, - slot: target_slot, - }, + target, priority: BindingPriority::new(0), - kind: binding_kind_for_slot(binding_slot_name), owner: current.id, }; assert_draft_directions(projected_nodes, current, &draft)?; @@ -1614,14 +1620,15 @@ fn register_target_binding( path: node_label(current), reason: format!("invalid source slot `{slot_name}`: {e}"), })?; + let source = BindingSource::ProducedSlot { + node: current.id, + slot: source_slot, + }; let draft = BindingDraft { - source: BindingSource::ProducedSlot { - node: current.id, - slot: source_slot, - }, + kind: binding_kind(&source, &target, slot_name), + source, target, priority: BindingPriority::authored(), - kind: binding_kind_for_slot(slot_name), owner: current.id, }; assert_draft_directions(projected_nodes, current, &draft)?; @@ -1634,7 +1641,22 @@ fn register_target_binding( Ok(()) } -fn binding_kind_for_slot(slot_name: &str) -> Kind { +/// The kind a binding establishes on the channels it touches. +/// +/// A bus endpoint's well-known registry kind is authoritative — the first +/// registered binding stamps the channel's kind, and the old slot-name +/// guess stamped e.g. `trigger` as Color because only the time-family +/// names were listed (2026-07-16: bus pane showed "trigger COLOR"). +/// Endpoints outside the registry fall back to the slot-name heuristic. +fn binding_kind(source: &BindingSource, target: &BindingTarget, slot_name: &str) -> Kind { + let channel = match (source, target) { + (BindingSource::BusChannel(channel), _) => Some(channel), + (_, BindingTarget::BusChannel(channel)) => Some(channel), + _ => None, + }; + if let Some(known) = channel.and_then(|channel| well_known_channel(&channel.0)) { + return known.kind; + } match slot_name { "time" | "seconds" | "delta_seconds" => Kind::Instant, _ => Kind::Color, @@ -1702,6 +1724,57 @@ fn binding_target_endpoint( } } +#[cfg(test)] +mod binding_kind_tests { + use lpc_model::{ChannelName, Kind, NodeId, SlotPath}; + + use super::binding_kind; + use crate::dataflow::binding::{BindingSource, BindingTarget}; + + fn consumed(slot: &str) -> BindingTarget { + BindingTarget::ConsumedSlot { + node: NodeId::new(1), + slot: SlotPath::parse(slot).expect("slot path"), + } + } + + fn produced(slot: &str) -> BindingSource { + BindingSource::ProducedSlot { + node: NodeId::new(1), + slot: SlotPath::parse(slot).expect("slot path"), + } + } + + #[test] + fn well_known_channel_kind_beats_the_slot_name_guess() { + // `trigger` is Instant in the registry; the old slot-name guess + // stamped it Color (only the time-family names were listed). + let source = BindingSource::BusChannel(ChannelName("trigger".into())); + assert_eq!( + binding_kind(&source, &consumed("trigger"), "trigger"), + Kind::Instant + ); + let target = BindingTarget::BusChannel(ChannelName("visual.out".into())); + assert_eq!( + binding_kind(&produced("output"), &target, "output"), + Kind::Color + ); + } + + #[test] + fn unregistered_channels_fall_back_to_the_slot_name_guess() { + let target = BindingTarget::BusChannel(ChannelName("wobble".into())); + assert_eq!( + binding_kind(&produced("seconds"), &target, "seconds"), + Kind::Instant + ); + assert_eq!( + binding_kind(&produced("output"), &target, "output"), + Kind::Color + ); + } +} + #[cfg(test)] mod tests { extern crate std; diff --git a/lp-core/lpc-engine/src/engine/project_read_probes.rs b/lp-core/lpc-engine/src/engine/project_read_probes.rs index 9d347e179..f0f77db1e 100644 --- a/lp-core/lpc-engine/src/engine/project_read_probes.rs +++ b/lp-core/lpc-engine/src/engine/project_read_probes.rs @@ -78,7 +78,11 @@ impl Engine { /// on implicit runtime consumed slots with no def field — plus every /// referenced channel with providers/consumers as indices into the /// binding list. See docs/adr/2026-07-06-binding-graph-probe.md. - pub(super) fn read_project_binding_graph_probe( + /// + /// Public (unlike the sibling probes) so host-level tests can assert + /// the effective graph directly — the binding index is load-time + /// materialized state with no other read surface. + pub fn read_project_binding_graph_probe( &mut self, registry: &ProjectRegistry, request: BindingGraphProbeRequest,