Files
secs-gem/docs/31_spec_as_data_and_codegen.md
T
raphael cae98d9a7d docs: chapters 30–36 — the codebase (Part 3 complete)
Seven chapters walking the implementation top-to-bottom.

30 — Repository tour.  Top-level layout, directory by directory.
The eight built binaries.  The dependency graph from TCP socket
up through EquipmentDataModel.  CMake's role.  Test layout.

31 — Spec-as-data and codegen.  Why the design choice fits SECS/
GEM specifically.  The five YAML files: messages catalog,
control/PJ/CJ transition tables, equipment dictionary.  How
tools/gen_messages.py turns messages.yaml into typed C++ at build
time.  The --validate-config multi-error validator.  How to add a
new SVID / CEID / host command / state / message without C++.

32 — Stores and the data model.  What a store IS (records + API +
change handler + optional persistence).  Every store in the
codebase mapped to the SEMI standard it serves (table of 21).
EquipmentDataModel as plain composition + cross-store convenience
methods (vid_value, compose_reports_for).  The no-locks single-
threaded contract.  How to add a new store.

33 — Transport.  hsms::Connection read path (length+payload async
chain), write path (queue + one outstanding write), timer model
(5 steady_timers + per-request T3).  The asio executor / strand
model and why it's the right shape.  secsi::Protocol as the IO-
free FSM with Action / Event variants; secsi::TcpTransport as the
asio adapter.  Pattern repeats for E84 + GEM comm-state.

34 — Codec and SML.  The four files (170 + 30 + 52 + 32 lines of
header, 229 + 220 lines of impl).  Item variant storage layout
(11 alternatives, 16 formats, shared storage where E5 permits).
encode_into recursion; decode_at with bounds checks throwing
CodecError.  Message wrapper.  SML printer + try_parse_sml +
why SML round-trips Items but not necessarily bytes.

35 — State machines and dispatch.  gem::Router as a typed
(stream, function) dispatch table.  How an S2F41 round-trip walks
through parser → store dispatch → side-effect → CEID emission →
S6F11 build → spool-aware deliver.  The 11 FSMs all sharing the
same three-property shape (pure data table + pure FSM + observer
pattern).  CEID cascading from FSM transitions to wire bytes.

36 — Persistence, validation, metrics.  Which 7 stores have file
journals + why the others don't.  Per-record file pattern (atomic
rename, partial-write safe).  Schema versioning + multi-version
read.  Multi-error YAML validator (--validate-config) + cross-file
reference checks.  Prometheus registry + HTTP exporter + worked
metric patterns from the PVD example.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 20:23:05 +02:00

