From b02419dee354bc8831d9c66b5a04d8ee502d2166 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Mon, 12 Oct 2026 22:25:15 +0200 Subject: [PATCH] feat: a signal switched off switches its dependent math channels off too A derived channel cannot be computed from a signal nobody acquires: `Source` resolves to a *channel*, and a disabled signal has none -- so a channel that reads one silently computed nothing, which is what "all math signals depending on it do not compute anymore" looked like from outside. Rather than leave it enabled and blank, the channels that read the signal follow it off: `disable_dependent_math_channels()` walks the graphs to a **fixpoint**, because one math channel may name another, and the switch goes through the same path the pane's own toggle uses -- `MathChannel::enabled` then `apply_math_channels()`, the single writer -- so the pane's switches follow. Their traces leave the plots with the signal's, and the notice says both halves. The channels that do *not* read it are untouched, which is the assertion's other half: [enable-all-probe] switched 'sine' off: traces_left=0 (want 0) math_off=[math x2,math3] math_on=[math lp] notice=''sine' is off: 1 trace left the plots; math off: math x2 and 1 more' `math x2` and `math3` both source `sine`; `math lp` sources `noise` and stays on. The probe's switch step now runs late enough that a math probe armed behind it has installed its graphs, which is why the earlier reading was empty -- `app.math` had nothing in it yet, not "no dependants". Re-enabling the signal does not switch them back on, for the same reason it does not restore the traces: that is the user's decision, and the rule is written down in invariants-math-channels.md. --- docs/claude/invariants-math-channels.md | 9 +++++++ docs/claude/probe-readings.md | 9 +++++++ src/ui.cpp | 36 +++++++++++++++++++++++++ src/ui_app.hpp | 1 + src/ui_probe.cpp | 26 +++++++++++++++--- src/ui_signals.cpp | 20 ++++++++++---- 6 files changed, 92 insertions(+), 9 deletions(-) diff --git a/docs/claude/invariants-math-channels.md b/docs/claude/invariants-math-channels.md index 36510ae..f8f3faa 100644 --- a/docs/claude/invariants-math-channels.md +++ b/docs/claude/invariants-math-channels.md @@ -291,3 +291,12 @@ the assertion: `finite` must be about `M` minus the hole share, and `finite=0` b which is the "modifying one expression invalidates all the others" report. `[math-probe] graph edit: stored_slots N -> M` reads it: M must be at least N -- the store keeps filling while the edit runs -- and never 0. + +- **A math channel whose source is switched off is switched off with it.** `Source` resolves to a *channel*, so + a derived channel cannot be computed from a signal nobody acquires: left enabled it would silently compute + nothing, which is what "all math signals depending on it do not compute anymore" looked like from outside. + `on_signal_switch()` disables the channels that read that signal -- **transitively**, since one math channel + may name another -- through the same path the pane's own switch uses (`MathChannel::enabled` and + `apply_math_channels()`), and takes their traces off the plots along with the signal's. The channels that do + not read it are untouched: `[enable-all-probe]`'s `math_on=[…]` is the half that reads this. Re-enabling the + signal does **not** switch them back on, for the same reason re-enabling does not restore the traces. diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md index 4bda204..f962fc3 100644 --- a/docs/claude/probe-readings.md +++ b/docs/claude/probe-readings.md @@ -1256,6 +1256,15 @@ switches one off **through its own acquire tick in the signal list** -- the tick is the shape `signal_tick_visible()` walks -- and reads the plots back. `traces_left=0` is the rule: a disabled signal leaves every plot rather than staying as a slot that draws nothing, and the notice is what says so. +The switch step runs late, so that a math probe armed *behind* this one has installed its graphs by then; the +two halves after it are the other rule. `math_off` is the derived channels that read this signal -- they cannot +be computed from a signal nobody acquires, so they go off with it -- and `math_on` is the ones that do not, +which must be untouched: + +``` +[enable-all-probe] switched 'sine' off: traces_left=0 (want 0) math_off=[math x2,math3] math_on=[math lp] notice=''sine' is off: 1 trace left the plots; math off: math x2 and 1 more' +``` + ## `[math-probe] graph edit:` ``` diff --git a/src/ui.cpp b/src/ui.cpp index c2de9da..1dd7d63 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -2534,6 +2534,42 @@ void remove_trace(App& app, int row, int slot) { app.rows->assign(row, slot, std::string(), std::string(), 0); } +/// Disables every math channel that (transitively) reads `name`, and answers with their names. +/// +/// A derived channel cannot be computed from a signal nobody acquires: `Source` resolves to a *channel*, and a +/// disabled signal has none -- so a channel left enabled would silently compute nothing. The rule is that the +/// channels which depend on a signal follow it off, which is also what the pane's own switch would do by hand. +/// The dependency is transitive: one math channel may name another, so a channel reading a disabled one goes +/// too, and the loop runs to a fixpoint for that. +std::vector disable_dependent_math_channels(App& app, const std::string& name) { + std::vector off; + if (name.empty()) return off; + const auto math_index_of = [&app](const std::string& n) -> int { + for (std::size_t k = 0; k < app.math.size(); ++k) { + if (app.math[k].name == n) return static_cast(k); + } + return -1; + }; + for (bool moved = true; moved;) { + moved = false; + for (MathChannel& channel : app.math) { + if (!channel.enabled) continue; + for (const MathNode& node : channel.nodes) { + if (node.op != MathOp::Source) continue; + const int k = math_index_of(node.source); + const bool reads_it = node.source == name; + const bool reads_a_disabled_one = k >= 0 && !app.math[static_cast(k)].enabled; + if (!reads_it && !reads_a_disabled_one) continue; + channel.enabled = false; + off.push_back(channel.name); + moved = true; + break; + } + } + } + return off; +} + /// Takes a signal off every plot, and says how many traces went. /// /// Switching a signal off means "do not acquire this", so a trace of it left on a plot draws nothing: a slot diff --git a/src/ui_app.hpp b/src/ui_app.hpp index 9b4e0cd..a461af7 100644 --- a/src/ui_app.hpp +++ b/src/ui_app.hpp @@ -1251,6 +1251,7 @@ void on_fixed_range_apply(GtkButton*, gpointer user); void on_y_fixed_clicked(GtkButton* button, gpointer user); void remove_trace(App& app, int row, int slot); int remove_signal_from_plots(App& app, const std::string& name); +std::vector disable_dependent_math_channels(App& app, const std::string& name); void on_remove_clicked(GtkButton* button, gpointer user); GtkWidget* menu_icon_button(const char* icon, const char* label); void on_row_menu_show_remove(GtkButton*, gpointer user); diff --git a/src/ui_probe.cpp b/src/ui_probe.cpp index c7a9e57..8b12b8c 100644 --- a/src/ui_probe.cpp +++ b/src/ui_probe.cpp @@ -1873,8 +1873,9 @@ gboolean enable_all_probe(gpointer user) { a->model->channel_count()); std::fflush(stderr); // Then switch one off through its own switch in the signal list: a disabled signal must leave the plots, - // not sit there drawing nothing in a slot a live one could take. - g_timeout_add(250, +[](gpointer u) -> gboolean { + // not sit there drawing nothing in a slot a live one could take. Late enough that a math probe armed behind + // this one has had its graphs installed -- the dependants are the point of the second line. + g_timeout_add(2000, +[](gpointer u) -> gboolean { App* app = static_cast(u); if (app->model->channels().empty() || app->signal_list == nullptr) return G_SOURCE_REMOVE; const std::size_t signal = app->model->channels().front().signal; @@ -1907,8 +1908,25 @@ gboolean enable_all_probe(gpointer user) { if (app->rows->row(r).signal_name[static_cast(s)] == name) ++left; } } - std::fprintf(stderr, "[enable-all-probe] switched '%s' off: traces_left=%d (want 0) notice='%s'\n", - name.c_str(), left, app->notice.c_str()); + // Which *math* channels the disabled signal took with it: a derived channel cannot be computed from a + // signal nobody acquires, so the ones that read it follow it off -- and their traces go too. + std::string off_list; + for (const MathChannel& m : app->math) { + if (m.enabled) continue; + if (!off_list.empty()) off_list += ","; + off_list += m.name; + } + // Both halves: the channels that read it went off, and the ones that do not are still on. + std::string on_list; + for (const MathChannel& m : app->math) { + if (!m.enabled) continue; + if (!on_list.empty()) on_list += ","; + on_list += m.name; + } + std::fprintf(stderr, + "[enable-all-probe] switched '%s' off: traces_left=%d (want 0) math_off=[%s] " + "math_on=[%s] notice='%s'\n", + name.c_str(), left, off_list.c_str(), on_list.c_str(), app->notice.c_str()); std::fflush(stderr); return G_SOURCE_REMOVE; }, a); diff --git a/src/ui_signals.cpp b/src/ui_signals.cpp index a34dd4a..38f4730 100644 --- a/src/ui_signals.cpp +++ b/src/ui_signals.cpp @@ -27,13 +27,23 @@ void on_signal_switch(GObject* obj, GParamSpec*, gpointer user) { app->model->set_signal_enabled(index, on); if (!on) { // A trace of a signal nobody acquires draws nothing, and holds the slot a live one could take. - const int gone = remove_signal_from_plots(*app, name); - if (gone > 0) { + int gone = remove_signal_from_plots(*app, name); + // ... and the derived channels that read it cannot be computed at all, so they follow it off, and their + // traces leave with them. + const std::vector off = disable_dependent_math_channels(*app, name); + for (const std::string& channel : off) gone += remove_signal_from_plots(*app, channel); + if (!off.empty()) apply_math_channels(*app); // the single writer: the pane's switches follow it + if (gone > 0 || !off.empty()) { app->relayout_pending = true; rebuild_signal_panels(*app); // the chips of the signals that are still plotted - set_notice(*app, "'" + name + "' is off: " + std::to_string(gone) + - (gone == 1 ? " trace left the plots" : " traces left the plots"), - 4); + std::string what = "'" + name + "' is off: " + std::to_string(gone) + + (gone == 1 ? " trace left the plots" : " traces left the plots"); + if (!off.empty()) { + // The channel names already read as what they are ("math x2"), so the sentence does not prefix them. + what += "; math off: " + off.front() + + (off.size() == 1 ? "" : " and " + std::to_string(off.size() - 1) + " more"); + } + set_notice(*app, what, 5); } } // The enable state is part of the session (a signal can be on only as a math channel's source).