From dcf343307d9ab1dbc1b2e3cf7664f5391e4da50d Mon Sep 17 00:00:00 2001 From: Johan Peltenburg Date: Fri, 17 Jul 2026 11:30:18 +0200 Subject: [PATCH 1/4] feat(yaml): finite-state-machine entities via an `fsms:` block Lower a top-level `fsms:` block, keyed by entity name, into an FSM entity: each state becomes one of the entity's events, its cardinality derived from the topology, and `FsmEntityBuilder` validates the topology at build time. The FSM constraint is builder-only; a hand-written one is rejected. Demonstrate it, and the AnyEvent decoder, in the instrumentation-build example: a Query FSM entity whose events are its states, printed through a callback exporter that decodes each event via AnyEvent. Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 5 +- .../instrumentation-build/example/Cargo.lock | 14 ++ crates/instrumentation-build/example/build.rs | 6 + .../instrumentation-build/example/model.yaml | 19 ++ .../instrumentation-build/example/src/main.rs | 39 ++-- crates/yaml/Cargo.toml | 1 + crates/yaml/src/ast.rs | 24 +++ crates/yaml/src/lib.rs | 20 ++- crates/yaml/src/lower.rs | 170 +++++++++++++++--- crates/yaml/tests/fsm.rs | 156 ++++++++++++++++ 10 files changed, 412 insertions(+), 42 deletions(-) create mode 100644 crates/yaml/tests/fsm.rs diff --git a/Cargo.lock b/Cargo.lock index 3abf6f492..eaa6f4155 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1599,9 +1599,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.186" +version = "0.2.182" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" [[package]] name = "libloading" @@ -2851,6 +2851,7 @@ version = "0.1.0" dependencies = [ "indexmap", "quent-constraints", + "quent-fsm", "quent-ref-target", "quent-ref-tree", "quent-schema", diff --git a/crates/instrumentation-build/example/Cargo.lock b/crates/instrumentation-build/example/Cargo.lock index 8fff128c7..ae21ee2d1 100644 --- a/crates/instrumentation-build/example/Cargo.lock +++ b/crates/instrumentation-build/example/Cargo.lock @@ -356,6 +356,18 @@ dependencies = [ "uuid", ] +[[package]] +name = "quent-fsm" +version = "0.1.0" +dependencies = [ + "petgraph", + "quent-constraints", + "quent-schema", + "serde", + "serde_json", + "thiserror", +] + [[package]] name = "quent-instrumentation" version = "0.1.0" @@ -454,6 +466,7 @@ version = "0.1.0" dependencies = [ "indexmap", "rustc-hash", + "serde", "smallvec", "thiserror", ] @@ -471,6 +484,7 @@ version = "0.1.0" dependencies = [ "indexmap", "quent-constraints", + "quent-fsm", "quent-ref-target", "quent-ref-tree", "quent-schema", diff --git a/crates/instrumentation-build/example/build.rs b/crates/instrumentation-build/example/build.rs index 5e292b58e..bacf05f69 100644 --- a/crates/instrumentation-build/example/build.rs +++ b/crates/instrumentation-build/example/build.rs @@ -23,6 +23,12 @@ fn main() -> Result<(), Box> { let opts = Options { event_derives: &["Debug"], record_derives: &["Debug"], + // To just print the events in this example, we'll be using the callback + // exporter. This exporter takes a type-erased event, so in order to + // downcast it back to a statically-typed event, this features enables + // the generation of the "AnyEvent" type (see main.rs). This is + // typically left false when using "real" exporters. + any_event: true, ..Default::default() }; let GenerateInfo { path, warnings } = generate(&parsed.schema, &opts)?; diff --git a/crates/instrumentation-build/example/model.yaml b/crates/instrumentation-build/example/model.yaml index 61e6d9d54..78062c94e 100644 --- a/crates/instrumentation-build/example/model.yaml +++ b/crates/instrumentation-build/example/model.yaml @@ -43,3 +43,22 @@ entities: once: upstream: { ref: Server, data: Route } closed: once + + Query: + doc: A query on the server, modeled as a finite-state machine. + +fsms: + Query: + states: + submitted: + initial: true + attributes: + text: string + connection: { scope-ref: Connection } + to: [running] + running: + attributes: { rows: u64 } + to: [running, done] + done: + exit: true + attributes: { ok: bool } diff --git a/crates/instrumentation-build/example/src/main.rs b/crates/instrumentation-build/example/src/main.rs index 8f1ba78cb..25ae5600a 100644 --- a/crates/instrumentation-build/example/src/main.rs +++ b/crates/instrumentation-build/example/src/main.rs @@ -3,7 +3,7 @@ use quent_instrumentation::{EventCallback, ExporterOptions}; -use crate::demo::{ConnectionHandle, ConnectionObserver, DemoContext, Event, Uuid}; +use crate::demo::{ConnectionHandle, ConnectionObserver, DemoContext, Uuid}; #[allow(unused)] mod demo { @@ -11,30 +11,32 @@ mod demo { } fn main() -> Result<(), Box> { + // The context owns the exporter and exposes one observer per entity type. let context: DemoContext = demo::DemoContext::try_new(Some(debug_printing_exporter()))?; - // Boot a server; its instance id is the target the connection references. + // `observer.handle()` creates a fresh entity instance to events emit for. let mut server = context.server_observer().handle(); server.booted()?; let observer: ConnectionObserver = context.connection_observer(); - - // The handle (may) hold per-instance state that enforces once-cardinality, - // hence it is mut so it can update its state after producing a once-event. + // Once-cardinality events take `&mut self` and may fire only once, tracked + // by the handle, hence it is mut: let mut conn: ConnectionHandle = observer.handle(); + // One method per entity event: conn.opened( demo::Endpoint { host: "localhost".to_owned(), port: 8080, }, Uuid::nil(), + // A handle can deal out a reference to the entity it represents: server.as_entity_ref(), )?; conn.data(1234, None)?; - // `extra` is the schema's `dynamic` field: a runtime-keyed bag of typed - // attributes, so callers attach whatever key/values they have on hand. + // A `dynamic` schema field maps to `CustomAttributes`, which are + // dynamically-typed keys-value pairs: let mut extra = demo::CustomAttributes::new(); extra.add_string("peer_agent", "curl/8.4"); extra.add_u64("chunk_index", 3); @@ -47,12 +49,20 @@ fn main() -> Result<(), Box> { }), )?; - // `ref` field carrying data: the server reference plus details of the edge. + // `as_entity_ref_with` produces an entity ref that also carries data: conn.routed(server.as_entity_ref_with(demo::Route { hops: 3 }))?; + // An FSM entity's events are transitions into its states. + // For now, the generated methods are like any other event. + // Their cardinality is derived from the topology at build time. + let mut query = context.query_observer().handle(); + query.submitted("select 1".to_owned(), conn.as_entity_ref())?; + query.running(10)?; + query.done(true)?; + conn.closed()?; - // Emitting a once-event a second time fails. + // A once-event returns an error if emitted again. assert!(conn.closed_emitted()); assert!(conn.closed().is_err()); @@ -62,15 +72,8 @@ fn main() -> Result<(), Box> { /// Return an exporter that debug-prints each emitted event's payload. fn debug_printing_exporter() -> ExporterOptions { ExporterOptions::Callback(EventCallback::new(|recorded| { - if let Some(event) = recorded - .event - .downcast_ref::>() - { - println!("[{} @ {}] {:?}", event.id, event.timestamp, event.data); - } else if let Some(event) = recorded.event.downcast_ref::>() { - println!("[{} @ {}] {:?}", event.id, event.timestamp, event.data); - } else { - unreachable!() + if let Some(event) = demo::AnyEvent::from_any(recorded.event.as_ref()) { + println!("{event:?}"); } })) } diff --git a/crates/yaml/Cargo.toml b/crates/yaml/Cargo.toml index d0e7f53b0..3197f637a 100644 --- a/crates/yaml/Cargo.toml +++ b/crates/yaml/Cargo.toml @@ -7,6 +7,7 @@ publish.workspace = true [dependencies] indexmap = { workspace = true, features = ["serde"] } quent-constraints = { path = "../constraints" } +quent-fsm = { path = "../fsm" } quent-ref-target = { path = "../ref-target" } quent-ref-tree = { path = "../ref-tree" } quent-schema = { path = "../schema" } diff --git a/crates/yaml/src/ast.rs b/crates/yaml/src/ast.rs index 3e8ae98f8..689e4e118 100644 --- a/crates/yaml/src/ast.rs +++ b/crates/yaml/src/ast.rs @@ -38,6 +38,30 @@ pub(crate) struct Model { pub(crate) records: IndexMap, #[serde(default)] pub(crate) entities: IndexMap, + /// FSMs keyed by the name of the entity whose events they declare. + #[serde(default)] + pub(crate) fsms: IndexMap, +} + +/// An FSM entity's states. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct FsmSpec { + pub(crate) states: IndexMap, +} + +/// One state of an FSM. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct StateSpec { + #[serde(default)] + pub(crate) initial: bool, + #[serde(default)] + pub(crate) exit: bool, + #[serde(default)] + pub(crate) attributes: IndexMap, + #[serde(default)] + pub(crate) to: Vec, } /// A record: named fields plus annotations. diff --git a/crates/yaml/src/lib.rs b/crates/yaml/src/lib.rs index 66240aa23..8848bc7dc 100644 --- a/crates/yaml/src/lib.rs +++ b/crates/yaml/src/lib.rs @@ -10,6 +10,7 @@ use std::path::Path; use quent_constraints::validate; +use quent_fsm::{FsmConstraint, FsmError}; use quent_ref_target::{RefTargetConstraint, RefTargetError}; use quent_ref_tree::{RefTreeConstraint, RefTreeError}; use quent_schema::Schema; @@ -66,7 +67,7 @@ pub fn parse_from_str(src: impl AsRef, source: Option<&str>) -> Result(&schema); + let report = validate::<(RefTargetConstraint, RefTreeConstraint, FsmConstraint)>(&schema); if let Err(e) = report.base_constraints { for record in e.recursive_records { sink.error( @@ -82,13 +83,16 @@ pub fn parse_from_str(src: impl AsRef, source: Option<&str>) -> Result sink.error("", error.to_string(), None), } } + +/// Report one diagnostic per FSM violation, flattening `Multiple`. +fn fsm_diagnostics(error: FsmError, sink: &mut Diagnostics) { + match error { + FsmError::Multiple(errors) => { + errors + .into_iter() + .for_each(|error| fsm_diagnostics(error, sink)); + } + error => sink.error("", error.to_string(), None), + } +} diff --git a/crates/yaml/src/lower.rs b/crates/yaml/src/lower.rs index f8618c1c2..002da2184 100644 --- a/crates/yaml/src/lower.rs +++ b/crates/yaml/src/lower.rs @@ -15,6 +15,7 @@ use indexmap::IndexMap; use quent_constraints::Constraint; +use quent_fsm::{FsmConstraint, FsmEntityBuilder, FsmEntityBuilderError, FsmError, StateDecl}; use quent_ref_target::RefTargetConstraint; use quent_ref_tree::RefTreeConstraint; use quent_schema::builder::{ @@ -53,8 +54,17 @@ pub(crate) fn lower(model: &Model, sink: &mut Diagnostics) -> Schema { let entities: Vec = model .entities .iter() - .filter_map(|(name, entity)| entity_of(name, entity, sink)) + .filter_map(|(name, entity)| entity_of(name, entity, model.fsms.get(name), sink)) .collect(); + for name in model.fsms.keys() { + if !model.entities.contains_key(name) { + sink.error( + &format!("fsms.{name}"), + format!("`fsms` declares `{name}`, but there is no such entity"), + None, + ); + } + } SchemaBuilder::new(name) .try_with_records(records) @@ -95,27 +105,137 @@ fn record_of(name: &str, record: &ast::Record, sink: &mut Diagnostics) -> Option } /// Lower one entity, or `None` (after reporting) if its name is rejected. -fn entity_of(name: &str, entity: &ast::Entity, sink: &mut Diagnostics) -> Option { +/// +/// An FSM entity's events come from its FSM states (via [`FsmEntityBuilder`], +/// which derives cardinality); a plain entity's come from its `events:`. +fn entity_of( + name: &str, + entity: &ast::Entity, + fsm_spec: Option<&ast::FsmSpec>, + sink: &mut Diagnostics, +) -> Option { let path = format!("entities.{name}"); let id = type_decl_ident(name, "entities", sink); - let events: Vec<_> = entity - .events - .iter() - .filter_map(|(event_name, event)| event_of(event_name, event, &path, sink)) - .collect(); - Some( - EntityBuilder::new(id?) - .try_with_events(events) - .expect("event names are unique") - .with_annotations(annotations( - &entity.doc, - &entity.constraints, - &entity.metadata, - &path, - sink, - )) - .build(), - ) + let anns = annotations( + &entity.doc, + &entity.constraints, + &entity.metadata, + &path, + sink, + ); + + match fsm_spec { + Some(spec) => { + if !entity.events.is_empty() { + sink.error( + &path, + "an FSM entity declares its events as FSM states; remove `events:`", + None, + ); + } + fsm_entity(id, spec, anns, &format!("fsms.{name}"), sink) + } + None => { + let events: Vec<_> = entity + .events + .iter() + .filter_map(|(event_name, event)| event_of(event_name, event, &path, sink)) + .collect(); + Some( + EntityBuilder::new(id?) + .try_with_events(events) + .expect("event names are unique") + .with_annotations(anns) + .build(), + ) + } + } +} + +/// Lower an FSM entity: feed its states into [`FsmEntityBuilder`], which derives +/// each state event's cardinality, attaches the FSM constraint, and validates +/// the topology. Invalid FSMs are reported and skipped. +fn fsm_entity( + id: Option, + spec: &ast::FsmSpec, + annotations: Annotations, + path: &str, + sink: &mut Diagnostics, +) -> Option { + // Lower the states first, before bailing on a rejected entity name, so one + // run still reports problems inside the states. + let mut states = Vec::new(); + let mut complete = true; + for (name, state) in &spec.states { + let Some(state_id) = ident(name, path, sink) else { + complete = false; + continue; + }; + let attributes = fields_of(&state.attributes, &format!("{path}.states.{name}"), sink); + let to = state + .to + .iter() + .filter_map(|target| ident(target, path, sink)) + .collect(); + states.push(StateDecl { + name: state_id, + attributes, + to, + initial: state.initial, + exit: state.exit, + }); + } + + // A rejected state name leaves a reduced set whose structural checks would + // mislead (e.g. reporting a missing initial state), so stop once one is bad. + let id = id?; + if !complete { + return None; + } + + let built = FsmEntityBuilder::new(id) + .with_annotations(annotations) + .with_states(states) + .build(); + match built { + Ok(entity) => Some(entity), + Err(error) => { + fsm_shape_error(&error, path, sink); + None + } + } +} + +/// Report an FSM structural error, using the YAML `initial: true` / `exit: true` +/// wording for the flag problems. +fn fsm_shape_error(error: &FsmEntityBuilderError, path: &str, sink: &mut Diagnostics) { + match error { + FsmEntityBuilderError::NoInitialState => { + sink.error(path, "no state marked `initial: true`", None); + } + FsmEntityBuilderError::MultipleInitialStates(_) => { + sink.error(path, "more than one state marked `initial: true`", None); + } + FsmEntityBuilderError::NoExitState => { + sink.error(path, "no state marked `exit: true`", None); + } + // Topology violations carry the constraint's errors; report one per + // violation, flattening `Multiple`. + FsmEntityBuilderError::Invalid(error) => fsm_error_diagnostics(error, path, sink), + other => sink.error(path, other.to_string(), None), + } +} + +/// Report one diagnostic per FSM topology violation, flattening `Multiple`. +fn fsm_error_diagnostics(error: &FsmError, path: &str, sink: &mut Diagnostics) { + match error { + FsmError::Multiple(errors) => { + for error in errors { + fsm_error_diagnostics(error, path, sink); + } + } + other => sink.error(path, other.to_string(), None), + } } /// Lower one event, or `None` (after reporting) if its name is rejected. @@ -310,6 +430,16 @@ fn add_annotations( sink.error(path, "constraint name must not be empty", None); continue; } + // The FSM constraint is produced only by the builder, from an `fsms:` + // block, never written by hand. + if name == FsmConstraint::NAME { + sink.error( + path, + "the FSM constraint is set from an `fsms:` block, not written directly", + None, + ); + continue; + } if let Err(e) = builder.try_insert_constraint(name, value.clone()) { sink.error(path, e.to_string(), None); } diff --git a/crates/yaml/tests/fsm.rs b/crates/yaml/tests/fsm.rs new file mode 100644 index 000000000..36789974f --- /dev/null +++ b/crates/yaml/tests/fsm.rs @@ -0,0 +1,156 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! FSM tests: an `fsms:` overlay declares an entity's events as states, deriving +//! their cardinality from the topology, and is validated against the entity. + +use quent_schema::test_utils::ident; +use quent_schema::{Cardinality, Schema}; +use quent_yaml::{Error, parse_from_str}; + +const FSM: &str = "quent.fsm.v0.1.0"; + +fn schema_of(src: &str) -> Schema { + parse_from_str(src, None).expect("parses").schema +} + +fn errors_of(src: &str) -> String { + match parse_from_str(src, None) { + Err(Error::Invalid(diagnostics)) => diagnostics.to_string(), + other => panic!("expected diagnostics, got {other:?}"), + } +} + +const QUERY: &str = "\ +quent: alpha +model: m +entities: + Query: + doc: A query. +fsms: + Query: + states: + submitted: + initial: true + attributes: { text: string } + to: [progress] + progress: + attributes: { pct: u8 } + to: [progress, finished] + finished: + exit: true + attributes: { ok: bool } +"; + +#[test] +fn fsm_builds_events_and_derives_cardinality() { + let schema = schema_of(QUERY); + let query = schema.entity(&ident("Query")).unwrap(); + assert!(query.annotations().has_constraint(FSM)); + assert_eq!(query.events().count(), 3); + // `progress` self-loops, so it is Multi; the others are Once. + let card = |e: &str| query.event(&ident(e)).unwrap().cardinality(); + assert!(matches!(card("progress"), Cardinality::Multi)); + assert!(matches!(card("submitted"), Cardinality::Once)); + assert!(matches!(card("finished"), Cardinality::Once)); +} + +#[test] +fn fsms_referencing_unknown_entity_is_rejected() { + let errors = errors_of( + "\ +quent: alpha +model: m +fsms: + Ghost: + states: + a: { initial: true, exit: true } +", + ); + assert!( + errors.contains("no such entity") && errors.contains("Ghost"), + "{errors}" + ); +} + +#[test] +fn fsm_entity_may_not_declare_events() { + let errors = errors_of( + "\ +quent: alpha +model: m +entities: + E: + events: + a: once +fsms: + E: + states: + a: { initial: true, exit: true } +", + ); + assert!( + errors.contains("declares its events as FSM states"), + "{errors}" + ); +} + +#[test] +fn fsm_needs_one_initial_state() { + let errors = errors_of( + "\ +quent: alpha +model: m +entities: + E: + doc: x +fsms: + E: + states: + a: { exit: true } + b: { exit: true } +", + ); + assert!( + errors.contains("no state marked `initial: true`"), + "{errors}" + ); +} + +#[test] +fn fsm_needs_an_exit_state() { + let errors = errors_of( + "\ +quent: alpha +model: m +entities: + E: + doc: x +fsms: + E: + states: + a: { initial: true, to: [a] } +", + ); + assert!(errors.contains("no state marked `exit: true`"), "{errors}"); +} + +#[test] +fn unreachable_state_is_rejected() { + // `b` is a state but nothing reaches it from the initial state. + let errors = errors_of( + "\ +quent: alpha +model: m +entities: + E: + doc: x +fsms: + E: + states: + a: { initial: true, exit: true } + b: { exit: true, to: [a] } +", + ); + assert!(errors.contains("unreachable"), "{errors}"); +} From 26297cd9bb43b8bf75194b4a23dd477260063f2b Mon Sep 17 00:00:00 2001 From: Johan Peltenburg Date: Fri, 17 Jul 2026 12:55:19 +0200 Subject: [PATCH 2/4] refactor(yaml): self-contained `fsms:` with `to:`/`exit` transitions Declare FSM entities entirely under `fsms:` (their own doc/annotations plus states) instead of an empty `entities:` shell alongside a separate overlay; an entity declared as both is rejected. A state lists its outgoing transitions in `to:`, and the reserved target `exit` terminates the FSM, replacing the per-state `exit:` flag. Co-Authored-By: Claude Opus 4.8 --- .../instrumentation-build/example/model.yaml | 10 +- .../instrumentation-build/example/src/main.rs | 2 +- crates/yaml/src/ast.rs | 15 ++- crates/yaml/src/lower.rs | 123 ++++++++---------- crates/yaml/tests/fsm.rs | 56 +++----- 5 files changed, 87 insertions(+), 119 deletions(-) diff --git a/crates/instrumentation-build/example/model.yaml b/crates/instrumentation-build/example/model.yaml index 78062c94e..ca07435c8 100644 --- a/crates/instrumentation-build/example/model.yaml +++ b/crates/instrumentation-build/example/model.yaml @@ -44,11 +44,9 @@ entities: upstream: { ref: Server, data: Route } closed: once - Query: - doc: A query on the server, modeled as a finite-state machine. - fsms: Query: + doc: A query on the server, modeled as a finite-state machine. states: submitted: initial: true @@ -58,7 +56,7 @@ fsms: to: [running] running: attributes: { rows: u64 } - to: [running, done] - done: - exit: true + to: [running, ready] + ready: attributes: { ok: bool } + to: [exit] diff --git a/crates/instrumentation-build/example/src/main.rs b/crates/instrumentation-build/example/src/main.rs index 25ae5600a..edded3803 100644 --- a/crates/instrumentation-build/example/src/main.rs +++ b/crates/instrumentation-build/example/src/main.rs @@ -58,7 +58,7 @@ fn main() -> Result<(), Box> { let mut query = context.query_observer().handle(); query.submitted("select 1".to_owned(), conn.as_entity_ref())?; query.running(10)?; - query.done(true)?; + query.ready(true)?; conn.closed()?; diff --git a/crates/yaml/src/ast.rs b/crates/yaml/src/ast.rs index 689e4e118..2fc47f816 100644 --- a/crates/yaml/src/ast.rs +++ b/crates/yaml/src/ast.rs @@ -38,15 +38,22 @@ pub(crate) struct Model { pub(crate) records: IndexMap, #[serde(default)] pub(crate) entities: IndexMap, - /// FSMs keyed by the name of the entity whose events they declare. + /// FSM entities, keyed by name: each declares an entity whose events are + /// its states. #[serde(default)] pub(crate) fsms: IndexMap, } -/// An FSM entity's states. +/// An FSM entity: annotations plus its states. #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] pub(crate) struct FsmSpec { + #[serde(default)] + pub(crate) doc: Option, + #[serde(default)] + pub(crate) constraints: AnnotationMap, + #[serde(default)] + pub(crate) metadata: AnnotationMap, pub(crate) states: IndexMap, } @@ -57,9 +64,9 @@ pub(crate) struct StateSpec { #[serde(default)] pub(crate) initial: bool, #[serde(default)] - pub(crate) exit: bool, - #[serde(default)] pub(crate) attributes: IndexMap, + /// States this transitions to; the reserved name `exit` transitions the FSM + /// out of existence. #[serde(default)] pub(crate) to: Vec, } diff --git a/crates/yaml/src/lower.rs b/crates/yaml/src/lower.rs index 002da2184..78e84e797 100644 --- a/crates/yaml/src/lower.rs +++ b/crates/yaml/src/lower.rs @@ -51,20 +51,23 @@ pub(crate) fn lower(model: &Model, sink: &mut Diagnostics) -> Schema { .iter() .filter_map(|(name, record)| record_of(name, record, sink)) .collect(); - let entities: Vec = model + let mut entities: Vec = model .entities .iter() - .filter_map(|(name, entity)| entity_of(name, entity, model.fsms.get(name), sink)) + .filter_map(|(name, entity)| entity_of(name, entity, sink)) .collect(); - for name in model.fsms.keys() { - if !model.entities.contains_key(name) { + // FSM entities are declared in their own section, not under `entities:`. + entities.extend(model.fsms.iter().filter_map(|(name, spec)| { + if model.entities.contains_key(name) { sink.error( &format!("fsms.{name}"), - format!("`fsms` declares `{name}`, but there is no such entity"), + format!("`{name}` is declared as both an entity and an FSM"), None, ); + return None; } - } + fsm_entity_of(name, spec, sink) + })); SchemaBuilder::new(name) .try_with_records(records) @@ -104,16 +107,8 @@ fn record_of(name: &str, record: &ast::Record, sink: &mut Diagnostics) -> Option ) } -/// Lower one entity, or `None` (after reporting) if its name is rejected. -/// -/// An FSM entity's events come from its FSM states (via [`FsmEntityBuilder`], -/// which derives cardinality); a plain entity's come from its `events:`. -fn entity_of( - name: &str, - entity: &ast::Entity, - fsm_spec: Option<&ast::FsmSpec>, - sink: &mut Diagnostics, -) -> Option { +/// Lower one plain entity, or `None` (after reporting) if its name is rejected. +fn entity_of(name: &str, entity: &ast::Entity, sink: &mut Diagnostics) -> Option { let path = format!("entities.{name}"); let id = type_decl_ident(name, "entities", sink); let anns = annotations( @@ -123,66 +118,60 @@ fn entity_of( &path, sink, ); - - match fsm_spec { - Some(spec) => { - if !entity.events.is_empty() { - sink.error( - &path, - "an FSM entity declares its events as FSM states; remove `events:`", - None, - ); - } - fsm_entity(id, spec, anns, &format!("fsms.{name}"), sink) - } - None => { - let events: Vec<_> = entity - .events - .iter() - .filter_map(|(event_name, event)| event_of(event_name, event, &path, sink)) - .collect(); - Some( - EntityBuilder::new(id?) - .try_with_events(events) - .expect("event names are unique") - .with_annotations(anns) - .build(), - ) - } - } + let events: Vec<_> = entity + .events + .iter() + .filter_map(|(event_name, event)| event_of(event_name, event, &path, sink)) + .collect(); + Some( + EntityBuilder::new(id?) + .try_with_events(events) + .expect("event names are unique") + .with_annotations(anns) + .build(), + ) } -/// Lower an FSM entity: feed its states into [`FsmEntityBuilder`], which derives -/// each state event's cardinality, attaches the FSM constraint, and validates -/// the topology. Invalid FSMs are reported and skipped. -fn fsm_entity( - id: Option, - spec: &ast::FsmSpec, - annotations: Annotations, - path: &str, - sink: &mut Diagnostics, -) -> Option { +/// Lower an FSM entity from its `fsms:` declaration: its states become the +/// entity's events (cardinality derived from the topology), and +/// [`FsmEntityBuilder`] validates the topology. Invalid FSMs are reported and +/// skipped. +fn fsm_entity_of(name: &str, spec: &ast::FsmSpec, sink: &mut Diagnostics) -> Option { + let path = format!("fsms.{name}"); + let id = type_decl_ident(name, "fsms", sink); + let anns = annotations(&spec.doc, &spec.constraints, &spec.metadata, &path, sink); + // Lower the states first, before bailing on a rejected entity name, so one // run still reports problems inside the states. let mut states = Vec::new(); let mut complete = true; - for (name, state) in &spec.states { - let Some(state_id) = ident(name, path, sink) else { + for (state_name, state) in &spec.states { + let Some(state_id) = ident(state_name, &path, sink) else { complete = false; continue; }; - let attributes = fields_of(&state.attributes, &format!("{path}.states.{name}"), sink); - let to = state - .to - .iter() - .filter_map(|target| ident(target, path, sink)) - .collect(); + let attributes = fields_of( + &state.attributes, + &format!("{path}.states.{state_name}"), + sink, + ); + // The reserved target `exit` transitions out of the FSM; the rest are + // transitions to other states. + let mut exit = false; + let mut to = Vec::new(); + for target in &state.to { + if target.eq_ignore_ascii_case("exit") { + exit = true; + } else if let Some(id) = ident(target, &path, sink) { + to.push(id); + } + } states.push(StateDecl { name: state_id, attributes, to, initial: state.initial, - exit: state.exit, + exit, }); } @@ -194,20 +183,20 @@ fn fsm_entity( } let built = FsmEntityBuilder::new(id) - .with_annotations(annotations) + .with_annotations(anns) .with_states(states) .build(); match built { Ok(entity) => Some(entity), Err(error) => { - fsm_shape_error(&error, path, sink); + fsm_shape_error(&error, &path, sink); None } } } -/// Report an FSM structural error, using the YAML `initial: true` / `exit: true` -/// wording for the flag problems. +/// Report an FSM structural error in the YAML's own terms (`initial: true`, +/// transitioning to `exit`). fn fsm_shape_error(error: &FsmEntityBuilderError, path: &str, sink: &mut Diagnostics) { match error { FsmEntityBuilderError::NoInitialState => { @@ -217,7 +206,7 @@ fn fsm_shape_error(error: &FsmEntityBuilderError, path: &str, sink: &mut Diagnos sink.error(path, "more than one state marked `initial: true`", None); } FsmEntityBuilderError::NoExitState => { - sink.error(path, "no state marked `exit: true`", None); + sink.error(path, "no state transitions to `exit`", None); } // Topology violations carry the constraint's errors; report one per // violation, flattening `Multiple`. diff --git a/crates/yaml/tests/fsm.rs b/crates/yaml/tests/fsm.rs index 36789974f..dd633e9fd 100644 --- a/crates/yaml/tests/fsm.rs +++ b/crates/yaml/tests/fsm.rs @@ -1,8 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! FSM tests: an `fsms:` overlay declares an entity's events as states, deriving -//! their cardinality from the topology, and is validated against the entity. +//! FSM tests: an `fsms:` block declares an entity whose events are its states, +//! deriving their cardinality from the topology and validating it. use quent_schema::test_utils::ident; use quent_schema::{Cardinality, Schema}; @@ -24,11 +24,9 @@ fn errors_of(src: &str) -> String { const QUERY: &str = "\ quent: alpha model: m -entities: - Query: - doc: A query. fsms: Query: + doc: A query. states: submitted: initial: true @@ -38,8 +36,8 @@ fsms: attributes: { pct: u8 } to: [progress, finished] finished: - exit: true attributes: { ok: bool } + to: [exit] "; #[test] @@ -56,25 +54,7 @@ fn fsm_builds_events_and_derives_cardinality() { } #[test] -fn fsms_referencing_unknown_entity_is_rejected() { - let errors = errors_of( - "\ -quent: alpha -model: m -fsms: - Ghost: - states: - a: { initial: true, exit: true } -", - ); - assert!( - errors.contains("no such entity") && errors.contains("Ghost"), - "{errors}" - ); -} - -#[test] -fn fsm_entity_may_not_declare_events() { +fn entity_declared_as_both_entity_and_fsm_is_rejected() { let errors = errors_of( "\ quent: alpha @@ -86,11 +66,11 @@ entities: fsms: E: states: - a: { initial: true, exit: true } + a: { initial: true, to: [exit] } ", ); assert!( - errors.contains("declares its events as FSM states"), + errors.contains("both an entity and an FSM") && errors.contains("E"), "{errors}" ); } @@ -101,14 +81,11 @@ fn fsm_needs_one_initial_state() { "\ quent: alpha model: m -entities: - E: - doc: x fsms: E: states: - a: { exit: true } - b: { exit: true } + a: { to: [exit] } + b: { to: [exit] } ", ); assert!( @@ -123,16 +100,16 @@ fn fsm_needs_an_exit_state() { "\ quent: alpha model: m -entities: - E: - doc: x fsms: E: states: a: { initial: true, to: [a] } ", ); - assert!(errors.contains("no state marked `exit: true`"), "{errors}"); + assert!( + errors.contains("no state transitions to `exit`"), + "{errors}" + ); } #[test] @@ -142,14 +119,11 @@ fn unreachable_state_is_rejected() { "\ quent: alpha model: m -entities: - E: - doc: x fsms: E: states: - a: { initial: true, exit: true } - b: { exit: true, to: [a] } + a: { initial: true, to: [exit] } + b: { to: [a, exit] } ", ); assert!(errors.contains("unreachable"), "{errors}"); From fc7abc6bc5d5d55f5cc47d1909f1ad6a944875fc Mon Sep 17 00:00:00 2001 From: Johan Peltenburg Date: Fri, 17 Jul 2026 14:00:00 +0200 Subject: [PATCH 3/4] Tweak comment --- crates/instrumentation-build/example/build.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/instrumentation-build/example/build.rs b/crates/instrumentation-build/example/build.rs index bacf05f69..ab6305886 100644 --- a/crates/instrumentation-build/example/build.rs +++ b/crates/instrumentation-build/example/build.rs @@ -25,9 +25,9 @@ fn main() -> Result<(), Box> { record_derives: &["Debug"], // To just print the events in this example, we'll be using the callback // exporter. This exporter takes a type-erased event, so in order to - // downcast it back to a statically-typed event, this features enables - // the generation of the "AnyEvent" type (see main.rs). This is - // typically left false when using "real" exporters. + // simplify downcasting back to a statically-typed event, this features + // enables the generation of the "AnyEvent" helper type (see main.rs). + // This is typically left false when using "real" exporters. any_event: true, ..Default::default() }; From 87a2182f305019601e3a14275e97177dfbb5b1a5 Mon Sep 17 00:00:00 2001 From: Johan Peltenburg Date: Mon, 20 Jul 2026 10:27:23 +0200 Subject: [PATCH 4/4] refactor(yaml): report constraint violations via their Display Drop the per-constraint flatten helpers and report each constraint result's error directly; an aggregate `Multiple` already renders its violations as a bullet list. Co-Authored-By: Claude Opus 4.8 --- .../instrumentation-build/example/src/main.rs | 4 +- crates/yaml/src/ast.rs | 8 ++-- crates/yaml/src/lib.rs | 48 +++---------------- 3 files changed, 13 insertions(+), 47 deletions(-) diff --git a/crates/instrumentation-build/example/src/main.rs b/crates/instrumentation-build/example/src/main.rs index edded3803..34295d4b6 100644 --- a/crates/instrumentation-build/example/src/main.rs +++ b/crates/instrumentation-build/example/src/main.rs @@ -53,8 +53,10 @@ fn main() -> Result<(), Box> { conn.routed(server.as_entity_ref_with(demo::Route { hops: 3 }))?; // An FSM entity's events are transitions into its states. - // For now, the generated methods are like any other event. // Their cardinality is derived from the topology at build time. + // + // FSMs will get typestate pattern handles in the future, also see + // https://github.com/rapidsai/quent/issues/416 let mut query = context.query_observer().handle(); query.submitted("select 1".to_owned(), conn.as_entity_ref())?; query.running(10)?; diff --git a/crates/yaml/src/ast.rs b/crates/yaml/src/ast.rs index 2fc47f816..2f5d4eac1 100644 --- a/crates/yaml/src/ast.rs +++ b/crates/yaml/src/ast.rs @@ -38,8 +38,6 @@ pub(crate) struct Model { pub(crate) records: IndexMap, #[serde(default)] pub(crate) entities: IndexMap, - /// FSM entities, keyed by name: each declares an entity whose events are - /// its states. #[serde(default)] pub(crate) fsms: IndexMap, } @@ -65,8 +63,10 @@ pub(crate) struct StateSpec { pub(crate) initial: bool, #[serde(default)] pub(crate) attributes: IndexMap, - /// States this transitions to; the reserved name `exit` transitions the FSM - /// out of existence. + // States the FSM can transition to. + // + // "exit" is a reserved special name to mark a state as final before + // dissapearing from existence through the exit transition. #[serde(default)] pub(crate) to: Vec, } diff --git a/crates/yaml/src/lib.rs b/crates/yaml/src/lib.rs index 8848bc7dc..83c993819 100644 --- a/crates/yaml/src/lib.rs +++ b/crates/yaml/src/lib.rs @@ -10,9 +10,9 @@ use std::path::Path; use quent_constraints::validate; -use quent_fsm::{FsmConstraint, FsmError}; -use quent_ref_target::{RefTargetConstraint, RefTargetError}; -use quent_ref_tree::{RefTreeConstraint, RefTreeError}; +use quent_fsm::FsmConstraint; +use quent_ref_target::RefTargetConstraint; +use quent_ref_tree::RefTreeConstraint; use quent_schema::Schema; use serde_saphyr::{MessageFormatter, UserMessageFormatter}; @@ -85,13 +85,13 @@ pub fn parse_from_str(src: impl AsRef, source: Option<&str>) -> Result) -> Result { let src = std::fs::read_to_string(path)?; parse_from_str(&src, Some(&path.display().to_string())) } - -/// Report one diagnostic per ref-target violation, flattening `Multiple`. -fn ref_target_diagnostics(error: RefTargetError, sink: &mut Diagnostics) { - match error { - RefTargetError::Multiple(errors) => { - errors - .into_iter() - .for_each(|error| ref_target_diagnostics(error, sink)); - } - error => sink.error("", error.to_string(), None), - } -} - -/// Report one diagnostic per ref-tree violation, flattening `Multiple`. -fn ref_tree_diagnostics(error: RefTreeError, sink: &mut Diagnostics) { - match error { - RefTreeError::Multiple(errors) => { - errors - .into_iter() - .for_each(|error| ref_tree_diagnostics(error, sink)); - } - error => sink.error("", error.to_string(), None), - } -} - -/// Report one diagnostic per FSM violation, flattening `Multiple`. -fn fsm_diagnostics(error: FsmError, sink: &mut Diagnostics) { - match error { - FsmError::Multiple(errors) => { - errors - .into_iter() - .for_each(|error| fsm_diagnostics(error, sink)); - } - error => sink.error("", error.to_string(), None), - } -}