396 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 31 — Spec-as-data and codegen
← [30 Repository tour](30_repository_tour.md) | [Back to index](00_index.md) | Next: [32 Stores and the data model](32_stores_and_the_data_model.md) →
The single design choice that keeps this codebase small is
**spec-as-data**: every SEMI behavioural rule, state-transition
table, and message body shape lives in YAML. The C++ is the engine
that reads them.
This chapter explains:
- Why spec-as-data is the right choice for SECS/GEM specifically.
- The five YAML files that drive everything.
- How the message catalog gets codegen'd into typed C++.
- How transition tables and equipment dictionaries load at runtime.
- How to add a new SVID / state / message / host command without
touching C++.
---
## Why spec-as-data here
Three properties of SECS/GEM standards push toward data-driven
implementation:
1. **Per-tool variation is enormous.** Every fab tool has its own
list of SVIDs, ECIDs, CEIDs, alarms. Hardcoding even one of
them in C++ would mean recompiling per deployment.
2. **The standards themselves are largely declarative.** E30 §6.2
is a 5×8 transition table, not an algorithm. E5 §9 is a
format-byte arithmetic. E40 §6 is a state graph. These map to
YAML cleanly.
3. **Customers need to audit the rules.** A fab QA team can read
`data/control_state.yaml` and see the transitions; they can't
read 600 lines of `if/else` and trust it the same way.
So the rule is: **anything a vendor or customer might want to
change without recompiling lives in YAML**. The C++ is the
runtime that reads it.
---
## The five YAML files
All under [`data/`](../data/).
### `messages.yaml` — the SECS-II message catalog
164 entries. Each one names a SECS-II message (SxFy) and describes
its body's typed shape. Used at **build time** by codegen.
Excerpt:
```yaml
- id: S1F1
stream: 1
function: 1
w: true
builder: s1f1
parser: parse_s1f1
body: none
- id: S1F2
stream: 1
function: 2
w: false
builder: s1f2
parser: parse_s1f2
body:
kind: list
struct_name: OnlineIdentification
fields:
- {name: mdln, shape: {kind: scalar, item_type: ASCII}}
- {name: softrev, shape: {kind: scalar, item_type: ASCII}}
```
The `body` field is the codegen's input. It supports:
- **`none`** — header-only, no body.
- **`scalar`** — one Item; the codegen picks an appropriate C++
parameter type from `item_type` (`ASCII`, `U1``U8`, etc.).
- **`list`** — fixed-arity `<L,k>` with named fields. Optionally
generates a `struct StructName { … }`.
- **`list_of`** — variable-arity `<L,n>` with a uniform element
shape.
The grammar is documented at the top of
[`tools/gen_messages.py`](../tools/gen_messages.py) and at the top
of [`data/messages.yaml`](../data/messages.yaml).
### `control_state.yaml` — the E30 §6.2 control state transition table
```yaml
transitions:
- {from: EquipmentOffline, on: operator_switch_online, to: AttemptOnline, then: OnlineRemote}
- {from: OnlineRemote, on: host_request_offline, to: HostOffline, ack: Accept}
- {from: OnlineLocal, on: host_request_remote, ack: NotAccept}
```
Loaded at **runtime** by `config::load_control_state_table`. The
default table — used by tests when no YAML is given —
mirrors this file exactly (in
`ControlTransitionTable::default_table()`).
### `process_job_state.yaml` — the E40 PJ transition table
Same shape as control_state.yaml but for PJs. Drives
`ProcessJobStateMachine`.
### `control_job_state.yaml` — the E94 CJ transition table
Same shape for CJs. Drives `ControlJobStateMachine`.
### `equipment.yaml` — the demo equipment data dictionary
Excerpt:
```yaml
device:
mdln: "SECS-GEM Demo Equipment"
softrev: "1.0.0"
capabilities: [Establish, OnLine, …]
svids:
- {id: 1, name: ControlState, units: "", type: ASCII, value: ""}
- {id: 2, name: Clock, units: "", type: ASCII, value: ""}
- {id: 3, name: WaferCounter, units: "wafers", type: U4, value: 0}
ecids:
- {id: 100, name: T3, units: "s", type: U4, value: 45, min: 1, max: 600}
ceids:
- {id: 100, name: ControlStateChange}
- {id: 300, name: ProcessStarted}
alarms:
- {id: 1, alcd: 0x84, text: "Chamber pressure above threshold"}
host_commands:
- {name: START, ack: Accept, emit_ceid: 300}
- {name: FAULT, ack: Accept, set_alarm: 1}
```
Loaded at startup by `config::load_equipment`. Every key under
this YAML maps to a typed struct in `config::EquipmentDescriptor`.
`examples/pvd_tool/equipment.yaml` is a more realistic version
with 29 SVIDs, 7 ECIDs, 21 CEIDs, 12 alarms.
---
## The codegen pass
`messages.yaml` is too large and too repetitive to write by hand —
164 messages × (builder + parser + struct + tests) would be ~5 k
lines of boilerplate. Instead, `tools/gen_messages.py` reads the
YAML at build time and emits one inline header:
**`build/generated/secsgem/gem/messages.hpp`**.
### What gets generated
Per message, the codegen emits:
```cpp
namespace secsgem::gem::messages {
// Optional struct if body has `struct_name`.
struct OnlineIdentification {
std::string mdln;
std::string softrev;
bool operator==(const OnlineIdentification&) const = default;
};
// Builder: takes typed params, returns a secs2::Message.
inline secs2::Message s1f1();
inline secs2::Message s1f2(const std::string& mdln, const std::string& softrev);
// Parser: takes a Message body, returns std::optional<Struct> (or the
// primitive type for scalar bodies).
inline std::optional<OnlineIdentification> parse_s1f2(const secs2::Item& body);
} // namespace
```
For ~160 named messages, the generated header is ~3 500 lines, all
inline. Tests in
[`tests/test_messages.cpp`](../tests/test_messages.cpp) (82 cases)
exercise every builder + parser round-trip.
### How CMake invokes it
CMakeLists.txt has a custom command:
```cmake
add_custom_command(
OUTPUT ${CMAKE_BINARY_DIR}/generated/secsgem/gem/messages.hpp
COMMAND ${Python3_EXECUTABLE}
${CMAKE_SOURCE_DIR}/tools/gen_messages.py
${CMAKE_SOURCE_DIR}/data/messages.yaml
${CMAKE_BINARY_DIR}/generated/secsgem/gem/messages.hpp
DEPENDS ${CMAKE_SOURCE_DIR}/data/messages.yaml
${CMAKE_SOURCE_DIR}/tools/gen_messages.py
)
```
Re-runs on `data/messages.yaml` edits *or* on
`tools/gen_messages.py` edits. Generated header goes into a
sibling include directory so the library can include it as
`#include "secsgem/gem/messages.hpp"`.
### Why Python rather than templates / constexpr
Three reasons:
1. **YAML parsing** — full grammar matters and `PyYAML` is more
reliable than yaml-cpp at parse-time gymnastics.
2. **Code shape control** — the generated C++ is easier to read
when generated by a textual templater than by C++ metaprogramming.
3. **Debuggability** — a customer who wants to see "what code is
actually being run for S2F33" can `grep` the generated header.
No mystery types, no instantiation chains.
The codegen is ~388 lines of Python; the input grammar is
documented at its top.
---
## Runtime loading
The other four YAMLs (`control_state`, `process_job_state`,
`control_job_state`, `equipment`) load at runtime, not build time.
The same loader handles all of them:
```cpp
// include/secsgem/config/loader.hpp
namespace secsgem::config {
EquipmentDescriptor load_equipment(const std::string& path);
ControlStateConfig load_control_state_table(const std::string& path);
ProcessJobStateConfig load_process_job_state(const std::string& path);
ControlJobStateConfig load_control_job_state(const std::string& path);
}
```
Each `load_*` returns a typed config struct on success or throws on
malformed YAML. Throwing is OK because YAML loading happens once
at startup — before binding the port — so a malformed file fails
the process up front.
---
## The `--validate-config` pass
YAML loaders that throw on first error are unfriendly: customers
often have multiple typos in a new equipment.yaml. The codebase
ships a multi-error validator:
```cpp
// include/secsgem/config/validate.hpp
class ConfigValidator {
public:
void validate_equipment(const std::string& path);
void validate_control_state(const std::string& path);
// ...
bool has_errors() const;
void format_issues_to(std::ostream&, ) const;
};
```
It tries to load each file, accumulates *every* issue it can find,
and prints them all. Then exits 0 or 1.
Invoke via:
```bash
secs_server --validate-config \
--config data/equipment.yaml \
--state-table data/control_state.yaml \
--pj-state-table data/process_job_state.yaml \
--cj-state-table data/control_job_state.yaml
```
This is proof #5 in [PROOFS.md](PROOFS.md) — runs in CI to
guarantee every shipped YAML is structurally + referentially
sound.
Tests:
[`tests/test_config_validate.cpp`](../tests/test_config_validate.cpp)
(8 cases — every category of validation issue).
---
## How to add a capability without C++
The point of spec-as-data is that **adding behaviour almost never
requires a C++ change**.
### New SVID
```yaml
# data/equipment.yaml
svids:
- {id: 4, name: ChamberTemp, units: "C", type: U4, value: 25}
```
Restart. Done. Host can now read SVID 4 via S1F3.
### New CEID with linked report
```yaml
# data/equipment.yaml
ceids:
- {id: 350, name: ChamberTempHigh}
events:
default_reports:
- {ceid: 350, vids: [4]}
```
Restart. Done. When the EAP fires CEID 350, the report carries
SVID 4 automatically.
### New host command
```yaml
host_commands:
- {name: VENT, ack: Accept, emit_ceid: 400, set_alarm: 2}
```
Restart. Done. Host sends `S2F41(RCMD=VENT)` → ACK=Accept,
CEID 400 fires, ALID 2 set.
### New control-state transition
```yaml
# data/control_state.yaml
transitions:
- {from: OnlineRemote, on: host_request_offline, to: HostOffline, ack: Accept}
```
Restart. Done.
### New SECS-II message
```yaml
# data/messages.yaml
- id: S6F30
stream: 6
function: 30
w: true
builder: s6f30_request
parser: parse_s6f30
body:
kind: list
struct_name: TempQuery
fields:
- {name: vid, shape: {kind: scalar, item_type: U4}}
```
`docker compose run --rm builder` regenerates `messages.hpp`. A
new `s6f30_request(uint32_t vid)` builder and a `parse_s6f30(item)
→ std::optional<TempQuery>` parser appear. Now the *handler* is
still C++ — `gem::Router::on(6, 30, ...)` — because the side-effect
of "host asked for the temperature" needs application logic.
---
## When spec-as-data isn't the right fit
Three categories that *do* need C++:
1. **Application logic** — what an alarm threshold actually is,
how a recipe step gets executed. No YAML schema can express
"vent the chamber if pressure > 1 Torr."
2. **State-machine actions** — when a CJ transitions to Executing,
*which* PJ to select next isn't a table entry; it's an
algorithm.
3. **External integrations** — talking to a PLC, reading a sensor,
driving a robot. Hardware bindings are vendor-specific code.
The codebase draws the line **at the message catalog and the
transition tables**. Everything below (codec, transport) is fixed
C++. Everything above (application wiring) is per-EAP C++.
Everything between (data dictionary + state model) is YAML.
---
## Where to go next
You now know how the YAML drives the runtime. The next chapter
gets concrete about the **stores** — the per-domain bundles
(SVIDs, CEIDs, alarms, carriers, …) that the YAML populates and
the Router handlers operate over.
Next: [→ 32 Stores and the data model](32_stores_and_the_data_model.md)