feat(triggers): runtime expression trigger evaluation for scripts and alarms

This commit is contained in:
Joseph Doherty
2026-05-16 05:35:02 -04:00
parent f789ab4a91
commit 9e21b47080
5 changed files with 301 additions and 22 deletions

View File

@@ -50,6 +50,12 @@ public class AlarmActor : ReceiveActor
private readonly string? _onTriggerScriptName; private readonly string? _onTriggerScriptName;
private readonly Script<object?>? _onTriggerCompiledScript; private readonly Script<object?>? _onTriggerCompiledScript;
// Expression trigger: compiled expression + the attribute snapshot it
// evaluates against. The compiled expression is also held on the
// ExpressionEvalConfig; this field caches it for the hot path.
private readonly Script<object?>? _compiledTriggerExpression;
private readonly Dictionary<string, object?> _attributeSnapshot = new();
// Rate of change tracking // Rate of change tracking
private readonly Queue<(DateTimeOffset Timestamp, double Value)> _rateOfChangeWindow = new(); private readonly Queue<(DateTimeOffset Timestamp, double Value)> _rateOfChangeWindow = new();
private readonly TimeSpan _rateOfChangeWindowDuration; private readonly TimeSpan _rateOfChangeWindowDuration;
@@ -65,6 +71,7 @@ public class AlarmActor : ReceiveActor
SharedScriptLibrary sharedScriptLibrary, SharedScriptLibrary sharedScriptLibrary,
SiteRuntimeOptions options, SiteRuntimeOptions options,
ILogger logger, ILogger logger,
Script<object?>? compiledTriggerExpression = null,
ISiteHealthCollector? healthCollector = null) ISiteHealthCollector? healthCollector = null)
{ {
_alarmName = alarmName; _alarmName = alarmName;
@@ -77,6 +84,7 @@ public class AlarmActor : ReceiveActor
_priority = alarmConfig.PriorityLevel; _priority = alarmConfig.PriorityLevel;
_onTriggerScriptName = alarmConfig.OnTriggerScriptCanonicalName; _onTriggerScriptName = alarmConfig.OnTriggerScriptCanonicalName;
_onTriggerCompiledScript = onTriggerCompiledScript; _onTriggerCompiledScript = onTriggerCompiledScript;
_compiledTriggerExpression = compiledTriggerExpression;
// Parse trigger type // Parse trigger type
_triggerType = Enum.TryParse<AlarmTriggerType>(alarmConfig.TriggerType, true, out var tt) _triggerType = Enum.TryParse<AlarmTriggerType>(alarmConfig.TriggerType, true, out var tt)
@@ -126,9 +134,18 @@ public class AlarmActor : ReceiveActor
/// </summary> /// </summary>
private void HandleAttributeValueChanged(AttributeValueChanged changed) private void HandleAttributeValueChanged(AttributeValueChanged changed)
{ {
// Only evaluate if this change is for an attribute we're monitoring // Expression triggers evaluate against a snapshot of every attribute,
if (!IsMonitoredAttribute(changed.AttributeName)) // not a single monitored attribute. Keep the snapshot current for every
// change before the IsMonitoredAttribute gate (which does not apply).
if (_triggerType == AlarmTriggerType.Expression)
{
_attributeSnapshot[changed.AttributeName] = changed.Value;
}
else if (!IsMonitoredAttribute(changed.AttributeName))
{
// Only evaluate if this change is for an attribute we're monitoring
return; return;
}
try try
{ {
@@ -143,6 +160,7 @@ public class AlarmActor : ReceiveActor
AlarmTriggerType.ValueMatch => EvaluateValueMatch(changed.Value), AlarmTriggerType.ValueMatch => EvaluateValueMatch(changed.Value),
AlarmTriggerType.RangeViolation => EvaluateRangeViolation(changed.Value), AlarmTriggerType.RangeViolation => EvaluateRangeViolation(changed.Value),
AlarmTriggerType.RateOfChange => EvaluateRateOfChange(changed.Value, changed.Timestamp), AlarmTriggerType.RateOfChange => EvaluateRateOfChange(changed.Value, changed.Timestamp),
AlarmTriggerType.Expression => EvaluateExpression(),
_ => false _ => false
}; };
@@ -337,6 +355,22 @@ public class AlarmActor : ReceiveActor
} }
} }
/// <summary>
/// Evaluates the compiled trigger expression against the current attribute
/// snapshot, returning the resulting bool. This bool feeds the existing
/// binary Normal↔Active state path — the alarm is active while true. A
/// throwing or non-bool expression is treated as false; the exception
/// propagates to the caller's catch, which logs it and continues.
/// </summary>
private bool EvaluateExpression()
{
if (_compiledTriggerExpression == null) return false;
var globals = new TriggerExpressionGlobals(_attributeSnapshot);
var state = _compiledTriggerExpression.RunAsync(globals).GetAwaiter().GetResult();
return state.ReturnValue is bool b && b;
}
/// <summary> /// <summary>
/// HiLo level evaluator: returns the most-severe matching band for the /// HiLo level evaluator: returns the most-severe matching band for the
/// given value. Severity order checked from highest to lowest so that a /// given value. Severity order checked from highest to lowest so that a
@@ -473,6 +507,14 @@ public class AlarmActor : ReceiveActor
HiMessage: TryReadString(root, "hiMessage"), HiMessage: TryReadString(root, "hiMessage"),
HiHiMessage: TryReadString(root, "hiHiMessage")), HiHiMessage: TryReadString(root, "hiHiMessage")),
// Expression triggers have no single monitored attribute; they
// evaluate the compiled expression (passed into the actor) over
// the full attribute snapshot. MonitoredAttributeName is unused.
AlarmTriggerType.Expression => new ExpressionEvalConfig(
"",
TryReadString(root, "expression") ?? "",
_compiledTriggerExpression),
_ => new ValueMatchEvalConfig(attr, null) _ => new ValueMatchEvalConfig(attr, null)
}; };
} }
@@ -535,6 +577,17 @@ internal record RateOfChangeEvalConfig(
TimeSpan WindowDuration, TimeSpan WindowDuration,
RateOfChangeDirection Direction) : AlarmEvalConfig(MonitoredAttributeName); RateOfChangeDirection Direction) : AlarmEvalConfig(MonitoredAttributeName);
/// <summary>
/// Expression evaluation config: a read-only boolean C# expression evaluated
/// over the full attribute snapshot. Has no single monitored attribute
/// (<see cref="AlarmEvalConfig.MonitoredAttributeName"/> is empty); the
/// compiled expression is passed into the actor and cached here.
/// </summary>
internal record ExpressionEvalConfig(
string MonitoredAttributeName,
string Expression,
Script<object?>? CompiledExpression) : AlarmEvalConfig(MonitoredAttributeName);
/// <summary> /// <summary>
/// HiLo evaluation config: any subset of the four setpoints may be set; null /// HiLo evaluation config: any subset of the four setpoints may be set; null
/// means "don't evaluate that band". Per-setpoint priorities override the /// means "don't evaluate that band". Per-setpoint priorities override the

