From 5782ef963f1754dad13e1ba9211e05c93dbfbf2d Mon Sep 17 00:00:00 2001 From: Aidan Rickert Date: Mon, 14 Sep 2026 03:18:35 -0700 Subject: [PATCH 01/22] daq: extract PT role to Elodin table resolution into fsw::config::SensorTables --- daq-server/diablo_server/lib/CMakeLists.txt | 12 ++ .../lib/include/config/SensorTables.hpp | 78 +++++++ .../lib/src/config/SensorTables.cpp | 64 ++++++ .../lib/test/test_sensor_tables.cpp | 193 ++++++++++++++++++ .../config_broadcast_service_main.cpp | 122 ++++++----- 5 files changed, 404 insertions(+), 65 deletions(-) create mode 100644 daq-server/diablo_server/lib/include/config/SensorTables.hpp create mode 100644 daq-server/diablo_server/lib/src/config/SensorTables.cpp create mode 100644 daq-server/diablo_server/lib/test/test_sensor_tables.cpp diff --git a/daq-server/diablo_server/lib/CMakeLists.txt b/daq-server/diablo_server/lib/CMakeLists.txt index be509e14..b5db19ae 100644 --- a/daq-server/diablo_server/lib/CMakeLists.txt +++ b/daq-server/diablo_server/lib/CMakeLists.txt @@ -9,6 +9,7 @@ set(FSW_SOURCES src/config/SensorAssignment.cpp src/config/LoadActiveBoards.cpp src/config/Config.cpp + src/config/SensorTables.cpp src/routing/HeartbeatRouter.cpp src/routing/SensorRouter.cpp src/time/BoardClockSync.cpp @@ -104,6 +105,17 @@ target_link_libraries(test_fire_lifecycle fsw_daq_lib daq_comms_lib daqv2_comms Threads::Threads pthread ${RT_LIBRARY}) add_test(NAME fire_lifecycle COMMAND test_fire_lifecycle) +add_executable(test_sensor_tables + test/test_sensor_tables.cpp +) +target_include_directories(test_sensor_tables PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/include + ${CMAKE_CURRENT_SOURCE_DIR}/../transport/include +) +target_link_libraries(test_sensor_tables + fsw_daq_lib daq_comms_lib daqv2_comms Threads::Threads pthread ${RT_LIBRARY}) +add_test(NAME sensor_tables COMMAND test_sensor_tables) + add_executable(test_actuator_delays test/test_actuator_delays.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../services/sequencer/ActuatorCommander.cpp diff --git a/daq-server/diablo_server/lib/include/config/SensorTables.hpp b/daq-server/diablo_server/lib/include/config/SensorTables.hpp new file mode 100644 index 00000000..c61b41db --- /dev/null +++ b/daq-server/diablo_server/lib/include/config/SensorTables.hpp @@ -0,0 +1,78 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "config/Config.hpp" + +namespace fsw { +namespace config { + +/** + * Where a named PT sensor role lives: which board, which channel, and which Elodin table its + * calibrated pressure is published on. + * + * This walk — a name -> [sensor_roles_] -> board -> table id — used to exist inline in + * exactly one place (config_broadcast_service_main.cpp) and nowhere else, so every other consumer + * that wanted a live pressure by name reached for a hardcoded channel number instead. That is how + * ControllerService ended up assigning `P_copv` from channel 6, which is "GN2 Regulated" on this + * rig and not the COPV at all, and how its subscriber filter came to drop every sensor on PT board + * 2 — "GN2 High" included. Resolving by role through config makes both unrepresentable. + */ +struct PtRoleRef { + /** The board declaring this role. Points into the Config that was passed in, so it is valid + * exactly as long as that Config is. Never null on a successful lookup. */ + const BoardConfig* board = nullptr; + /** The board's section name with the "boards." prefix stripped, e.g. "pt_board_2". This is the + * suffix every per-board section is keyed by: sensor_roles_, calibration_model_, + * calibration_full_scale_. Resolved here so callers do not each re-derive it. */ + std::string board_key; + /** Canonical role name as config spells it, e.g. "GN2 High" — not a slug. */ + std::string role; + /** Connector channel on that board, 1-based, straight from [sensor_roles_]. */ + int channel = 0; + /** Elodin slot, BoardConfig::slot() — board_id % 10, with 0 meaning 10. */ + uint8_t board_number = 0; + + /** + * The calibrated-PT table this sensor publishes on: {0x20, (slot-1)*0x20 + 0x10 + channel}. + * + * The encoding is DatabaseConfig.cpp's, which is what actually registers the VTables — not + * [routing.pt_calibrated]'s description string, which claims board 2 lives at 0x1B-0x1E, has + * no consumer anywhere in the tree, and is wrong. + */ + uint8_t table_hi = 0x20; + uint8_t table_lo = 0; + + std::pair table() const { + return {table_hi, table_lo}; + } +}; + +/** + * Every PT sensor role declared by an enabled PT board, in board-declaration order. + * + * Boards that are disabled, not of type "PT", or carry no usable board_id are skipped, matching + * what the config-broadcast abort-threshold path has always done. A role declared on more than one + * board appears once per board; callers wanting a single answer should use find_pt_role. + */ +std::vector pt_role_tables(const Config& cfg); + +/** + * Resolve one role by its canonical config name. + * + * First enabled PT board that declares the name wins, in config order — the same "not on this + * board, keep looking" rule the inline walk used. Returns nullopt when no enabled PT board + * declares it, which callers must treat as a refusal rather than a default: a sensor that cannot + * be located has no safe stand-in value. + */ +std::optional find_pt_role(const Config& cfg, const std::string& role); + +/** The board's section name minus a leading "boards.", e.g. "boards.pt_board_2" -> "pt_board_2". */ +std::string board_key_of(const BoardConfig& b); + +} // namespace config +} // namespace fsw diff --git a/daq-server/diablo_server/lib/src/config/SensorTables.cpp b/daq-server/diablo_server/lib/src/config/SensorTables.cpp new file mode 100644 index 00000000..84ee6b69 --- /dev/null +++ b/daq-server/diablo_server/lib/src/config/SensorTables.cpp @@ -0,0 +1,64 @@ +#include "config/SensorTables.hpp" + +namespace fsw { +namespace config { + +std::string board_key_of(const BoardConfig& b) { + return b.section.rfind("boards.", 0) == 0 ? b.section.substr(7) : b.section; +} + +namespace { + +/** Fill in the derived half of a ref once board/role/channel are known. */ +PtRoleRef makeRef(const BoardConfig& b, std::string board_key, std::string role, int channel) { + PtRoleRef r; + r.board = &b; + r.board_key = std::move(board_key); + r.role = std::move(role); + r.channel = channel; + r.board_number = b.slot(); + r.table_hi = 0x20; + r.table_lo = static_cast((r.board_number - 1) * 0x20 + 0x10 + channel); + return r; +} + +/** The boards a PT role can live on. Mirrors the abort-threshold walk's filter exactly. */ +bool usablePtBoard(const BoardConfig& b) { + return b.type == "PT" && b.enabled && b.board_id > 0; +} + +} // namespace + +std::vector pt_role_tables(const Config& cfg) { + std::vector out; + for (const auto& b : cfg.boards) { + if (!usablePtBoard(b)) + continue; + const std::string board_key = board_key_of(b); + const auto* roles = cfg.sensor_roles_for("sensor_roles_" + board_key); + if (roles == nullptr) + continue; + for (const auto& [role, channel] : *roles) + out.push_back(makeRef(b, board_key, role, channel)); + } + return out; +} + +std::optional find_pt_role(const Config& cfg, const std::string& role) { + for (const auto& b : cfg.boards) { + if (!usablePtBoard(b)) + continue; + const std::string board_key = board_key_of(b); + const auto* roles = cfg.sensor_roles_for("sensor_roles_" + board_key); + if (roles == nullptr) + continue; + auto it = roles->find(role); + if (it == roles->end()) + continue; // not on this board — keep looking + return makeRef(b, board_key, role, it->second); + } + return std::nullopt; +} + +} // namespace config +} // namespace fsw diff --git a/daq-server/diablo_server/lib/test/test_sensor_tables.cpp b/daq-server/diablo_server/lib/test/test_sensor_tables.cpp new file mode 100644 index 00000000..e66ebe0b --- /dev/null +++ b/daq-server/diablo_server/lib/test/test_sensor_tables.cpp @@ -0,0 +1,193 @@ +// Pins fsw::config::find_pt_role / pt_role_tables — the role -> board -> channel -> Elodin table +// walk that was lifted out of config_broadcast_service_main.cpp so the sequencer's pressure +// subscriber can resolve a sensor by name instead of hardcoding a channel number. +// +// The two cases that matter most are the ones the old hardcoding got wrong on this very rig: +// +// "GN2 Regulated" board_id 21 -> slot 1 -> ch 6 -> {0x20, 0x16} +// "GN2 High" board_id 22 -> slot 2 -> ch 4 -> {0x20, 0x34} +// +// ControllerService assigns its `P_copv` from channel 6, which is GN2 Regulated — the regulated +// downstream pressure, not the COPV — and its subscriber filter (pid_lo < 0x11 || pid_lo > 0x1A) +// drops every board-2 sensor, GN2 High included. Both are silent. A test that computes 0x34 from +// config is what keeps a second consumer from inheriting either. +// +// Pure: no sockets, no files, no clock. Config comes in as a string. + +#include +#include + +#include "config/Config.hpp" +#include "config/SensorTables.hpp" + +static int g_failures = 0; + +static void check(bool ok, const std::string& what) { + std::cout << (ok ? " ok " : " FAIL ") << what << std::endl; + if (!ok) + g_failures++; +} + +// Two PT boards laid out as the server profile lays them out, plus a disabled third and an +// ACTUATOR board that must never be searched for sensor roles. +// +// pt_board_z is declared FIRST in this text but sorts last, and it re-declares "GN2 High" on a +// different channel. Which one find_pt_role returns is therefore a statement about cfg.boards +// ordering — see the duplicate-role case below. +static const char* kConfig = R"TOML( +[boards.pt_board_z] +type = "PT" +ip = "192.168.2.99" +board_id = 29 +enabled = true + +[boards.pt_board] +type = "PT" +ip = "192.168.2.21" +board_id = 21 +enabled = true + +[boards.pt_board_2] +type = "PT" +ip = "192.168.2.22" +board_id = 22 +enabled = true + +[boards.pt_board_off] +type = "PT" +ip = "192.168.2.28" +board_id = 28 +enabled = false + +[boards.pt_board_slot10] +type = "PT" +ip = "192.168.2.30" +board_id = 30 +enabled = true + +[boards.actuator_board] +type = "ACTUATOR" +ip = "192.168.2.12" +board_id = 12 +enabled = true + +[sensor_roles_pt_board_z] +"GN2 High" = 9 + +[sensor_roles_pt_board] +"Fuel Upstream" = 1 +"Ox Upstream" = 5 +"GN2 Regulated" = 6 + +[sensor_roles_pt_board_2] +"GSE High" = 1 +"GN2 High" = 4 + +[sensor_roles_pt_board_off] +"Ghost Sensor" = 3 + +[sensor_roles_pt_board_slot10] +"Slot Ten Sensor" = 2 + +[sensor_roles_actuator_board] +"Not A Sensor" = 1 +)TOML"; + +int main() { + const fsw::config::Config cfg = fsw::config::load_from_string(kConfig); + check(cfg.boards.size() == 6, "fixture parsed six boards"); + + // ── The two traps, computed from config ─────────────────────────────────────────────────── + { + const auto r = fsw::config::find_pt_role(cfg, "GN2 Regulated"); + check(r.has_value(), "GN2 Regulated resolves"); + if (r) { + check(r->board_key == "pt_board", "GN2 Regulated is on pt_board"); + check(r->channel == 6, "GN2 Regulated is channel 6"); + check(r->board_number == 1, "board_id 21 -> slot 1"); + check(r->table() == std::make_pair(0x20, 0x16), + "GN2 Regulated table is {0x20, 0x16}"); + } + } + { + const auto r = fsw::config::find_pt_role(cfg, "GN2 High"); + check(r.has_value(), "GN2 High resolves"); + if (r) { + check(r->board_number == 2, "board_id 22 -> slot 2"); + check(r->channel == 4, "GN2 High is channel 4"); + // The whole point: 0x34 is outside the 0x11..0x1A window ControllerService filters on, + // so a consumer that copies that filter drops the COPV sensor entirely. + check( + r->table() == std::make_pair(0x20, 0x34), + "GN2 High table is {0x20, 0x34} — board 2, NOT in ControllerService's 0x11..0x1A"); + check(r->board != nullptr && r->board->ip == "192.168.2.22", + "ref carries the owning board"); + } + } + + // ── slot() wraparound: board_id 30 -> slot 10, not slot 0 ───────────────────────────────── + { + const auto r = fsw::config::find_pt_role(cfg, "Slot Ten Sensor"); + check(r.has_value(), "Slot Ten Sensor resolves"); + if (r) { + check(r->board_number == 10, "board_id 30 -> slot 10 (not 0)"); + // (10-1)*0x20 + 0x10 + 2 = 0x120 + 0x12 = 0x132, truncated to uint8_t = 0x32. + // Pinned as the encoding's actual behaviour at slot 10 rather than asserted correct: + // a slot-10 PT board would collide with slot 2's channel 2. Worth knowing. + check(r->table_lo == static_cast((10 - 1) * 0x20 + 0x10 + 2), + "slot 10 table_lo follows the documented encoding"); + } + } + + // ── Boards that must not contribute roles ──────────────────────────────────────────────── + check(!fsw::config::find_pt_role(cfg, "Ghost Sensor").has_value(), + "a disabled board's roles do not resolve"); + check(!fsw::config::find_pt_role(cfg, "Not A Sensor").has_value(), + "a non-PT board's roles do not resolve"); + check(!fsw::config::find_pt_role(cfg, "Nonexistent").has_value(), + "an unknown role resolves to nullopt, not a default"); + + // ── Duplicate role: which board wins, and is it stable? ────────────────────────────────── + // + // pt_board_z declares "GN2 High" on channel 9 and is written FIRST in the TOML text, but + // [boards] is parsed by iterating a toml++ table, which is key-sorted. So cfg.boards order is + // alphabetical by section key and "pt_board_2" precedes "pt_board_z". This asserts the rule + // that actually holds, so a future change to either ordering is caught here rather than by a + // sensor silently reading the wrong board. + { + const auto r = fsw::config::find_pt_role(cfg, "GN2 High"); + check(r.has_value() && r->board_key == "pt_board_2", + "duplicate role resolves by cfg.boards order (key-sorted), not TOML text order"); + } + + // ── pt_role_tables: every role on every enabled PT board, and nothing else ─────────────── + { + const auto all = fsw::config::pt_role_tables(cfg); + // 1 (z) + 3 (pt_board) + 2 (pt_board_2) + 1 (slot10) = 7; the disabled and ACTUATOR + // boards contribute nothing. + check(all.size() == 7, "pt_role_tables lists every enabled PT board's roles and no others"); + bool saw_ghost = false, saw_actuator_role = false; + for (const auto& r : all) { + if (r.role == "Ghost Sensor") + saw_ghost = true; + if (r.role == "Not A Sensor") + saw_actuator_role = true; + } + check(!saw_ghost, "pt_role_tables omits a disabled board"); + check(!saw_actuator_role, "pt_role_tables omits a non-PT board"); + } + + // ── board_key_of strips the prefix, and tolerates a section without one ────────────────── + { + fsw::config::BoardConfig b; + b.section = "boards.pt_board_2"; + check(fsw::config::board_key_of(b) == "pt_board_2", "board_key_of strips \"boards.\""); + b.section = "pt_board_2"; + check(fsw::config::board_key_of(b) == "pt_board_2", + "board_key_of passes a bare key through"); + } + + std::cout << (g_failures == 0 ? "\nAll sensor-table checks passed.\n" + : "\nFAILURES: " + std::to_string(g_failures) + "\n"); + return g_failures == 0 ? 0 : 1; +} diff --git a/daq-server/diablo_server/services/config_broadcast/config_broadcast_service_main.cpp b/daq-server/diablo_server/services/config_broadcast/config_broadcast_service_main.cpp index 43b58974..6a8edb1d 100644 --- a/daq-server/diablo_server/services/config_broadcast/config_broadcast_service_main.cpp +++ b/daq-server/diablo_server/services/config_broadcast/config_broadcast_service_main.cpp @@ -37,6 +37,7 @@ static inline void store_u32(uint8_t* dst, uint32_t v) { #include #include "config/Config.hpp" +#include "config/SensorTables.hpp" #include "net/DaqInterface.hpp" namespace { @@ -261,75 +262,66 @@ std::vector buildPackets(const std::string& config_path) { std::set abort_warnings; for (const auto& [sensor_name, threshold_psi] : abort_pts) { const std::string tag = "[ConfigBroadcast] abort_pts \"" + sensor_name + "\": "; - bool resolved_role = false; - for (const auto& b : cfg.boards) { - if (b.type != "PT" || !b.enabled || b.board_id <= 0) - continue; - const std::string board_key = - b.section.rfind("boards.", 0) == 0 ? b.section.substr(7) : b.section; - const auto* roles = cfg.sensor_roles_for("sensor_roles_" + board_key); - if (roles == nullptr) + // The role -> board -> channel walk lives in fsw::config::find_pt_role now, so the + // sequencer's pressure subscriber resolves a sensor by name the same way this does rather + // than growing its own copy with its own off-by-one. + const auto ref = fsw::config::find_pt_role(cfg, sensor_name); + if (!ref) { + abort_warnings.insert(tag + "no PT sensor_role declares this name — NO board trip"); + continue; + } + const auto& b = *ref->board; + const std::string& board_key = ref->board_key; + const int channel = ref->channel; + if (channel < 1 || channel > 255) { + abort_warnings.insert(tag + "channel out of range — no board abort threshold"); + continue; + } + const uint16_t uid = static_cast(b.board_id * 100 + channel); + // Prefer the calibration service's model-correct threshold, as long as the PSI it was + // computed for still matches the live config (it re-emits on capture/clear/reload, not + // on a bare abort_pts edit). This is the path that covers cubic/robust and operator + // cal. + { + auto cit = cal_thresholds.find(uid); + if (cit != cal_thresholds.end() && std::abs(cit->second.first - threshold_psi) < 0.5) { + abort_pt_list.push_back( + {ipToU32Le(b.ip), static_cast(channel), cit->second.second}); continue; - auto rit = roles->find(sensor_name); - if (rit == roles->end()) - continue; // not on this board — keep looking - resolved_role = true; - const int channel = rit->second; - if (channel < 1 || channel > 255) { - abort_warnings.insert(tag + "channel out of range — no board abort threshold"); - break; - } - const uint16_t uid = static_cast(b.board_id * 100 + channel); - // Prefer the calibration service's model-correct threshold, as long as the PSI it was - // computed for still matches the live config (it re-emits on capture/clear/reload, not - // on a bare abort_pts edit). This is the path that covers cubic/robust and operator - // cal. - { - auto cit = cal_thresholds.find(uid); - if (cit != cal_thresholds.end() && - std::abs(cit->second.first - threshold_psi) < 0.5) { - abort_pt_list.push_back( - {ipToU32Le(b.ip), static_cast(channel), cit->second.second}); - break; - } - } - // Fallback (calibration service not up yet, or the abort PSI changed and it hasn't - // re-emitted): invert physics inline. Interface + model + full-scale resolved exactly - // as calibration_main does, so a physics threshold lands on the same curve the operator - // reads. - const bool is_loop = b.has_hp_pt_keys || b.pt_type == "4-20 mA absolute"; - std::string model = is_loop ? "physics" : "cubic"; // interface-aware default - if (const auto* models = cfg.calibration_model_for("calibration_model_" + board_key)) { - auto mit = models->find(sensor_name); - if (mit != models->end()) - model = mit->second; - } - double full_scale = is_loop ? b.hp_pt_full_scale_psi : 1000.0; - if (const auto* fss = cfg.full_scale_for("calibration_full_scale_" + board_key)) { - auto fit = fss->find(sensor_name); - if (fit != fss->end() && fit->second > 0.0) - full_scale = fit->second; } + } + // Fallback (calibration service not up yet, or the abort PSI changed and it hasn't + // re-emitted): invert physics inline. Interface + model + full-scale resolved exactly + // as calibration_main does, so a physics threshold lands on the same curve the operator + // reads. + const bool is_loop = b.has_hp_pt_keys || b.pt_type == "4-20 mA absolute"; + std::string model = is_loop ? "physics" : "cubic"; // interface-aware default + if (const auto* models = cfg.calibration_model_for("calibration_model_" + board_key)) { + auto mit = models->find(sensor_name); + if (mit != models->end()) + model = mit->second; + } + double full_scale = is_loop ? b.hp_pt_full_scale_psi : 1000.0; + if (const auto* fss = cfg.full_scale_for("calibration_full_scale_" + board_key)) { + auto fit = fss->find(sensor_name); + if (fit != fss->end() && fit->second > 0.0) + full_scale = fit->second; + } - if (model != "physics" || is_loop) { - abort_warnings.insert( - tag + "model \"" + model + (is_loop ? " (4-20 mA)" : "") + - "\" cannot be inverted for a board trip — set " - "calibration_model = \"physics\", or this sensor has NO trip"); - break; - } - if (!(full_scale > 0.0) || !(threshold_psi > 0.0)) { - abort_warnings.insert(tag + "non-positive full_scale/threshold — NO board trip"); - break; - } - constexpr double ADC_MAX = 2147483648.0; // 2^31 - double adc = std::clamp((threshold_psi / full_scale) * ADC_MAX, 0.0, ADC_MAX - 1.0); - abort_pt_list.push_back({ipToU32Le(b.ip), static_cast(channel), - static_cast(llround(adc))}); - break; // handled on its owning board + if (model != "physics" || is_loop) { + abort_warnings.insert(tag + "model \"" + model + (is_loop ? " (4-20 mA)" : "") + + "\" cannot be inverted for a board trip — set " + "calibration_model = \"physics\", or this sensor has NO trip"); + continue; } - if (!resolved_role) - abort_warnings.insert(tag + "no PT sensor_role declares this name — NO board trip"); + if (!(full_scale > 0.0) || !(threshold_psi > 0.0)) { + abort_warnings.insert(tag + "non-positive full_scale/threshold — NO board trip"); + continue; + } + constexpr double ADC_MAX = 2147483648.0; // 2^31 + double adc = std::clamp((threshold_psi / full_scale) * ADC_MAX, 0.0, ADC_MAX - 1.0); + abort_pt_list.push_back( + {ipToU32Le(b.ip), static_cast(channel), static_cast(llround(adc))}); } // Log each distinct abort-threshold problem once while it persists, and note recovery — never // spam the every-cycle rebuild. Dropping resolved entries lets a re-break warn again. From 9640290aa9d15a0ebc5ae50968bdc7683fd1595d Mon Sep 17 00:00:00 2001 From: Aidan Rickert Date: Mon, 14 Sep 2026 03:30:12 -0700 Subject: [PATCH 02/22] daq: state-script language (lexer, parser, validator) and hermetic state_script_check --- daq-server/diablo_server/CMakeLists.txt | 1 + daq-server/diablo_server/lib/CMakeLists.txt | 16 + .../lib/include/script/ScriptConfig.hpp | 58 ++ .../lib/include/script/StateScript.hpp | 292 ++++++ .../diablo_server/lib/src/script/Lexer.cpp | 414 ++++++++ .../diablo_server/lib/src/script/Lexer.hpp | 81 ++ .../diablo_server/lib/src/script/Parser.cpp | 934 ++++++++++++++++++ .../lib/src/script/ScriptConfig.cpp | 56 ++ .../diablo_server/lib/src/script/Slug.cpp | 43 + .../diablo_server/lib/src/script/Validate.cpp | 151 +++ .../lib/test/test_state_script.cpp | 325 ++++++ daq-server/diablo_server/tools/CMakeLists.txt | 22 + .../tools/state_script_check/main.cpp | 173 ++++ 13 files changed, 2566 insertions(+) create mode 100644 daq-server/diablo_server/lib/include/script/ScriptConfig.hpp create mode 100644 daq-server/diablo_server/lib/include/script/StateScript.hpp create mode 100644 daq-server/diablo_server/lib/src/script/Lexer.cpp create mode 100644 daq-server/diablo_server/lib/src/script/Lexer.hpp create mode 100644 daq-server/diablo_server/lib/src/script/Parser.cpp create mode 100644 daq-server/diablo_server/lib/src/script/ScriptConfig.cpp create mode 100644 daq-server/diablo_server/lib/src/script/Slug.cpp create mode 100644 daq-server/diablo_server/lib/src/script/Validate.cpp create mode 100644 daq-server/diablo_server/lib/test/test_state_script.cpp create mode 100644 daq-server/diablo_server/tools/CMakeLists.txt create mode 100644 daq-server/diablo_server/tools/state_script_check/main.cpp diff --git a/daq-server/diablo_server/CMakeLists.txt b/daq-server/diablo_server/CMakeLists.txt index d415f1c2..76873fa4 100644 --- a/daq-server/diablo_server/CMakeLists.txt +++ b/daq-server/diablo_server/CMakeLists.txt @@ -2,3 +2,4 @@ add_subdirectory(transport) add_subdirectory(lib) add_subdirectory(daq_bridge) add_subdirectory(services) +add_subdirectory(tools) diff --git a/daq-server/diablo_server/lib/CMakeLists.txt b/daq-server/diablo_server/lib/CMakeLists.txt index b5db19ae..d4fd00cc 100644 --- a/daq-server/diablo_server/lib/CMakeLists.txt +++ b/daq-server/diablo_server/lib/CMakeLists.txt @@ -10,6 +10,11 @@ set(FSW_SOURCES src/config/LoadActiveBoards.cpp src/config/Config.cpp src/config/SensorTables.cpp + src/script/Lexer.cpp + src/script/Parser.cpp + src/script/Validate.cpp + src/script/Slug.cpp + src/script/ScriptConfig.cpp src/routing/HeartbeatRouter.cpp src/routing/SensorRouter.cpp src/time/BoardClockSync.cpp @@ -105,6 +110,17 @@ target_link_libraries(test_fire_lifecycle fsw_daq_lib daq_comms_lib daqv2_comms Threads::Threads pthread ${RT_LIBRARY}) add_test(NAME fire_lifecycle COMMAND test_fire_lifecycle) +add_executable(test_state_script + test/test_state_script.cpp +) +target_include_directories(test_state_script PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/include + ${CMAKE_CURRENT_SOURCE_DIR}/../transport/include +) +target_link_libraries(test_state_script + fsw_daq_lib daq_comms_lib daqv2_comms Threads::Threads pthread ${RT_LIBRARY}) +add_test(NAME state_script COMMAND test_state_script) + add_executable(test_sensor_tables test/test_sensor_tables.cpp ) diff --git a/daq-server/diablo_server/lib/include/script/ScriptConfig.hpp b/daq-server/diablo_server/lib/include/script/ScriptConfig.hpp new file mode 100644 index 00000000..6c32dbb9 --- /dev/null +++ b/daq-server/diablo_server/lib/include/script/ScriptConfig.hpp @@ -0,0 +1,58 @@ +#pragma once + +#include +#include + +#include "config/Config.hpp" +#include "script/StateScript.hpp" + +/** + * The bridge between config and the script language. + * + * Kept out of StateScript.hpp on purpose: the language itself is pure — strings in, diagnostics + * out, no config, no I/O, no clock — which is what lets it be tested exhaustively and linked into + * a hermetic checker binary. This header is where it learns what a real rig declares. + */ +namespace fsw { +namespace script { + +/** A name that could not be turned into a usable slug, reported so config can refuse it loudly. */ +struct BadSlug { + std::string canonical; // the config spelling + std::string slug; // what slugify produced + std::string where; // "actuator" / "sensor" / "state" +}; + +/** Two config names in one namespace that slug to the same identifier — genuinely ambiguous. */ +struct SlugCollision { + std::string slug; + std::string first; + std::string second; + std::string where; +}; + +struct TableBuild { + SlugTables tables; + std::vector bad; + std::vector collisions; + + bool ok() const { + return bad.empty() && collisions.empty(); + } +}; + +/** + * Build the actuator / sensor / state slug tables from a loaded config. + * + * `allowed_transitions` is left empty — it depends on which state owns the script and on + * state_transitions.csv, neither of which this library reads. The caller fills it. + * + * Collisions are reported per namespace only. Across namespaces they are expected and harmless: + * on the shipped `server` profile FUEL_VENT is both a valve and a state, and on `digital-twin` + * FUEL_UPSTREAM is both a valve and a sensor. Positional resolution is what makes that fine, and a + * blanket collision rule would refuse config that runs today. + */ +TableBuild build_slug_tables(const fsw::config::Config& cfg); + +} // namespace script +} // namespace fsw diff --git a/daq-server/diablo_server/lib/include/script/StateScript.hpp b/daq-server/diablo_server/lib/include/script/StateScript.hpp new file mode 100644 index 00000000..047b8bbf --- /dev/null +++ b/daq-server/diablo_server/lib/include/script/StateScript.hpp @@ -0,0 +1,292 @@ +#pragma once + +#include +#include +#include +#include +#include + +/** + * The state-script language: a small Python-looking language an operator writes in the config UI, + * which the sequencer runs on entry to a dynamic state. + * + * ── What this is, and what it deliberately is not ───────────────────────────────────────────── + * + * A script never becomes a C++ function. It is parsed once, at sequencer startup, into the flat + * arena below, and an interpreter walks that arena. There is no code generation, no dlopen, no + * eval, and no compiler anywhere near the stand. + * + * That is not merely the easier option, it is the correct one. Because the program is data being + * walked, the runner can check a stop flag, a wall deadline and an iteration budget between any + * two statements — which is what lets an abort preempt a running script at a known boundary and + * what makes a runaway loop bounded. Compiled code offers no such seam. + * + * ── Names are slugs, resolved positionally ─────────────────────────────────────────────────── + * + * `open_valve(GSE_HIGH_PRESS_CONTROL)`, not `open_valve("GSE High Press Control")`. The slug is + * the canonical config name uppercased with whitespace collapsed to underscores. + * + * The namespace comes from the ARGUMENT SLOT, never from the name itself: the argument of + * open_valve/close_valve is an actuator role, of pressure() a PT sensor role, of transition_to() + * a state. An identifier anywhere else is a variable. + * + * That is not a style choice. On the shipped `server` profile FUEL_PRESS and FUEL_VENT are each + * BOTH a valve and a state, and on `digital-twin` FUEL_UPSTREAM is both a valve and a PT sensor. + * A flat namespace would be ambiguous against config that exists today. Positional resolution + * also means collisions WITHIN a namespace are an error while collisions ACROSS namespaces are + * fine and expected — a blanket collision rule would refuse this rig's live config. + * + * ── Everything checkable at parse time is checked at parse time ────────────────────────────── + * + * A script that cannot be proven bounded, or that names something config does not define, is + * refused at load. The state then never becomes enterable, so the failure is an unavailable + * button rather than a valve opening on a script that was going to fail anyway. + */ +namespace fsw { +namespace script { + +// ───────────────────────────────────────────────────────────────────────────────────────────── +// Resource caps +// +// These bound the program, not the rig. The rig's bound is the per-state script_timeout_ms, which +// is mandatory and enforced separately. These exist so that an unbounded script is refused before +// it can run at all, rather than discovered mid-run with a valve open. +// ───────────────────────────────────────────────────────────────────────────────────────────── +inline constexpr size_t kMaxSourceBytes = 4096; +inline constexpr uint32_t kMaxLines = 200; +inline constexpr uint32_t kMaxStatements = 500; +inline constexpr uint32_t kMaxNestingDepth = 8; +inline constexpr uint32_t kMaxVariables = 32; +inline constexpr uint32_t kMaxExprDepth = 32; +/** Widest indent unit accepted. The first indented line sets the unit; every later indent must be + * an exact multiple of it, which kills "3 spaces here, 4 there" without a stack of columns. */ +inline constexpr uint32_t kMaxIndentUnit = 8; + +/** Which table a slug is resolved against. Determined by argument position, never by spelling. */ +enum class Ns : uint8_t { + Actuator, // open_valve / close_valve + PtSensor, // pressure + State, // transition_to +}; + +const char* ns_name(Ns ns); + +/** One slug reference, with its source span so a rename can rewrite exactly this token. */ +struct SlugRef { + std::string slug; + Ns ns = Ns::Actuator; + uint32_t line = 0; // 1-based + uint32_t col = 0; // 1-based + uint32_t len = 0; +}; + +enum class StmtKind : uint8_t { + Assign, + OpenValve, + CloseValve, + Delay, + TransitionTo, + If, + While, +}; + +enum class ExprKind : uint8_t { + Number, + Var, + Pressure, + Elapsed, + Unary, // op: Neg, Not + Binary, // op: arithmetic, comparison, And, Or +}; + +enum class Op : uint8_t { + Neg, + Not, + Add, + Sub, + Mul, + Div, + Lt, + Le, + Gt, + Ge, + Eq, + Ne, + And, + Or, +}; + +const char* op_name(Op op); + +struct Stmt { + StmtKind kind = StmtKind::Delay; + uint32_t line = 0; + uint32_t col = 0; + /** Assign: index into ScriptProgram::variables. */ + int32_t var = -1; + /** Assign value, Delay seconds, If/While condition. Index into ScriptProgram::exprs. */ + int32_t expr = -1; + /** OpenValve/CloseValve/TransitionTo target. Index into ScriptProgram::slugs. */ + int32_t slug = -1; + /** If/While body, and If's else arm: [begin, end) into ScriptProgram::block_items. */ + int32_t body_begin = -1; + int32_t body_end = -1; + int32_t else_begin = -1; + int32_t else_end = -1; +}; + +struct Expr { + ExprKind kind = ExprKind::Number; + uint32_t line = 0; + uint32_t col = 0; + double number = 0.0; // Number + int32_t var = -1; // Var -> variables index + int32_t slug = -1; // Pressure -> slugs index + Op op = Op::Add; // Unary / Binary + int32_t lhs = -1; + int32_t rhs = -1; // Binary only +}; + +/** + * A parsed script, as a flat arena. + * + * Children are int32_t indices, never pointers, and -1 means none. This is deliberate: the whole + * program copies by value into ScriptRunner::start() with no ownership question — matching + * HoldSpec's "a number that is passed in cannot go stale" discipline — and every size cap becomes + * a vector size check rather than a tree walk. + */ +struct ScriptProgram { + std::vector stmts; + std::vector exprs; + /** Statement indices, grouped into contiguous [begin, end) block ranges. */ + std::vector block_items; + int32_t top_begin = 0; + int32_t top_end = 0; + /** Every slug reference in SOURCE ORDER, with spans. The rename path rewrites these; the + * pressure subscriber derives its subscribe list from the Ns::PtSensor ones. */ + std::vector slugs; + std::vector variables; + uint32_t max_depth = 0; + + bool empty() const { + return top_begin == top_end; + } +}; + +/** + * Diagnostic codes. + * + * Tests and the shared corpus assert on the CODE and the LINE, never the message text, so wording + * can improve without churning them. + */ +enum class Diag : uint16_t { + // Lexer + TabIndent = 1, + BadIndentUnit, + BadDedent, + UnexpectedIndent, + UnexpectedChar, + BadNumber, + // Parser + ExpectedColon, + ExpectedLParen, + ExpectedRParen, + ExpectedNewline, + ExpectedName, + ExpectedExpr, + ExpectedBlock, + UnknownCall, + ChainedComparison, + CallNotAStatement, + AssignToCall, + // Caps + ProgramTooLarge, + TooManyLines, + TooDeep, + TooManyStatements, + TooManyVariables, + ExprTooDeep, + // Semantics provable without config + DelayNotPositive, + LoopWithoutDelay, + VarUsedBeforeAssign, + DivideByZeroLiteral, + // Semantics needing config (validate()) + UnknownActuator, + UnknownSensor, + UnknownState, + TransitionNotAllowed, + VarShadowsSlug, +}; + +const char* diag_name(Diag d); + +struct Diagnostic { + Diag code = Diag::UnexpectedChar; + uint32_t line = 0; // 1-based + uint32_t col = 0; // 1-based + /** Human wording, including the offending text. Never asserted on by tests. */ + std::string message; +}; + +struct ParseResult { + ScriptProgram program; + std::vector diagnostics; + + bool ok() const { + return diagnostics.empty(); + } +}; + +/** + * Lex and parse, applying every check that does not need config. + * + * Stops at the first error rather than attempting recovery: this is a 200-line config language + * written a few lines at a time, and a cascade of speculative follow-on errors would bury the one + * that matters. + */ +ParseResult parse(const std::string& source); + +/** + * What a script's slugs are allowed to name, built from the loaded config. + * + * Maps rather than sets so a diagnostic can name the canonical spelling a slug resolves to, which + * is what the operator sees elsewhere in the UI. + */ +struct SlugTables { + std::map actuators; // slug -> canonical name + std::map sensors; + std::map states; + /** State slugs reachable from the state that owns this script, per state_transitions.csv. + * Every transition_to target is checked against this — not just the configured fallbacks — + * because a script that runs 25 s and THEN cannot leave is worse than one that never starts. + */ + std::set allowed_transitions; +}; + +/** Config-dependent checks. Run after a successful parse; returns empty when the script is good. */ +std::vector validate(const ScriptProgram& program, const SlugTables& tables); + +/** + * Canonical name -> slug: trim, uppercase, collapse internal whitespace runs to a single '_'. + * + * The same rule the TS side already uses for sensor entity names + * (backend/src/sensor-config.ts:104), so the two agree by construction. + */ +std::string slugify(const std::string& name); + +/** True when a slug is usable as a bare identifier: ^[A-Z][A-Z0-9_]*$. */ +bool is_valid_slug(const std::string& slug); + +/** + * A stable s-expression rendering of the parsed program. + * + * This exists for the conformance corpus. Two implementations can agree that a script is + * "accepted" while disagreeing about precedence — `a - b - c`, `not a and b`, `-x * y` — and a + * precedence disagreement between the validator the operator sees and the interpreter that opens + * the valve is the worst bug this feature can have. Pinning the shape catches it. + */ +std::string print_canonical(const ScriptProgram& program); + +} // namespace script +} // namespace fsw diff --git a/daq-server/diablo_server/lib/src/script/Lexer.cpp b/daq-server/diablo_server/lib/src/script/Lexer.cpp new file mode 100644 index 00000000..5009837c --- /dev/null +++ b/daq-server/diablo_server/lib/src/script/Lexer.cpp @@ -0,0 +1,414 @@ +#include "Lexer.hpp" + +#include +#include + +namespace fsw { +namespace script { + +const char* tok_name(Tok t) { + switch (t) { + case Tok::Eof: + return "end of script"; + case Tok::Newline: + return "end of line"; + case Tok::Indent: + return "indent"; + case Tok::Dedent: + return "dedent"; + case Tok::Ident: + return "a name"; + case Tok::Number: + return "a number"; + case Tok::Colon: + return "':'"; + case Tok::LParen: + return "'('"; + case Tok::RParen: + return "')'"; + case Tok::Comma: + return "','"; + case Tok::Assign: + return "'='"; + case Tok::Plus: + return "'+'"; + case Tok::Minus: + return "'-'"; + case Tok::Star: + return "'*'"; + case Tok::Slash: + return "'/'"; + case Tok::Lt: + return "'<'"; + case Tok::Le: + return "'<='"; + case Tok::Gt: + return "'>'"; + case Tok::Ge: + return "'>='"; + case Tok::EqEq: + return "'=='"; + case Tok::Ne: + return "'!='"; + case Tok::KwIf: + return "'if'"; + case Tok::KwElif: + return "'elif'"; + case Tok::KwElse: + return "'else'"; + case Tok::KwWhile: + return "'while'"; + case Tok::KwAnd: + return "'and'"; + case Tok::KwOr: + return "'or'"; + case Tok::KwNot: + return "'not'"; + } + return "?"; +} + +namespace { + +bool identStart(char c) { + return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || c == '_'; +} +bool identChar(char c) { + return identStart(c) || (c >= '0' && c <= '9'); +} + +Tok keywordOf(const std::string& s) { + if (s == "if") + return Tok::KwIf; + if (s == "elif") + return Tok::KwElif; + if (s == "else") + return Tok::KwElse; + if (s == "while") + return Tok::KwWhile; + if (s == "and") + return Tok::KwAnd; + if (s == "or") + return Tok::KwOr; + if (s == "not") + return Tok::KwNot; + return Tok::Ident; +} + +struct Lexer { + const std::string& src; + LexResult out; + size_t i = 0; + uint32_t line = 1; + uint32_t col = 1; + /** Column stack for open blocks; [0] is always 0. */ + std::vector indents{0}; + /** Set by the first indented line; every later indent must be a multiple of it. 0 = unset. */ + uint32_t unit = 0; + bool failed = false; + + explicit Lexer(const std::string& s) : src(s) { + } + + void fail(Diag code, uint32_t l, uint32_t c, std::string msg) { + if (failed) + return; + failed = true; + out.diagnostics.push_back({code, l, c, std::move(msg)}); + } + + void push(Tok k, uint32_t l, uint32_t c, uint32_t len, std::string text = {}, + double num = 0.0) { + Token t; + t.kind = k; + t.line = l; + t.col = c; + t.len = len; + t.text = std::move(text); + t.number = num; + out.tokens.push_back(std::move(t)); + } + + char peek(size_t off = 0) const { + return (i + off < src.size()) ? src[i + off] : '\0'; + } + + void run() { + bool at_line_start = true; + while (!failed && i < src.size()) { + if (at_line_start) { + if (!handleLineStart(at_line_start)) + return; + continue; + } + char c = peek(); + if (c == '\n') { + push(Tok::Newline, line, col, 1); + i++; + line++; + col = 1; + at_line_start = true; + continue; + } + if (c == '\r') { // CRLF normalised at the door + i++; + continue; + } + if (c == ' ') { + i++; + col++; + continue; + } + if (c == '#') { + while (i < src.size() && src[i] != '\n') + i++; + continue; + } + if (!lexToken()) + return; + } + if (failed) + return; + // A file that does not end in a newline still closes its last statement. + if (!out.tokens.empty() && out.tokens.back().kind != Tok::Newline) + push(Tok::Newline, line, col, 0); + while (indents.size() > 1) { + indents.pop_back(); + push(Tok::Dedent, line, col, 0); + } + push(Tok::Eof, line, col, 0); + } + + /** + * Measure one line's indentation and emit INDENT/DEDENT. + * @return false on a hard error. Sets at_line_start=false when real content follows. + */ + bool handleLineStart(bool& at_line_start) { + uint32_t width = 0; + size_t j = i; + while (j < src.size()) { + char c = src[j]; + if (c == ' ') { + width++; + j++; + } else if (c == '\t') { + fail(Diag::TabIndent, line, width + 1, + "tab in indentation — use spaces (a tab is invisible in the editor, and " + "mixed indentation silently changes which branch a valve command is in)"); + return false; + } else if (c == '\r') { + j++; + } else { + break; + } + } + // Blank or comment-only line: no NEWLINE, no INDENT/DEDENT — it has no structure. + if (j >= src.size() || src[j] == '\n' || src[j] == '#') { + while (j < src.size() && src[j] != '\n') + j++; + if (j < src.size()) { + j++; // consume '\n' + line++; + } + i = j; + col = 1; + at_line_start = true; + return true; + } + + i = j; + col = width + 1; + at_line_start = false; + + const uint32_t cur = indents.back(); + if (width > cur) { + if (unit == 0) { + const uint32_t u = width - cur; + if (u > kMaxIndentUnit) { + fail(Diag::BadIndentUnit, line, width + 1, + "indent of " + std::to_string(u) + " spaces is wider than the " + + std::to_string(kMaxIndentUnit) + "-space maximum"); + return false; + } + unit = u; + } + if (width != cur + unit) { + fail(Diag::BadIndentUnit, line, width + 1, + "indented " + std::to_string(width - cur) + " spaces; this script indents " + + std::to_string(unit) + " at a time, so " + std::to_string(cur + unit) + + " was expected"); + return false; + } + indents.push_back(width); + push(Tok::Indent, line, col, 0); + } else if (width < cur) { + while (indents.size() > 1 && indents.back() > width) { + indents.pop_back(); + push(Tok::Dedent, line, col, 0); + } + if (indents.back() != width) { + fail(Diag::BadDedent, line, width + 1, + "unindent to column " + std::to_string(width + 1) + + " does not line up with any enclosing block"); + return false; + } + } + return true; + } + + bool lexToken() { + const uint32_t l = line, c = col; + const char ch = peek(); + + if (ch == '\t') { + fail(Diag::UnexpectedChar, l, c, "tab character — use spaces"); + return false; + } + // No string literals exist in this language, and refusing the quote outright is what + // guarantees a script can never contain ''' — which is what keeps every text-embedding of + // a script (TOML literal block included) safe without an escaping rule. + if (ch == '\'' || ch == '"') { + fail(Diag::UnexpectedChar, l, c, + std::string("quote character — names are bare slugs here, e.g. ") + + "open_valve(FUEL_VENT), not open_valve(\"Fuel Vent\")"); + return false; + } + + if (identStart(ch)) { + size_t start = i; + while (i < src.size() && identChar(src[i])) { + i++; + col++; + } + std::string text = src.substr(start, i - start); + const Tok k = keywordOf(text); + push(k, l, c, static_cast(text.size()), text); + return true; + } + + if (ch >= '0' && ch <= '9') { + size_t start = i; + while (i < src.size() && src[i] >= '0' && src[i] <= '9') { + i++; + col++; + } + if (i < src.size() && src[i] == '.') { + i++; + col++; + while (i < src.size() && src[i] >= '0' && src[i] <= '9') { + i++; + col++; + } + } + // "1e3" must not lex as 1 followed by the name e3. HoldParse records the same trap on + // the wire: atoi("1e3") is 1, so a frame asking for a thousand seconds ran a + // one-millisecond pulse and the operator weighed a mass against a window that never + // happened. Refuse it rather than silently truncating. + if (i < src.size() && (identChar(src[i]) || src[i] == '.')) { + fail(Diag::BadNumber, l, c, + "malformed number — digits and at most one '.', with no exponent and no " + "trailing letters"); + return false; + } + std::string text = src.substr(start, i - start); + push(Tok::Number, l, c, static_cast(text.size()), text, + std::atof(text.c_str())); + return true; + } + + auto two = [&](char a, char b) { + return ch == a && peek(1) == b; + }; + + Tok k = Tok::Eof; + uint32_t len = 1; + if (two('<', '=')) { + k = Tok::Le; + len = 2; + } else if (two('>', '=')) { + k = Tok::Ge; + len = 2; + } else if (two('=', '=')) { + k = Tok::EqEq; + len = 2; + } else if (two('!', '=')) { + k = Tok::Ne; + len = 2; + } else { + switch (ch) { + case ':': + k = Tok::Colon; + break; + case '(': + k = Tok::LParen; + break; + case ')': + k = Tok::RParen; + break; + case ',': + k = Tok::Comma; + break; + case '=': + k = Tok::Assign; + break; + case '+': + k = Tok::Plus; + break; + case '-': + k = Tok::Minus; + break; + case '*': + k = Tok::Star; + break; + case '/': + k = Tok::Slash; + break; + case '<': + k = Tok::Lt; + break; + case '>': + k = Tok::Gt; + break; + default: { + std::string what = "'"; + what += ch; + what += "'"; + fail(Diag::UnexpectedChar, l, c, "unexpected character " + what); + return false; + } + } + } + i += len; + col += len; + push(k, l, c, len); + return true; + } +}; + +} // namespace + +LexResult lex(const std::string& source) { + Lexer lx(source); + if (source.size() > kMaxSourceBytes) { + lx.out.diagnostics.push_back({Diag::ProgramTooLarge, 1, 1, + "script is " + std::to_string(source.size()) + + " bytes; the limit is " + + std::to_string(kMaxSourceBytes)}); + return lx.out; + } + uint32_t lines = 1; + for (char c : source) + if (c == '\n') + lines++; + if (lines > kMaxLines) { + lx.out.diagnostics.push_back({Diag::TooManyLines, kMaxLines + 1, 1, + "script is " + std::to_string(lines) + + " lines; the limit is " + std::to_string(kMaxLines)}); + return lx.out; + } + lx.run(); + return lx.out; +} + +} // namespace script +} // namespace fsw diff --git a/daq-server/diablo_server/lib/src/script/Lexer.hpp b/daq-server/diablo_server/lib/src/script/Lexer.hpp new file mode 100644 index 00000000..4e641960 --- /dev/null +++ b/daq-server/diablo_server/lib/src/script/Lexer.hpp @@ -0,0 +1,81 @@ +#pragma once + +// Internal to the script library. Not installed, not included outside lib/src/script. + +#include +#include +#include + +#include "script/StateScript.hpp" + +namespace fsw { +namespace script { + +enum class Tok : uint8_t { + Eof, + Newline, + Indent, + Dedent, + + Ident, + Number, + + Colon, + LParen, + RParen, + Comma, + Assign, + + Plus, + Minus, + Star, + Slash, + + Lt, + Le, + Gt, + Ge, + EqEq, + Ne, + + KwIf, + KwElif, + KwElse, + KwWhile, + KwAnd, + KwOr, + KwNot, +}; + +const char* tok_name(Tok t); + +struct Token { + Tok kind = Tok::Eof; + std::string text; // Ident spelling; Number source text + double number = 0.0; // Number value + uint32_t line = 1; // 1-based + uint32_t col = 1; // 1-based + uint32_t len = 0; +}; + +struct LexResult { + std::vector tokens; + std::vector diagnostics; + + bool ok() const { + return diagnostics.empty(); + } +}; + +/** + * Tokenize, emitting INDENT/DEDENT for block structure. + * + * Stops at the first error. Every ambiguity Python resolves by convention is rejected here + * instead: tabs are refused outright (the editor is a bare