Skip to content

Commit e9effdd

Browse files
WS2 complete: implemented strict/degraded startup policy with validated config parsing, enforced device initialization guarantees, added integration tests, and updated configuration docs
1 parent 88617dc commit e9effdd

13 files changed

Lines changed: 571 additions & 124 deletions

CMakeLists.txt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,17 @@ if(BUILD_TESTING)
262262
ENVIRONMENT "${_provider_test_env}"
263263
)
264264

265+
add_test(
266+
NAME provider.config_startup
267+
COMMAND ${Python3_EXECUTABLE} ${CMAKE_SOURCE_DIR}/tests/test_config_startup.py --test all
268+
)
269+
set_tests_properties(provider.config_startup PROPERTIES
270+
LABELS "provider;integration;config"
271+
FIXTURES_REQUIRED proto_python
272+
TIMEOUT 120
273+
ENVIRONMENT "${_provider_test_env}"
274+
)
275+
265276
add_test(
266277
NAME provider.adpp
267278
COMMAND ${Python3_EXECUTABLE} ${CMAKE_SOURCE_DIR}/tests/test_adpp_integration.py --test all

docs/configuration.md

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ This document defines the `anolis-provider-sim` YAML configuration used at proce
1616
provider: # Optional
1717
name: chamber-provider
1818

19+
startup_policy: strict # Optional: strict | degraded (default: strict)
20+
1921
devices: # Recommended (can be empty)
2022
- id: tempctl0
2123
type: tempctl
@@ -51,6 +53,13 @@ Supported type options:
5153

5254
`chaos_control` is always added automatically and must not be configured in `devices`.
5355

56+
### `startup_policy` (optional)
57+
58+
Controls startup behavior when one or more configured devices fail to initialize.
59+
60+
- `strict` (default): abort startup on first initialization failure
61+
- `degraded`: continue startup with successfully initialized devices only
62+
5463
### `simulation` (required)
5564

5665
`simulation.mode` is required and must be one of:
@@ -72,6 +81,7 @@ Additional notes:
7281
- `tick_rate_hz` range: `[0.1, 1000.0]`
7382
- `physics_config` is resolved relative to the provider config file directory
7483
- `sim` mode requires a FluxGraph-enabled build (`ENABLE_FLUXGRAPH=ON`)
84+
- Removed keys (hard errors): `noise_enabled`, `update_rate_hz`
7585

7686
## Example Configs In-Repo
7787

@@ -184,11 +194,15 @@ The runtime does not parse provider YAML content.
184194

185195
## Startup Behavior
186196

187-
Current behavior is fail-fast:
197+
Startup behavior is policy-controlled:
188198

189-
- Invalid YAML/schema -> startup failure
190-
- Unknown device type or invalid device parameter -> startup failure
191-
- Device initialization exception -> startup failure
199+
- Config/schema errors are always fail-fast:
200+
- invalid YAML/schema
201+
- duplicate `devices[].id`
202+
- invalid simulation key matrix
203+
- Device initialization failures:
204+
- `startup_policy=strict`: fail-fast
205+
- `startup_policy=degraded`: startup continues with successful devices only
192206

193207
## Validation Commands
194208

scripts/test.ps1

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,14 @@
66
#
77
# Options:
88
# -Preset <name> Test preset (default: dev-windows-release on Windows, dev-release otherwise)
9-
# -Suite <name> all|smoke|adpp|multi|fault|fluxgraph (default: all)
9+
# -Suite <name> all|smoke|config|adpp|multi|fault|fluxgraph (default: all)
1010
# -VerboseOutput Run ctest with -VV
1111
# -Help Show help
1212

1313
[CmdletBinding(PositionalBinding = $false)]
1414
param(
1515
[string]$Preset = "",
16-
[ValidateSet("all", "smoke", "adpp", "multi", "fault", "fluxgraph")]
16+
[ValidateSet("all", "smoke", "config", "adpp", "multi", "fault", "fluxgraph")]
1717
[string]$Suite = "all",
1818
[switch]$VerboseOutput,
1919
[switch]$Help,

scripts/test.sh

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
#
77
# Options:
88
# --preset <name> Test preset (default: dev-release)
9-
# --suite <name> all|smoke|adpp|multi|fault|fluxgraph (default: all)
9+
# --suite <name> all|smoke|config|adpp|multi|fault|fluxgraph (default: all)
1010
# -v, --verbose Run ctest with -VV
1111
# -h, --help Show help
1212

@@ -59,10 +59,10 @@ while [[ $# -gt 0 ]]; do
5959
done
6060

6161
case "$SUITE" in
62-
all | smoke | adpp | multi | fault | fluxgraph) ;;
62+
all | smoke | config | adpp | multi | fault | fluxgraph) ;;
6363
*)
6464
echo "[ERROR] Invalid suite: $SUITE"
65-
echo "Valid values: all, smoke, adpp, multi, fault, fluxgraph"
65+
echo "Valid values: all, smoke, config, adpp, multi, fault, fluxgraph"
6666
exit 2
6767
;;
6868
esac

src/config.cpp

Lines changed: 133 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,15 @@
22
#include <filesystem>
33
#include <iostream>
44
#include <regex>
5+
#include <set>
56
#include <stdexcept>
67

78
namespace anolis_provider_sim {
89

910
namespace fs = std::filesystem;
11+
namespace {
12+
constexpr const char *kReservedChaosControlId = "chaos_control";
13+
}
1014

1115
// Parse simulation mode from string
1216
SimulationMode parse_simulation_mode(const std::string &mode_str) {
@@ -22,6 +26,17 @@ SimulationMode parse_simulation_mode(const std::string &mode_str) {
2226
}
2327
}
2428

29+
StartupPolicy parse_startup_policy(const std::string &policy_str) {
30+
if (policy_str == "strict") {
31+
return StartupPolicy::Strict;
32+
}
33+
if (policy_str == "degraded") {
34+
return StartupPolicy::Degraded;
35+
}
36+
throw std::runtime_error("Invalid startup_policy: '" + policy_str +
37+
"'. Valid values: strict, degraded");
38+
}
39+
2540
// Parse transform type from string
2641
static TransformType parse_transform_type(const std::string &type_str) {
2742
if (type_str == "first_order_lag") {
@@ -226,12 +241,27 @@ ProviderConfig load_config(const std::string &path) {
226241
config.provider_name = provider_name;
227242
}
228243

244+
// Parse optional startup policy (default: strict)
245+
if (yaml["startup_policy"]) {
246+
try {
247+
const std::string startup_policy_str =
248+
yaml["startup_policy"].as<std::string>();
249+
config.startup_policy = parse_startup_policy(startup_policy_str);
250+
} catch (const YAML::Exception &) {
251+
throw std::runtime_error(
252+
"[CONFIG] Invalid startup_policy: must be a string");
253+
} catch (const std::runtime_error &e) {
254+
throw std::runtime_error("[CONFIG] " + std::string(e.what()));
255+
}
256+
}
257+
229258
// Parse devices section
230259
if (yaml["devices"]) {
231260
if (!yaml["devices"].IsSequence()) {
232261
throw std::runtime_error("'devices' must be a sequence");
233262
}
234263

264+
std::set<std::string> seen_device_ids;
235265
for (std::size_t i = 0; i < yaml["devices"].size(); ++i) {
236266
const auto &device_node = yaml["devices"][i];
237267
if (!device_node.IsMap()) {
@@ -261,6 +291,27 @@ ProviderConfig load_config(const std::string &path) {
261291
std::to_string(i) + "]: " + e.what());
262292
}
263293

294+
if (spec.id.empty()) {
295+
throw std::runtime_error("[CONFIG] Invalid devices[" +
296+
std::to_string(i) +
297+
"]: 'id' must not be empty");
298+
}
299+
if (spec.type.empty()) {
300+
throw std::runtime_error("[CONFIG] Invalid devices[" +
301+
std::to_string(i) +
302+
"]: 'type' must not be empty");
303+
}
304+
if (spec.id == kReservedChaosControlId) {
305+
throw std::runtime_error(
306+
"[CONFIG] devices[].id 'chaos_control' is reserved and cannot be "
307+
"configured explicitly");
308+
}
309+
310+
if (!seen_device_ids.insert(spec.id).second) {
311+
throw std::runtime_error("[CONFIG] Duplicate device id: '" + spec.id +
312+
"'");
313+
}
314+
264315
// Store all other fields as configuration parameters
265316
for (const auto &kv : device_node) {
266317
std::string key = kv.first.as<std::string>();
@@ -295,9 +346,34 @@ ProviderConfig load_config(const std::string &path) {
295346
std::string(e.what()));
296347
}
297348

349+
std::set<std::string> provided_simulation_keys;
350+
const std::set<std::string> known_simulation_keys = {
351+
"mode", "tick_rate_hz", "physics_config", "ambient_temp_c",
352+
"ambient_signal_path"};
353+
for (const auto &kv : yaml["simulation"]) {
354+
const std::string key = kv.first.as<std::string>();
355+
provided_simulation_keys.insert(key);
356+
357+
if (key == "noise_enabled" || key == "update_rate_hz") {
358+
throw std::runtime_error("[CONFIG] simulation." + key +
359+
" is no longer supported");
360+
}
361+
362+
if (known_simulation_keys.find(key) == known_simulation_keys.end()) {
363+
throw std::runtime_error("[CONFIG] Unknown simulation key: '" + key +
364+
"'");
365+
}
366+
}
367+
298368
// Parse simulation.tick_rate_hz (required for non_interacting and sim)
299369
if (yaml["simulation"]["tick_rate_hz"]) {
300-
double tick_rate = yaml["simulation"]["tick_rate_hz"].as<double>();
370+
double tick_rate = 0.0;
371+
try {
372+
tick_rate = yaml["simulation"]["tick_rate_hz"].as<double>();
373+
} catch (const YAML::Exception &) {
374+
throw std::runtime_error("[CONFIG] simulation.tick_rate_hz must be "
375+
"numeric in range [0.1, 1000.0]");
376+
}
301377

302378
// Validate bounds
303379
if (tick_rate < 0.1 || tick_rate > 1000.0) {
@@ -310,37 +386,77 @@ ProviderConfig load_config(const std::string &path) {
310386

311387
// Parse simulation.physics_config (required for sim mode)
312388
if (yaml["simulation"]["physics_config"]) {
313-
config.physics_config_path =
314-
yaml["simulation"]["physics_config"].as<std::string>();
389+
try {
390+
config.physics_config_path =
391+
yaml["simulation"]["physics_config"].as<std::string>();
392+
} catch (const YAML::Exception &) {
393+
throw std::runtime_error(
394+
"[CONFIG] simulation.physics_config must be a string");
395+
}
396+
397+
if (config.physics_config_path->empty()) {
398+
throw std::runtime_error(
399+
"[CONFIG] simulation.physics_config cannot be empty");
400+
}
401+
}
402+
403+
if (yaml["simulation"]["ambient_temp_c"]) {
404+
try {
405+
config.ambient_temp_c = yaml["simulation"]["ambient_temp_c"].as<double>();
406+
} catch (const YAML::Exception &) {
407+
throw std::runtime_error(
408+
"[CONFIG] simulation.ambient_temp_c must be numeric");
409+
}
315410
}
316411

412+
if (yaml["simulation"]["ambient_signal_path"]) {
413+
try {
414+
config.ambient_signal_path =
415+
yaml["simulation"]["ambient_signal_path"].as<std::string>();
416+
} catch (const YAML::Exception &) {
417+
throw std::runtime_error(
418+
"[CONFIG] simulation.ambient_signal_path must be a string");
419+
}
420+
421+
if (config.ambient_signal_path->empty()) {
422+
throw std::runtime_error(
423+
"[CONFIG] simulation.ambient_signal_path cannot be empty");
424+
}
425+
}
426+
427+
if (config.ambient_signal_path && !config.ambient_temp_c) {
428+
throw std::runtime_error("[CONFIG] simulation.ambient_signal_path requires "
429+
"simulation.ambient_temp_c");
430+
}
431+
432+
const auto ensure_mode_allowed_keys = [&](const std::set<std::string> &keys,
433+
const std::string &mode_name) {
434+
for (const auto &provided_key : provided_simulation_keys) {
435+
if (keys.find(provided_key) == keys.end()) {
436+
throw std::runtime_error("[CONFIG] simulation." + provided_key +
437+
" is not valid for mode=" + mode_name);
438+
}
439+
}
440+
};
441+
317442
// Startup validation matrix
318443
switch (config.simulation_mode) {
319444
case SimulationMode::NonInteracting:
445+
ensure_mode_allowed_keys({"mode", "tick_rate_hz"}, "non_interacting");
320446
if (!config.tick_rate_hz) {
321447
throw std::runtime_error(
322448
"[CONFIG] mode=non_interacting requires simulation.tick_rate_hz");
323449
}
324-
if (config.physics_config_path) {
325-
throw std::runtime_error(
326-
"[CONFIG] mode=non_interacting cannot have physics_config (prevents "
327-
"silent ignored config)");
328-
}
329450
break;
330451

331452
case SimulationMode::Inert:
332-
if (config.tick_rate_hz) {
333-
throw std::runtime_error(
334-
"[CONFIG] mode=inert cannot have simulation.tick_rate_hz (prevents "
335-
"ignored config)");
336-
}
337-
if (config.physics_config_path) {
338-
throw std::runtime_error("[CONFIG] mode=inert cannot have physics_config "
339-
"(prevents silent ignored config)");
340-
}
453+
ensure_mode_allowed_keys({"mode"}, "inert");
341454
break;
342455

343456
case SimulationMode::Sim:
457+
ensure_mode_allowed_keys({"mode", "tick_rate_hz", "physics_config",
458+
"ambient_temp_c", "ambient_signal_path"},
459+
"sim");
344460
if (!config.tick_rate_hz) {
345461
throw std::runtime_error(
346462
"[CONFIG] mode=sim requires simulation.tick_rate_hz");
@@ -352,48 +468,6 @@ ProviderConfig load_config(const std::string &path) {
352468
break;
353469
}
354470

355-
const bool has_ambient_temp =
356-
static_cast<bool>(yaml["simulation"]["ambient_temp_c"]);
357-
const bool has_ambient_path =
358-
static_cast<bool>(yaml["simulation"]["ambient_signal_path"]);
359-
360-
if (config.simulation_mode != SimulationMode::Sim &&
361-
(has_ambient_temp || has_ambient_path)) {
362-
throw std::runtime_error(
363-
"[CONFIG] simulation.ambient_* options are only valid in mode=sim");
364-
}
365-
366-
if (config.simulation_mode == SimulationMode::Sim) {
367-
if (has_ambient_path && !has_ambient_temp) {
368-
throw std::runtime_error("[CONFIG] simulation.ambient_signal_path "
369-
"requires simulation.ambient_temp_c");
370-
}
371-
372-
if (has_ambient_temp) {
373-
try {
374-
(void)yaml["simulation"]["ambient_temp_c"].as<double>();
375-
} catch (const YAML::Exception &) {
376-
throw std::runtime_error(
377-
"[CONFIG] simulation.ambient_temp_c must be numeric");
378-
}
379-
}
380-
381-
if (has_ambient_path) {
382-
std::string ambient_path;
383-
try {
384-
ambient_path =
385-
yaml["simulation"]["ambient_signal_path"].as<std::string>();
386-
} catch (const YAML::Exception &) {
387-
throw std::runtime_error(
388-
"[CONFIG] simulation.ambient_signal_path must be a string");
389-
}
390-
if (ambient_path.empty()) {
391-
throw std::runtime_error(
392-
"[CONFIG] simulation.ambient_signal_path cannot be empty");
393-
}
394-
}
395-
}
396-
397471
// Validate physics_bindings are only used in sim mode
398472
if (config.simulation_mode != SimulationMode::Sim) {
399473
for (const auto &device : config.devices) {
@@ -405,14 +479,6 @@ ProviderConfig load_config(const std::string &path) {
405479
}
406480
}
407481

408-
// Store legacy simulation params for backward compatibility
409-
for (const auto &kv : yaml["simulation"]) {
410-
std::string key = kv.first.as<std::string>();
411-
if (key != "mode" && key != "tick_rate_hz" && key != "physics_config") {
412-
config.simulation[key] = kv.second;
413-
}
414-
}
415-
416482
return config;
417483
}
418484

0 commit comments

Comments
 (0)