View File

@@ -515,6 +515,10 @@ public class InstanceActor : ReceiveActor
continue; continue;
} }
// Compile the trigger expression for Expression-triggered scripts.
var triggerExpression = CompileTriggerExpression(
script.TriggerType, script.TriggerConfiguration, $"script-trigger-{script.CanonicalName}");
var props = Props.Create(() => new ScriptActor( var props = Props.Create(() => new ScriptActor(
script.CanonicalName, script.CanonicalName,
_instanceUniqueName, _instanceUniqueName,
@@ -524,6 +528,7 @@ public class InstanceActor : ReceiveActor
_sharedScriptLibrary, _sharedScriptLibrary,
_options, _options,
_logger, _logger,
triggerExpression,
_healthCollector, _healthCollector,
_serviceProvider)); _serviceProvider));
@@ -559,6 +564,10 @@ public class InstanceActor : ReceiveActor
} }
} }
// Compile the trigger expression for Expression-triggered alarms.
var triggerExpression = CompileTriggerExpression(
alarm.TriggerType, alarm.TriggerConfiguration, $"alarm-trigger-expr-{alarm.CanonicalName}");
var props = Props.Create(() => new AlarmActor( var props = Props.Create(() => new AlarmActor(
alarm.CanonicalName, alarm.CanonicalName,
_instanceUniqueName, _instanceUniqueName,
@@ -568,6 +577,7 @@ public class InstanceActor : ReceiveActor
_sharedScriptLibrary, _sharedScriptLibrary,
_options, _options,
_logger, _logger,
triggerExpression,
_healthCollector)); _healthCollector));
var actorRef = Context.ActorOf(props, $"alarm-{alarm.CanonicalName}"); var actorRef = Context.ActorOf(props, $"alarm-{alarm.CanonicalName}");
@@ -581,6 +591,47 @@ public class InstanceActor : ReceiveActor
_instanceUniqueName, _scriptActors.Count, _alarmActors.Count); _instanceUniqueName, _scriptActors.Count, _alarmActors.Count);
} }
/// <summary>
/// Compiles the boolean trigger expression for an Expression-triggered
/// script or alarm. Returns null for non-Expression triggers, a blank
/// expression, or a compilation failure (logged) — in which case the
/// trigger is inert and the actor still starts.
/// </summary>
private Microsoft.CodeAnalysis.Scripting.Script<object?>? CompileTriggerExpression(
string? triggerType, string? triggerConfigJson, string compileName)
{
if (!string.Equals(triggerType, "Expression", StringComparison.OrdinalIgnoreCase))
return null;
if (string.IsNullOrEmpty(triggerConfigJson))
return null;
string? expression;
try
{
var doc = JsonSerializer.Deserialize<JsonElement>(triggerConfigJson);
expression = doc.TryGetProperty("expression", out var e) ? e.GetString() : null;
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Failed to read trigger expression config for {Name} on {Instance}",
compileName, _instanceUniqueName);
return null;
}
if (string.IsNullOrWhiteSpace(expression))
return null;
var result = _compilationService.CompileTriggerExpression(compileName, expression);
if (result.IsSuccess)
return result.CompiledScript;
_logger.LogError(
"Trigger expression for {Name} on {Instance} failed to compile: {Errors}",
compileName, _instanceUniqueName, string.Join("; ", result.Errors));
return null;
}
/// <summary> /// <summary>
/// Read-only access to current attribute count (for testing/diagnostics). /// Read-only access to current attribute count (for testing/diagnostics).
/// </summary> /// </summary>

