Files
secs-gem/docs/31_spec_as_data_and_codegen.md
raphael af1a159c59 docs: bring the documentation up to the daemon/client era
A large gap had opened between the docs and the code: the README and
INTEGRATION guide did not mention the gRPC daemon or the Python client at
all (the entire vendor surface), ARCHITECTURE still described secs_server
as the ~1200-line canonical wiring example (it is a ~110-line thin main
over EquipmentRuntime), and test counts across six files were stale
(445/2753 -> 473/3087 core + the separate 125-assertion daemon suite).

- README: new "Integrating your tool (pick a tier)" section — Python
  client / any-language gRPC / embedded C++ — plus daemon tests and
  tools/run_interop.sh in the Testing section.
- ARCHITECTURE: layer diagram gains the vendor-surface and
  EquipmentRuntime/default_handlers tiers; stale wiring row fixed.
- INTEGRATION: three-tier chooser up front (this guide = the C++ tier).
- ch30 tour: secs_gemd + secs_gemd_tests in the binaries table.
- ch31: example alarm used a nonexistent `alcd:` field with bit 7 set
  (which the validator forbids) -> real `category:`/`name:` fields, and
  the roles: block documented.
- ch35: handler-location note now points at default_handlers.cpp's 15
  per-capability register_* functions.
- ch40: built-artifacts list + sample output counts.
- ch50: secsgem::gem runtime/default_handlers/handler_slot/name_index
  includes + new secsgem::daemon namespace section.
- PROOFS: test-count table gains the runtime/handlers/daemon row so the
  tally adds up; daemon suite noted. VERIFICATION/COMPLIANCE counts.
- interop/README: the one-command runner + the two daemon-track harnesses
  (daemon_interop, pyclient_interop).

Audited via a docs-vs-code sweep (the audit itself under-reported: it
validated counts textually; reality was 473/3087).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 23:18:31 +02:00

406 lines
12 KiB
Markdown
Raw Permalink 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:
# `category` is the lower-7 ALCD severity bitmap; bit 7 (set/clear) is
# applied at emit time and must NOT be in YAML. `name` is an optional
# local key for name-based APIs (the gRPC daemon / Python client).
- {id: 1, name: pressure_high, category: 4,
text: "Chamber pressure above threshold"}
host_commands:
- {name: START, ack: Accept, emit_ceid: 300}
- {name: FAULT, ack: Accept, set_alarm: 1}
# Role bindings: which of the ids above the engine's built-in behaviours
# target (control-state/clock SVID refresh, CJ state CEIDs).
roles:
control_state_svid: 1
clock_svid: 2
```
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)