View File

@@ -1,10 +1,12 @@
using Akka.Actor; using Akka.Actor;
using Microsoft.CodeAnalysis.Scripting; using Microsoft.CodeAnalysis.Scripting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using ScadaLink.Commons.Messages.ScriptExecution; using ScadaLink.Commons.Messages.ScriptExecution;
using ScadaLink.Commons.Messages.Streaming; using ScadaLink.Commons.Messages.Streaming;
using ScadaLink.Commons.Types.Flattening; using ScadaLink.Commons.Types.Flattening;
using ScadaLink.HealthMonitoring; using ScadaLink.HealthMonitoring;
using ScadaLink.SiteEventLogging;
using ScadaLink.SiteRuntime.Scripts; using ScadaLink.SiteRuntime.Scripts;
using System.Text.Json; using System.Text.Json;
@@ -40,6 +42,12 @@ public class ScriptActor : ReceiveActor, IWithTimers
private int _executionCounter; private int _executionCounter;
private readonly Commons.Types.Scripts.ScriptScope _scope; private readonly Commons.Types.Scripts.ScriptScope _scope;
// Expression trigger state: compiled expression, edge-tracking, and the
// attribute snapshot the expression evaluates against.
private readonly Script<object?>? _compiledTriggerExpression;
private bool _lastExpressionResult;
private readonly Dictionary<string, object?> _attributeSnapshot = new();
public ITimerScheduler Timers { get; set; } = null!; public ITimerScheduler Timers { get; set; } = null!;
public ScriptActor( public ScriptActor(
@@ -51,6 +59,7 @@ public class ScriptActor : ReceiveActor, IWithTimers
SharedScriptLibrary sharedScriptLibrary, SharedScriptLibrary sharedScriptLibrary,
SiteRuntimeOptions options, SiteRuntimeOptions options,
ILogger logger, ILogger logger,
Script<object?>? compiledTriggerExpression = null,
ISiteHealthCollector? healthCollector = null, ISiteHealthCollector? healthCollector = null,
IServiceProvider? serviceProvider = null) IServiceProvider? serviceProvider = null)
{ {
@@ -65,6 +74,7 @@ public class ScriptActor : ReceiveActor, IWithTimers
_serviceProvider = serviceProvider; _serviceProvider = serviceProvider;
_minTimeBetweenRuns = scriptConfig.MinTimeBetweenRuns; _minTimeBetweenRuns = scriptConfig.MinTimeBetweenRuns;
_scope = scriptConfig.Scope; _scope = scriptConfig.Scope;
_compiledTriggerExpression = compiledTriggerExpression;
// Parse trigger configuration // Parse trigger configuration
_triggerConfig = ParseTriggerConfig(scriptConfig.TriggerType, scriptConfig.TriggerConfiguration); _triggerConfig = ParseTriggerConfig(scriptConfig.TriggerType, scriptConfig.TriggerConfiguration);
@@ -143,10 +153,15 @@ public class ScriptActor : ReceiveActor, IWithTimers
} }
/// <summary> /// <summary>
/// Handles attribute value changes — triggers script if configured for value-change or conditional. /// Handles attribute value changes — triggers script if configured for
/// value-change, conditional, or expression. The attribute snapshot is
/// updated for every change before any trigger logic runs.
/// </summary> /// </summary>
private void HandleAttributeValueChanged(AttributeValueChanged changed) private void HandleAttributeValueChanged(AttributeValueChanged changed)
{ {
// Keep the snapshot current for every change, regardless of trigger type.
_attributeSnapshot[changed.AttributeName] = changed.Value;
if (_triggerConfig is ValueChangeTriggerConfig valueTrigger) if (_triggerConfig is ValueChangeTriggerConfig valueTrigger)
{ {
if (valueTrigger.AttributeName == changed.AttributeName) if (valueTrigger.AttributeName == changed.AttributeName)
@@ -165,6 +180,55 @@ public class ScriptActor : ReceiveActor, IWithTimers
} }
} }
} }
else if (_triggerConfig is ExpressionTriggerConfig)
{
EvaluateExpressionTrigger();
}
}
/// <summary>
/// Evaluates the compiled trigger expression against the current attribute
/// snapshot and runs the script edge-triggered — once per false→true
/// transition. A throwing or non-bool expression is treated as false and
/// logged as a script error; the actor never crashes.
/// </summary>
private void EvaluateExpressionTrigger()
{
if (_compiledTriggerExpression == null) return;
bool result;
try
{
var globals = new TriggerExpressionGlobals(_attributeSnapshot);
var state = _compiledTriggerExpression.RunAsync(globals).GetAwaiter().GetResult();
result = state.ReturnValue is bool b && b;
}
catch (Exception ex)
{
LogExpressionError(ex);
result = false;
}
if (result && !_lastExpressionResult)
{
TrySpawnExecution(null);
}
_lastExpressionResult = result;
}
/// <summary>
/// Records a trigger-expression evaluation failure to the site event log,
/// mirroring how ScriptExecutionActor reports script errors.
/// </summary>
private void LogExpressionError(Exception ex)
{
_healthCollector?.IncrementScriptError();
var errorMsg = $"Trigger expression for script '{_scriptName}' on instance '{_instanceName}' failed: {ex.Message}";
_logger.LogError(ex, "Trigger expression evaluation failed: {Script} on {Instance}", _scriptName, _instanceName);
_ = _serviceProvider?.GetService<ISiteEventLogger>()?.LogEventAsync(
"script", "Error", _instanceName, $"ScriptActor:{_scriptName}", errorMsg, ex.ToString());
} }
/// <summary> /// <summary>
@@ -264,11 +328,26 @@ public class ScriptActor : ReceiveActor, IWithTimers
"interval" => ParseIntervalTrigger(triggerConfigJson), "interval" => ParseIntervalTrigger(triggerConfigJson),
"valuechange" => ParseValueChangeTrigger(triggerConfigJson), "valuechange" => ParseValueChangeTrigger(triggerConfigJson),
"conditional" => ParseConditionalTrigger(triggerConfigJson), "conditional" => ParseConditionalTrigger(triggerConfigJson),
"expression" => ParseExpressionTrigger(triggerConfigJson),
"call" => null, // No automatic trigger — invoked only via Instance.CallScript() "call" => null, // No automatic trigger — invoked only via Instance.CallScript()
_ => null _ => null
}; };
} }
private static ExpressionTriggerConfig? ParseExpressionTrigger(string? json)
{
if (string.IsNullOrEmpty(json)) return null;
try
{
var doc = JsonDocument.Parse(json);
var expr = doc.RootElement.TryGetProperty("expression", out var e)
? e.GetString()
: null;
return string.IsNullOrWhiteSpace(expr) ? null : new ExpressionTriggerConfig(expr);
}
catch { return null; }
}
private static IntervalTriggerConfig? ParseIntervalTrigger(string? json) private static IntervalTriggerConfig? ParseIntervalTrigger(string? json)
{ {
if (string.IsNullOrEmpty(json)) return null; if (string.IsNullOrEmpty(json)) return null;
@@ -323,4 +402,5 @@ public class ScriptActor : ReceiveActor, IWithTimers
internal record IntervalTriggerConfig(TimeSpan Interval) : ScriptTriggerConfig; internal record IntervalTriggerConfig(TimeSpan Interval) : ScriptTriggerConfig;
internal record ValueChangeTriggerConfig(string AttributeName) : ScriptTriggerConfig; internal record ValueChangeTriggerConfig(string AttributeName) : ScriptTriggerConfig;
internal record ConditionalTriggerConfig(string AttributeName, string Operator, double Threshold) : ScriptTriggerConfig; internal record ConditionalTriggerConfig(string AttributeName, string Operator, double Threshold) : ScriptTriggerConfig;
internal record ExpressionTriggerConfig(string Expression) : ScriptTriggerConfig;
internal abstract record ScriptTriggerConfig; internal abstract record ScriptTriggerConfig;

View File

@@ -87,11 +87,45 @@ public class ScriptCompilationService
return violations; return violations;
} }
/// <summary>
/// Shared Roslyn scripting options (references + imports) used by both full
/// script compilation and trigger-expression compilation.
/// </summary>
private static ScriptOptions BuildScriptOptions() => ScriptOptions.Default
.WithReferences(
typeof(object).Assembly,
typeof(Enumerable).Assembly,
typeof(Math).Assembly,
typeof(Microsoft.CSharp.RuntimeBinder.CSharpArgumentInfo).Assembly,
typeof(Commons.Types.DynamicJsonElement).Assembly)
.WithImports(
"System",
"System.Collections.Generic",
"System.Linq",
"System.Threading.Tasks");
/// <summary> /// <summary>
/// Compiles a script into a reusable delegate that takes a ScriptRuntimeContext /// Compiles a script into a reusable delegate that takes a ScriptRuntimeContext
/// and parameters dictionary, and returns an object? result. /// and parameters dictionary, and returns an object? result.
/// </summary> /// </summary>
public ScriptCompilationResult Compile(string scriptName, string code) public ScriptCompilationResult Compile(string scriptName, string code)
=> CompileCore(scriptName, code, typeof(ScriptGlobals));
/// <summary>
/// Compiles a bare C# boolean trigger expression against the restricted
/// read-only <see cref="TriggerExpressionGlobals"/>. The expression is a
/// trailing expression (no <c>return</c>); Roslyn scripting yields its
/// value, which the caller coerces to <c>bool</c>. Reuses the same script
/// options and forbidden-API trust validation as <see cref="Compile"/>.
/// </summary>
public ScriptCompilationResult CompileTriggerExpression(string name, string expression)
=> CompileCore(name, expression, typeof(TriggerExpressionGlobals));
/// <summary>
/// Shared compilation path: validates the trust model, builds the script
/// against the given globals type, and returns the compiled result.
/// </summary>
private ScriptCompilationResult CompileCore(string name, string code, Type globalsType)
{ {
// Validate trust model // Validate trust model
var violations = ValidateTrustModel(code); var violations = ValidateTrustModel(code);
@@ -99,29 +133,16 @@ public class ScriptCompilationService
{ {
_logger.LogWarning( _logger.LogWarning(
"Script {Script} failed trust validation: {Violations}", "Script {Script} failed trust validation: {Violations}",
scriptName, string.Join("; ", violations)); name, string.Join("; ", violations));
return ScriptCompilationResult.Failed(violations); return ScriptCompilationResult.Failed(violations);
} }
try try
{ {
var scriptOptions = ScriptOptions.Default
.WithReferences(
typeof(object).Assembly,
typeof(Enumerable).Assembly,
typeof(Math).Assembly,
typeof(Microsoft.CSharp.RuntimeBinder.CSharpArgumentInfo).Assembly,
typeof(Commons.Types.DynamicJsonElement).Assembly)
.WithImports(
"System",
"System.Collections.Generic",
"System.Linq",
"System.Threading.Tasks");
var script = CSharpScript.Create<object?>( var script = CSharpScript.Create<object?>(
code, code,
scriptOptions, BuildScriptOptions(),
globalsType: typeof(ScriptGlobals)); globalsType: globalsType);
var diagnostics = script.Compile(); var diagnostics = script.Compile();
var errors = diagnostics var errors = diagnostics
@@ -133,16 +154,16 @@ public class ScriptCompilationService
{ {
_logger.LogWarning( _logger.LogWarning(
"Script {Script} compilation failed: {Errors}", "Script {Script} compilation failed: {Errors}",
scriptName, string.Join("; ", errors)); name, string.Join("; ", errors));
return ScriptCompilationResult.Failed(errors); return ScriptCompilationResult.Failed(errors);
} }
_logger.LogDebug("Script {Script} compiled successfully", scriptName); _logger.LogDebug("Script {Script} compiled successfully", name);
return ScriptCompilationResult.Succeeded(script); return ScriptCompilationResult.Succeeded(script);
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Unexpected error compiling script {Script}", scriptName); _logger.LogError(ex, "Unexpected error compiling script {Script}", name);
return ScriptCompilationResult.Failed([$"Compilation exception: {ex.Message}"]); return ScriptCompilationResult.Failed([$"Compilation exception: {ex.Message}"]);
} }
} }

View File

@@ -0,0 +1,74 @@
namespace ScadaLink.SiteRuntime.Scripts;
/// <summary>
/// Read-only globals a trigger expression is compiled against. Exposes only
/// attribute reads, backed by an in-memory snapshot — no I/O, no actor Ask,
/// no side-effecting APIs. A missing attribute key reads as <c>null</c> and
/// never throws.
///
/// Canonical attribute keys are dotted (e.g. "TempSensor.Reading"); the prefix
/// logic here mirrors <see cref="AttributeAccessor.Resolve"/>.
/// </summary>
public sealed class TriggerExpressionGlobals
{
private readonly IReadOnlyDictionary<string, object?> _snapshot;
public TriggerExpressionGlobals(IReadOnlyDictionary<string, object?> snapshot)
=> _snapshot = snapshot;
/// <summary>Attributes in the expression's own scope (root prefix).</summary>
public ReadOnlyAttributes Attributes => new(_snapshot, "");
/// <summary>Indexed access to child compositions' attributes.</summary>
public ReadOnlyChildren Children => new(_snapshot);
/// <summary>
/// Parent composition (null at root). Set by the caller for derived/composed
/// scopes; the runtime actors evaluate at root scope, so this stays null.
/// </summary>
public ReadOnlyComposition? Parent { get; init; }
/// <summary>
/// Read-only attribute view anchored at a canonical-name prefix. Indexing
/// resolves to the canonical key ("" → key, "TempSensor" → "TempSensor.key").
/// </summary>
public sealed class ReadOnlyAttributes
{
private readonly IReadOnlyDictionary<string, object?> _s;
private readonly string _prefix;
public ReadOnlyAttributes(IReadOnlyDictionary<string, object?> s, string prefix)
{
_s = s;
_prefix = prefix;
}
public object? this[string key] =>
_s.TryGetValue(_prefix.Length == 0 ? key : _prefix + "." + key, out var v) ? v : null;
}
/// <summary>A read-only view of one composition at a canonical-name path.</summary>
public sealed class ReadOnlyComposition
{
private readonly IReadOnlyDictionary<string, object?> _s;
private readonly string _path;
public ReadOnlyComposition(IReadOnlyDictionary<string, object?> s, string path)
{
_s = s;
_path = path;
}
public ReadOnlyAttributes Attributes => new(_s, _path);
}
/// <summary>Dictionary-style accessor for child compositions.</summary>
public sealed class ReadOnlyChildren
{
private readonly IReadOnlyDictionary<string, object?> _s;
public ReadOnlyChildren(IReadOnlyDictionary<string, object?> s) => _s = s;
public ReadOnlyComposition this[string compositionName] => new(_s, compositionName);
}
}