README §3 promised a monitoring story ("aggregate into Prometheus via
a sidecar that polls the data model"). Nothing shipped. Customers
running a real fab without a metrics pipeline find out about T7
storms, spool blowups, and stalled CJs after their MES does — not
the position you want SRE in.
This commit ships:
- include/secsgem/metrics/prometheus.hpp: header-only. A Registry
(counters + gauges + HELP/TYPE descriptions, label-keyed,
mutex-guarded so updates from the io thread and scrape renders from
the same io serialize cleanly) plus a PrometheusServer (asio
acceptor, replies to any GET with the text-exposition rendering,
no auth — drop nginx in front for that).
- tests/test_metrics_prometheus.cpp: 3 cases / 19 assertions.
Render counter+gauge with labels, scrape via raw TCP and parse the
HTTP body, verify live updates land on subsequent scrapes.
- INTEGRATION.md §6.4: worked example that pairs the exporter with the
Connection + EquipmentDataModel hooks documented in §6.1/§6.2.
Shows the wrap-around-handler trick for message counters, a 5s
polling timer for gauges (spool depth, active alarms), and the
expected /metrics output.
Deliberately *not* shipped:
- A StandardMetrics helper that auto-wires everything — would force
a single hook owner per store, breaking customers who want
composable observers. Customers wire what they need; the registry
gives them counters + gauges + an HTTP endpoint, no policy.
- TLS / auth on the HTTP endpoint. Reverse-proxy territory.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`actions/checkout@v4` is a JavaScript action — it expects `node` on
PATH in the runner image. Gitea Actions (and local `act`) running
against `ubuntu:24.04` had neither node nor git pre-installed, so
checkout failed with:
❌ Failure - Main actions/checkout@v4
exitcode '127': command not found
The pre-step now installs nodejs + git + ca-certificates from apt
before checkout runs. The rest of the C++ toolchain installs in a
second step after the source tree is on disk.
Doesn't affect GitHub-hosted runners (their images already have node);
doesn't change build behaviour either.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Customer SREs and capacity planners had nothing to point at.
INTEGRATION.md asked the right questions ("how many tx/sec?"
"how much memory per active CJ?") but had no numbers.
secs_bench spins up an in-process passive equipment + active host
on an OS-allocated port, runs three canned workloads, and emits a
markdown table customers can capture and diff across commits:
- S1F1/F2 header-only round-trip — dispatch + framing baseline
- S1F3/F4 with N SVIDs — encode + decode throughput
- S6F11 push (W=0) — one-way emission ceiling
- PJ + CJ pair memory footprint — bytes per active job
Latency reports p50/p95/p99/max via std::nth_element over the
sample vector. RSS is read from /proc/self/statm on Linux,
mach_task_basic_info on macOS.
CLI: --requests / --concurrency / --svid-count / --store-pairs.
Default 20k req @ 16 concurrent.
BENCHMARKS.md checks in a reference run (Docker on M-series
macOS): ~140k req/s S1F1, ~79k req/s S1F3 with 32-SVID list,
~572k S6F11/s push, ~450 bytes per PJ+CJ pair. Three orders of
magnitude headroom over typical fab tool load.
The doc is explicit about what the bench does NOT measure (real
network, persistence I/O, TLS tunnel overhead, multi-session GS
dispatch) — customers should re-run on their target hardware.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The existing loader throws ConfigError on the first problem it hits.
A customer with a tool-specific equipment.yaml that has six issues
sees one, fixes, restarts, sees the next, fixes, restarts — six
edit-restart cycles before the server even binds. Day-1 friction
is the top support ticket source in fab integrations.
This commit adds a parallel validator that does a separate read-only
pass and surfaces *every* issue at once:
$ secs_server --validate-config \
--config equipment.yaml \
--state-table control_state.yaml
[error] equipment.yaml:5 svids[0].type — unknown SECS-II type `WTF`
[error] equipment.yaml:7 alarms[0].category — value 200 out of range [0, 127]
[error] equipment.yaml:9 host_commands[0].emit_ceid — CEID 999 not declared in `ceids` section
3 error(s), 0 warning(s) across 4 files
What it catches:
- Missing required fields (device.model_name, .software_rev, …)
- Range violations (alarm category must be 0–127, spool streams 1–127,
device.id fits u16, etc.)
- Unknown enum values (SECS-II types, HCACK values, control/PJ/CJ
state and event names — using the right case + snake convention
the runtime parsers enforce)
- Duplicate IDs within svids / dvids / ecids / ceids / alarms,
duplicate PPIDs in recipes, duplicate command names in host_commands
- Referential integrity: host_commands[*].emit_ceid must exist in
ceids; host_commands[*].set_alarm must exist in alarms;
emit_on_control_change must exist in ceids
- PJ-table-specific: `NoState` sentinel rejected as `initial`,
`from`, or `to` (matches loader's existing runtime check)
- yaml-cpp Mark → 1-based line numbers when available
What it doesn't catch (out of scope this round):
- JSON Schema for editor red-squigglies (future)
- Deep semantic checks across state-table reachability
- ECID min/max value parsing (would need numeric type coupling)
Tests cover: clean file passes; multi-error YAML surfaces every issue
on a single pass; line numbers populate; control_state /
process_job_state / control_job_state casing conventions;
format_issues_to renders both severities; the shipped
data/equipment.yaml etc. validate cleanly (regression tripwire if
anyone breaks the demo configs).
INTEGRATION.md §2.3 calls out the flag and suggests CI use.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Hard blocker for any fab customer's procurement / legal review —
without a LICENSE in the repo they couldn't even begin evaluation,
because permission to read the source is itself something the
copyright holder has to grant.
This license grants nothing by default. Viewing the repo is the
only implicit allowance; everything else (compile, evaluate,
benchmark, deploy, sublicense, train ML on, reverse-engineer)
requires a separate written agreement with r.maenle@gmail.com.
Explicitly *not* granting the carve-outs that open-source licenses
imply: no fair use, no internal evaluation, no academic research,
no demo, no production deployment. Customers who want any of those
need to talk to Raphael first.
SPDX-License-Identifier: LicenseRef-Proprietary for tooling.
README header gains a license callout pointing at the file and
contact email so anyone landing on the GitHub frontpage sees the
restriction before reading further.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
README §6 claimed bidirectional forward-compat for journal records.
Reality is narrower:
- ProcessJobStore (kVersion=2) and SubstrateStore (kVersion=2) accept
v1 records on replay — their loaders explicitly switch on the version
byte and treat the v2 trailer fields as empty when absent. This is
the actual upgrade path the README half-described.
- ControlJobStore, CarrierStore, LoadPortStore, ExceptionStore, and
SpoolStore use strict `header[1] != kVersion` rejection. A future
kVersion bump there without a matching loader-side dispatch would
silently nuke every replayed record. The README sold this as a
feature; it isn't yet.
This commit adds:
- tests/test_persistence_upgrade.cpp: five cases that craft journal
records byte-by-byte so format drift is caught (no codec round-trip
hiding the field layout). PJ v1 -> v2 read; PJ v1 rewrite stamps
current kVersion=2; PJ unknown future version rejected; Substrate
v1 read with empty history trailer; CJ + Carrier reject unknown
versions (tripwire for the strict-version stores).
- README §6: replaces the rosy "newer versions ignore unknown
trailers" claim with what's actually implemented — multi-version
reads on PJ + Substrate, strict equality elsewhere — and points
at the test as the contract anchor.
When the strict-version stores grow their own v2, the rejection
tests will need to flip to acceptance; the layout is right there in
the test so the edit is mechanical.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
INTEGRATION.md §3 used to show a sensor-poll thread calling
model->svids.set_value() directly while the io_context thread reads
the same SVID for an inbound S1F3. That's a data race — there are
zero locks anywhere in EquipmentDataModel and there's no intention
to add them. The library is single-threaded by design; the doc was
just inviting trouble.
This commit makes the actual contract explicit:
- INTEGRATION.md §3: thread-safety callout box. All access must run
on the io_context that drives the HSMS connection. Sensor updates
from other threads marshal via asio::post(io.get_executor(), ...).
Same applies to set_*_change_handler callbacks (they fire on the
io_context thread; observers must be thread-safe or hand work off).
- README.md §3 (Monitoring & observability): added a paragraph noting
that hooks fire on the io_context thread, blocking I/O inside a
handler stalls the dispatcher, and metrics exporters must respect
the same contract.
- tests/test_thread_safety.cpp: two scenarios that exercise the
canonical pattern — N producer threads asio::post sensor updates
onto a worker-driven io_context; reads marshal back through the
io. Catches obvious regressions (e.g. someone adding a
"convenience" cross-thread mutator that bypasses the strand).
A passing run isn't proof of race-freedom under ThreadSanitizer —
it pins down the *pattern* customers should follow. TSan integration
is a separate workstream.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The E84StateMachine timers landed last commit but stayed theoretical —
arming was delivered via abstract callbacks the application had to
glue to a real clock. This commit ships the canonical glue:
- include/secsgem/gem/e84_asio_timers.hpp: header-only
E84AsioTimers wraps three asio::steady_timers, wires set_timer_handlers
on attach(), routes async_wait expiry back into fsm.on_timeout().
detach() cancels everything cleanly.
- tests/test_e84_asio_timers.cpp: four scenarios exercised through a
real asio::io_context with wall-clock timers — TA1 expiry,
signal-driven cancel before TA1 fires, TA3 expiry from the
Transferring state, and detach() halting further transitions.
These cover the integration the synthetic unit tests in
test_e84_timers.cpp can't reach.
- INTEGRATION.md §4.6: the vendor-side recipe — create the port,
set timeouts, make_shared<E84AsioTimers>(...)::attach(), feed signals
from your I/O bridge.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
E84StateMachine had the full signal-level handshake but no timer
enforcement. In a real AMHS that's a deadlock: if equipment is slow to
assert L_REQ / U_REQ, or AMHS is slow to assert BUSY / COMPT, neither
side notices — the wires just sit stuck. SEMI E84 §6 mandates three
timers that bound each leg of the dance.
TA1 — armed in ValidAsserted, cancelled in Load/UnloadReady.
AMHS bounds how long equipment takes to acknowledge VALID.
TA2 — armed in Load/UnloadReady, cancelled in Transferring.
Equipment bounds how long AMHS takes to start the transfer.
TA3 — armed in Transferring, cancelled on Complete.
Equipment bounds the BUSY-phase duration.
The FSM stays I/O-free (it's the design invariant): arm/cancel are
delivered via callbacks, the application owns the asio::steady_timer,
and the application calls `fsm.on_timeout(id)` when its real clock
fires. Stale on_timeout calls (post-cancel race) are no-ops.
On expiry, the FSM transitions to a new `HandoffFault` state, records
the `E84Fault` reason, fires the optional fault_handler, and latches
the fault until `reset()`. Signal jitter on the wires cannot silently
clear a recorded handshake timeout — once you've crossed the timer,
you stop.
Defaults are all-zero, which disables arming. This is what every
existing test relies on, and what back-to-back simulation (no
wall-clock) needs. Production tools call `set_timeouts({2s, 2s, 60s})`
or whatever their port spec dictates.
12 new test cases / 59 assertions: arming per state, cancelling per
exit, expiry-to-fault for all three timers, ES cancels everything,
stale-expiry no-op, fault latching across signal jitter, and a
full-cycle arm/cancel trace.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The closest thing to an in-repo "RTS" — a runnable executable that
points at any HSMS-SS equipment and walks through every E30
fundamental + additional capability, reporting pass/fail per check
and exiting with the right code for CI / canary use.
build/secs_conformance --host <ip> --port 5000 --device 0
Each check sends a host-initiated primary and asserts the equipment
replies with the expected stream/function within T3. Checks chain
forward through async callbacks (each reply handler kicks off the
next check) so the conformance run stays inside one io.run().
Initial check set (mirrors COMPLIANCE.md §3 fundamentals):
E37 §7.2 SELECT handshake
E30 §6.5 S1F13/F14 Establish Comms
E30 §6.7 S1F1/F2 Are You There
E30 §6.13 S1F11/F12 SVID Namelist
E30 §6.16 S2F29/F30 ECID Namelist
E30 §6.20 S2F17/F18 Clock
E30 §6.14 S5F5/F6 List Alarms
E30 §6.17 S7F19/F20 PP List
E30 §6.10 S1F19/F20 GEM Compliance
Validated against the demo server: 9/9 PASS.
README.md §8 (Compliance + certification) updated to point at the
harness as the suggested first-line conformance check. Tool
vendors fork apps/secs_conformance.cpp and add their own
capability-specific checks alongside.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
implementation_plan.md was a Layer-0..6 roadmap from the project's
spec-as-data exploration phase; every layer it described is now
shipped (Layer 0 foundations through Layer 4 message catalog +
state machines). Removed.
README rewritten for the fab-deployment audience. Sections added:
1. Persistence directory layout (storage rules, disk budget, DR)
2. Security (network isolation, TLS tunnels, audit logging,
config signing)
3. Monitoring + observability (signals → hooks table, Prometheus
pattern)
4. High availability (active/standby on shared persistence)
5. Deployment patterns (Docker / systemd / k8s)
6. Upgrade path (YAML reload, code rollout, schema versioning)
7. Integration with the fab stack (MES / AMHS / OHT / recipe
engine table)
8. Compliance + certification (fork COMPLIANCE.md per tool, run
RTS)
9. Testing in production (canary, synthetic transactions, shadow
traffic)
10. Operational runbook (incident → first check → mitigation)
Stale stats refreshed: test count went 148/794 → 384/2390;
catalog grew to 164 messages; HSMS-GS, SECS-I T3/T4, per-port E84,
E42 formatted PPs all mentioned.
COMPLIANCE.md §9 lost its stale `implementation_plan.md` reference.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
E42 was an explicit out-of-scope item in the prior COMPLIANCE.md.
This commit closes it.
Wire messages added via the catalog:
S7F23 Formatted PP Send (H↔E, W=1)
S7F24 Formatted PP Ack (ProcessProgramAck)
S7F25 Formatted PP Request (PPID, W=1)
S7F26 Formatted PP Data (E→H, no reply)
Body shape: <L,4 PPID MDLN SOFTREV <L,n <L,2 CCODE <L,m <L,2
PNAME PVAL>>>>>. PVAL is declared ITEM so any SECS-II Item type
round-trips — proven by a test that mixes ASCII, BOOLEAN, U4, F8,
Binary, and nested List values in one step.
RecipeStore extension:
add_formatted(ppid, FormattedRecipe{mdln, softrev, steps})
get_formatted(ppid) -> optional<FormattedRecipe>
has_formatted(ppid) -> bool
Formatted + opaque views live alongside each other: a PPID can carry
both, size() counts unique PPIDs. remove() kills both views.
Six new tests cover wire round-trip per function, every
ProcessProgramAck code, ITEM passthrough, and the store's dual-view
semantics.
COMPLIANCE.md updated: E30 §6.17 row mentions S7F23-F26, S5 message
table grows two rows, §8 "out of scope" entry for E42 removed.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The SECS-I Protocol FSM now enforces T3 (reply timeout) and T4
(inter-block timeout) directly, instead of leaving them as
upper-layer hooks.
T3: on complete_send, if the block we just acked had W=1, record its
system_bytes in awaiting_reply_sys_ and emit ActionStartTimer{T3}.
deliver_recv cancels T3 when a block arrives whose system_bytes
match the outstanding request. EventTimeout{T3} aborts the FSM with
"T3 reply timeout".
T4: deliver_recv emits ActionStartTimer{T4} whenever the delivered
block has end_block=false. The next block's deliver_recv cancels
the timer; EventTimeout{T4} aborts with "T4 inter-block timeout".
abort() now also cancels T3/T4 and clears the tracking state.
Test changes:
- Old "T3/T4 are FSM-level no-ops" test → REPLACED by four new
tests: T3 arm+expire, T3 arm+matching-reply cancels, T4
arm+expire, T4 arm+next-block cancels.
- Two new observer accessors on Protocol (awaiting_reply,
awaiting_next_block) so the tests can assert tracking state
without poking internals.
COMPLIANCE.md §1a: T3 + T4 rows go ⬜ → ✅.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
E84 (Parallel I/O) is fundamentally per-load-port: each port has its
own ten-wire handshake with the AMHS. Earlier revisions modeled it
as a single equipment-wide FSM; this commit refactors to a per-port
store, so multi-LP tools can run independent handshakes in parallel.
Public API change in EquipmentDataModel:
E84StateMachine e84; -> removed
E84PortStore e84_ports; // create(port_id), get(port_id), ...
Convenience pass-throughs: E84PortStore::on_signal_change auto-creates
the port on first use (ergonomic for demos); applications should call
create() explicitly with their full port set.
The two existing callsites (test_gem300_scenario, test_e87_wire_scenarios)
are updated. The multi-LP test now demonstrates the actual win:
interleaved LP1 load + LP2 unload handshakes that reach their
respective Ready states without sequencing, and an ES on LP1 that
does NOT affect LP2 — exactly the failure mode the previous design
couldn't catch.
Five new dedicated tests in test_e84_ports.cpp for the store itself.
COMPLIANCE.md §4i updated: row now reflects per-port design.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds the last terminal-services message: a multi-line broadcast push
to all terminals, no reply. Same TID+lines body as S10F5, W=0.
Generated via the catalog: data/messages.yaml schema entry +
auto-generated s10f7_terminal_display_broadcast / parse_s10f7.
Test round-trips TID and a 3-line broadcast through the builder
and parser, confirms W=0.
COMPLIANCE.md updated: S10F7 row in §5 added; §8 "out of scope"
entry removed.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Connection now supports both HSMS-SS (single session — the
constructor's behaviour, unchanged) and HSMS-GS (multi-session).
add_session(device_id) registers additional sessions; each one has
its own NotSelected/Selected state and its own message/selected
handlers. In GS mode the Select.req carries session_id=device_id;
in SS mode it stays at 0xFFFF (legacy). Linktest/Separate remain
connection-scope per spec.
Public API additions:
add_session(device_id)
set_session_message_handler(device_id, h)
set_session_selected_handler(device_id, h)
session_state(device_id) -> State
is_session_selected(device_id) -> bool
send_request(device_id, msg, cb)
send_data(device_id, msg)
Internal refactor: state_/on_message_/on_selected_ folded into a
SessionSlot map keyed by device_id; SS-style getters/setters route
through the primary session. T7 + linktest are connection-scope —
T7 fires only when no session is selected; linktest runs while at
least one is.
Five wire-level tests:
- passive: two sessions selected independently via Select.req
with their own session_id
- GS Select.req for an unregistered session id is Rejected
(EntityNotSelected)
- data routed by session_id; data on a not-selected session is
Rejected
- active: two registered sessions both end up selected via
serialized Select.req per session
- SS legacy: existing single-session API still works (session_id
0xFFFF in Select.req)
COMPLIANCE.md §1 updated: HSMS-GS row goes ⬜ → ✅.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Per-substrate transition history now survives restart. Each entry's
steady_clock timestamp is written as a system_clock-millis snapshot;
on replay the steady_clock time_point is reconstructed relative to
the current (steady_now, system_now) pair, so inter-event spacing
is preserved across restarts even if the FSM is in a different
process. Absolute wall-clock accuracy degrades by any NTP step
that happened between write and read; that's a documented caveat.
Record format goes v1 → v2. v1 (history-less) records still load,
just with empty history.
Test updates:
- the old "history is NOT journaled" test is REPLACED with one
that asserts every axis + event + label round-trips.
- hand-crafted v1 record on disk still loads (proves backwards
compat).
- 15 ms-spaced events restore with their spacing intact (±slop
for scheduler jitter).
Closes the "substrate history persistence" caveat from the post-#1-13
status writeup.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes the v1 caveat: the optional E40-0705 trailers on S16F11 —
recipe variables (RcpVar) and process parameters (ProcessParam),
each carrying a secs2::Item value of arbitrary type — now survive
restart.
Record format bumps to v2:
v2 header = v1 header
+ [u16 rcpvar_count][repeat: u16 name_len, name, u32 enc_len,
secs2::encode(value)]
+ [u16 ppparam_count][...same shape]
v1 records are still accepted by load_record_ (no extras come back).
Two new tests:
- round-trip mixed F4 / ASCII / U4 / nested-list values through
rcpvars + prprocessparams
- hand-crafted v1 record on disk still loads cleanly, just with
empty extras (proves backwards compat)
Closes the "PJ rcpvars / prprocessparams persistence" caveat from
the post-#1-13 status writeup.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
End-to-end guide for an equipment vendor integrating the library
into a real semiconductor tool:
1. Architecture: what the runtime provides vs what the application
contributes — three boundary classes (EquipmentDataModel,
Router, hsms::Connection).
2. 30-minute first connection: YAML + minimal main() + run.
3. Wiring real sensors to SVIDs.
4. Plugging the FSMs into the tool: EPT, carriers, substrates,
E40 PJ / E94 CJ, alarms, recoverable exceptions.
5. Persistence: enable_persistence(dir) per store, storage budget,
replay semantics, current caveats.
6. Monitoring + observability: connection lifecycle hooks,
state-change handlers, S9 protocol errors.
7. Recommended deployment layout (/opt/acme-secsgem/...).
8. Integration testing checklist.
9. When to extend the runtime.
10. The honest gap between "this stack runs" and "this is a
certified GEM tool".
Cross-referenced from COMPLIANCE.md §9 distinction (stack vs tool).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Real GEM sessions don't serialize requests — the host can have many
primaries outstanding, replies may arrive in any order, and both
peers can talk at once. Connection demuxes via system_bytes per
E37 §8.3; this commit pins the behaviour with four wire tests:
- 5 in-flight requests; equipment buffers all primaries before
replying — proves Connection holds the pending map correctly
even when no replies are coming.
- 7 pipelined primaries with synchronous in-handler replies;
every host callback fires with the correct function and stream.
- Bidirectional in-flight: host issues 3 primaries while equipment
issues 3 of its own; all 6 callbacks resolve with the right
replies.
- 100-burst sequential cycle; confirms the pending_requests_ map
doesn't leak entries (every reply delivered ⇒ map drained).
Closes#13 in the test-gap backlog.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SEMI E5 allows identifier fields (DATAID, RPTID, VID, CEID, ALID,
EXID, OBJID, …) to be encoded as U1, U2, U4, or U8. Our parsers
route through any_unsigned_first<T> in messages_helpers.hpp. The
existing per-message round-trip tests prove the U4 path; this
commit adds the cross-width matrix that the interop incident with
secsgem-py demanded:
- as_u4_scalar accepts U1/U2/U4/U8 inputs for the same value
- as_u8_scalar accepts every narrower width
- as_u1_scalar accepts wider widths when the value fits
- as_u1_scalar / as_u2_scalar REJECT out-of-range values rather
than silently truncating
- codec round-trip preserves the format byte AND the value
- signed counterparts (as_i4_scalar) follow the same rule for I1/I2
If a future code-gen change hard-codes a single width on any
identifier field, the rejection case here breaks loudly.
Closes#12 in the test-gap backlog (renumbered: this is gap entry
"identifier wildcard matrix").
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Four new test cases:
* S3F19 verify with matching map → SlotMapVerifyAck::Accept and
CSMS lands in Read on the equipment side.
* S3F19 verify with disagreeing map → Mismatch ack and CSMS lands
in Mismatched.
* 4 LPs + 4 carriers, host verifies CAR-1 (mismatch) and CAR-3
(match) — only those two carriers move on the CSMS axis;
CAR-2/CAR-4 stay NotRead. Confirms per-carrier independence.
* Multi-LP E84 handshake sequencing (load then unload) round-trips
through Idle. Documents that the current E84StateMachine is
per-equipment, not per-port — a future per-port FSM would
update this test alongside.
Closes#11 in the test-gap backlog.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
test_gem300_scenario.cpp drives EquipmentDataModel in-memory. This
companion test does the same lifecycle through actual hsms::Connection
frames on a loopback socket pair:
S1F13/F14 establish comm
S3F17/F18 carrier action ProceedWithCarrier (E87)
S16F11/F12 process job create (E40)
S14F9/F10 control job create (E94)
S16F27/F28 CJSTART → CJ → Executing
S6F11 ControlJobExecuting CEID auto-emitted on transition
CJ → Completed via internal AllJobsComplete
EquipmentEmulator owns the data model + a passive Connection,
registers state-change handlers that synthesize S6F11/S16F9 on
transitions, and dispatches the inbound primaries above. HostEmulator
wraps the active Connection and captures everything the equipment
sends unsolicited.
This is the wire-level equivalent of the existing in-memory scenario,
which closes the gap between "FSM works" and "full GEM 300 stack
works on a wire".
Closes#10 in the test-gap backlog.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
FSM unit tests already verified state transitions fire the change
handler — but they don't prove the frame leaves the socket with the
right CEID and linked report payload. This commit wires a passive
equipment Connection to an EquipmentDataModel via a small emitter,
drives transitions, and asserts on what the host peer receives.
Six new tests:
EPT → Productive ⇒ S6F11(kCeidProductive) with the linked report
EPT (no subscription) ⇒ no S6F11 (proves disable gate)
PJ Queued→SettingUp ⇒ S16F9 PRJobAlert with PRJOBID + state byte
PJ alert_enabled=false ⇒ no S16F9 (per-PJ gate works)
CJ → Executing ⇒ S6F11(ControlJobExecuting) on the wire
Substrate StartProcessing ⇒ S6F11(SubstrateInProcess) on the wire
All use the generated parse_s6f11 / parse_s16f9 to decode the
incoming frame and assert against typed fields (CEID, PRJOBID, etc.)
rather than poking variant internals — that ties the test to the
schema-as-data rather than to wire byte offsets.
Closes#9 in the test-gap backlog.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Deterministic-seed fuzz coverage of the byte-decoding surface:
- secs2::decode on 2000 random buffers
- secs2::decode on every truncation of a real encoding + 500
one-byte flips of the full encoding
- hsms::Frame::decode on 1000 random payloads
- hsms::Header::decode on 2000 random 10-byte buffers
- secsi::Block::decode on 2000 random buffers
- secs2 encode/decode round-trip identity across a battery of every
Item factory (List, ASCII, Binary, Boolean, U1..U8, I1..I8, F4/F8,
nested List)
- oversize <A 3 length-bytes> length-prefix doesn't allocate GBs
- 64-level nested List round-trip doesn't blow the stack
Contract is binary: no crash, no UB. Each decoder is allowed to throw
or return whatever; we deliberately don't assert *what* result comes
back, only that control returns. Fixed PRNG seeds make any failure
reproducible from the CI log alone.
Closes#8 in the test-gap backlog.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
test_secsi.cpp covered T2 on the send side (retry) and a tick-based
back-to-back exchange. This commit fills in the rest of the timer
matrix at FSM level:
T1 in RecvBlock → abort, reason mentions "T1"
T1 outside RecvBlock → ignored
T2 in RecvEotSent → abort
T2 in RecvBlock → abort (mid-block stall)
T3 / T4 → FSM-level no-op (documented as upper-layer driven)
T2 contrast → send-side retries, recv-side aborts (same timer,
different recovery, both demonstrated in one test)
If a future commit moves T3 or T4 enforcement into the FSM, the
no-op test breaks loudly so protocol.hpp can be updated alongside.
Closes#7 in the test-gap backlog.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
S9F3/F5 are covered by test_s9_fallback (router path); S9F9/F11 by
test_hsms_timers (timer/over-length). This commit adds S9F7 wire-level
tests for the third path — a primary whose body fails secs2::decode.
Three new cases:
- hand-built primary with truncated <B> body provokes S9F7
carrying the original 10-byte MHEAD (sys + stream + function)
- emission is non-fatal: the next well-formed primary still routes
to the registered handler
- data-while-NOT-SELECTED still echoes Reject(EntityNotSelected)
(sanity copy of the test_hsms_connection case so the "what does
the equipment say when a peer sends garbage" family lives together)
Closes#6 in the test-gap backlog.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Real-socket tests for the timer family in E37 §10 — these replace
the "the timer fires somewhere" implicit assumption with
end-to-end observations on a loopback pair:
T3: send_request that gets no reply emits S9F9 with the original
MHEAD echoed in the body and surfaces Timeout to the caller.
T6: active mode whose Select.req goes unanswered self-closes
with a "T6 timeout on Select" reason.
T7: passive mode that never receives Select.req self-closes
with a "T7 not-selected timeout" reason.
T8: peer sends only the 4-byte length prefix; T8 expires mid-read
and closes with "T8 intercharacter timeout".
Plus S9F11 emission for an over-length frame (length prefix of
1 GiB+1) — body's <B 10> echoes the offending bytes verbatim.
Per-test timer profiles (only the timer under test is short, the
rest are 5s) so the FSM isn't racing against unrelated timers.
Closes#5 in the test-gap backlog.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Per-EXID binary record (.ex), magic + version + atomic .tmp+rename.
Records full E5 §9 lifecycle: state, EXID, EXTYPE, EXMESSAGE, and
the candidate EXRECVRA list.
Cleared exceptions are terminal — the FSM transitions through
Cleared remove the in-memory entry AND delete the journal file
(matching the existing in-memory semantics). Recovering /
RecoverFailed states survive restart: the application can decide
on replay whether to retry recovery or abort.
Five new tests cover post+replay, Recovering-survives-restart,
autonomous-clear cleanup, RecoverFailed retry post-restart, and
corrupt-record drop.
This completes #12 in the test-gap backlog (persistence for the four
in-memory stores beyond Spool).
Closes#4 in the test-gap backlog.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Per-job binary record (.pj / .cj) with magic+version, atomic
.tmp+rename. PJ store additionally writes an order.idx index file
that preserves HOQ-aware queue position across restarts.
Rcpvars / prprocessparams (secs2::Item variants) are intentionally
out of scope for v1 — they're optional E40 trailers and need a body
codec round-trip; callers re-populate via set_e40_extras() after
restart.
Five new tests cover full lifecycle replay (Processing mid-run +
HOQ-reordered queue), dequeue-deletes-file, corrupt-record drop,
CJ state + PJ-list replay, and CJ remove cleanup.
Closes#3 in the test-gap backlog.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Same pattern as carriers: per-substrate binary record (.sub) with
atomic .tmp+rename, replay on enable, delete on remove. Records
current state across all three E90 axes (location / processing /
ID-status), plus substid / carrierid / slot / free-form location
label. History is deliberately NOT journaled — it's an in-memory
ring buffer and rebuilding from replayed state would mislead.
Five new tests cover full-axis replay, every terminal processing
state, remove-deletes-journal, corrupt-record drop, and the
history-is-transient invariant.
Closes#2 in the test-gap backlog.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Mirrors SpoolStore: per-record file with atomic .tmp+rename, magic+
version-prefixed binary layout, replay on enable, delete on remove.
FSMs gain a restore_state() that bypasses the transition table and
handlers since a replay isn't a transition.
Six new tests cover write+restart+replay across every CIDS/CSMS/CAS
axis, remove-deletes-journal, malformed-record drop-not-poison, and
the persistence-disabled no-op path.
Closes#1 in the test-gap backlog.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
A host couldn't drive the new messages through the HostHandler class —
only the server side knew how to dispatch them. Adds six new senders
plus a unit test that walks each through a real loopback connection:
* send_legacy_remote_command -> S2F21
* send_event_report_request -> S6F15
* send_individual_report_request -> S6F19
* send_annotated_report_request -> S6F21
* send_pp_load_inquire -> S7F1
* send_delete_pp -> S7F17
Suite: 296 cases / 1571 assertions.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds round-trip checks for the SECS-II messages added in the AA
catalog-growth commit but never cross-validated against secsgem-py:
* S2F21/F22 — legacy remote command (no params). secsgem-py's
stock S2F21 sends with W=0; we register a W=1 override so the
transaction awaits our S2F22 reply. Also widens CMDA's allowed
types to include Binary (secsgem-py 0.3.0 declares CMDA as
Dynamic[U1, I1] only; SEMI E5 §10.18 says Binary, and our server
emits it that way).
* S6F15/F16 — event-report request by CEID.
* S6F19/F20 — individual report request by RPTID.
* S6F21/F22 — annotated individual report request.
* S7F1/F2 — PP load inquire.
* S7F17/F18 — PP delete.
Suite is now 32 named host-vs-server checks — all green in three
consecutive runs.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a docker-compose service `server-spool` that runs secs_server
with --spool-dir pointed at a named volume. Two-phase Python
harness (interop/spool_persistence_test.py):
1. Enqueue phase: force-spool one S6F11(CEID=300) via the
SPOOL_ON / START / SPOOL_OFF RCMD trio, then disconnect.
2. Driver runs `docker compose restart server-spool` between
the phases — the named volume preserves the journal files.
3. Drain phase: reconnect, send S6F23(Transmit), verify the
replayed S6F11 carries CEID 300.
Surfaces a real interop bug along the way: secsgem-py 0.3.0 encodes
RSDC (and other "single-byte status" fields) as <U1>, while SEMI E5
spells them as <B>. Our `as_binary_first` was strict on Binary; now
accepts either (the byte semantics are identical, and the leniency is
symmetric with the U-type widening from the first interop commit).
Result: enqueue → docker restart → drain returns CEID 300 cleanly.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes the test gap for messages I added but whose reply parsers were
only generated, never exercised:
* S6F8 — full nested DATAID/CEID/DS/DV structure.
* S12F14 — row-format map reply (RSINF tuples).
* S12F16 — array-format map reply.
* S12F18 — coordinate-format map reply.
Suite: 295 cases / 1545 assertions.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Cross-validates the GEM 300 streams secsgem-py 0.3.0 doesn't ship
(S3 carriers, S14 control jobs, S16 process jobs) by minting custom
`SecsStreamFunction` subclasses on the fly and registering the
matching `DataItem` definitions (CARRIERID, CTLJOBID, PRJOBID, PRCMD,
CTLJOBCMD, MF, …) with `secsgem.secs.data_items`.
Drives the C++ passive server through:
* S3F17/F18 (E87 carrier action) — server replies CarrierIDUnknown
for the unregistered carrier.
* S16F5/F6 (E40 PRJobCommand) — server returns InvalidObject
for the nonexistent PJ.
* S16F27/F28 (E94 CJobCommand) — server cascades CJSTART.
Scope cut: S16F11 full-body and S14F9 (both have variable-length
nested lists with named scalar elements) hit a quirk of secsgem-py's
SFDL tokenizer where `< L name > <SCALAR> >` parses as a fixed-1
list, not a variable-length list of SCALARs. The full-body S16F11
is already round-tripped by the C++ unit tests (and via secsgem-py's
host driver in `host_vs_cpp_server.py`), so the raw harness focuses
on the no-variable-list messages where the SFDL grammar cooperates.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The doc was last updated when only E40 + E94 were in tree. Brings it
up to date with everything actually implemented:
* New §4a–§4k tables for each GEM 300 standard: E40, E94, E87, E90,
E116, E120, E148, E157, E84, E5 §13 wafer maps, and the exception
recovery extension (S5F13–F18 + ExceptionStateMachine).
* Refreshed message coverage matrix to all 149 catalog entries
(added S1F23/F24, S2F21/F22, S6F5–F8/F15–F22, S7F1/F2/F17/F18,
S10F3/F4, S12F9–F18).
* Updated test count to 291 cases / 1515 assertions.
* New §7 documents the secsgem-py interop harness (24 host-side
checks + raw-GEM300 round-trip).
* §8 trimmed: persistent spool is no longer "out of scope" (CC1
landed); E40/E87/E90 removed from "Layer 5 follow-on" list since
they're done.
* §9 honesty pass — every GEM 300 standard in scope now implemented
end-to-end; the remaining gap is third-party RTS certification +
per-vendor application wiring.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds opt-in disk persistence to SpoolStore. `enable_persistence(dir)`
turns every enqueue into a single `<seq>.spool` file alongside the
in-memory queue; drain and clear delete the matching files; restart
replays the directory sorted by seq.
Writes are atomic: serialize the message via the SECS-II codec, write
to `.tmp`, then `std::filesystem::rename` to the final name. Malformed
records are dropped silently so a single bad file can't poison the
whole spool.
`secs_server --spool-dir <path>` enables persistence at startup.
Without the flag the behaviour is identical to before (in-memory only).
Two new tests: enqueue → restart → replay → drain restores the wire
order, and clear deletes the journal files.
Test suite: 291 cases / 1515 assertions.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replaces the simplified <L,3 PRJOBID PPID MTRLOUTSPEC> demo body with
the full SEMI E40-0705 §10.2 shape:
<L,5 PRJOBID MF PRRECIPEMETHOD
<L,2 PPID <L,n <L,2 RCPPARNM RCPPARVAL>>>
<L,n MTRLOUTSPEC>
<L,n <L,2 PARAMNAME PARAMVAL>>>
ProcessJob now carries the extra fields (MaterialFlag, ProcessRecipeMethod,
RcpVar[], ProcessParam[]) so a tool's recipe engine can later consume
the recipe-variable overrides and per-job process parameters. Server
S16F11 dispatch populates them via the new ProcessJobStore::set_e40_extras
helper after a successful create.
MaterialFlag + ProcessRecipeMethod enums live in their own tiny header
(`e40_constants.hpp`) so process_jobs.hpp (the store) can use them
without dragging in messages_helpers.hpp (which would create a circular
include via data_model.hpp).
The simplified 3-arg HostHandler::send_create_process_job convenience
remains; it constructs a sensible-default PRJobCreateRequest internally.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds the SECS-II messages secsgem-py 0.3.0 ships but our C++ catalog
didn't have, plus the alternative wafer-map formats from E5 §13.
None of these were strictly required for GEM core compliance, but
they're the messages a host might send to a conformant equipment.
* S7F1/F2 — Process Program Load Inquire / Grant. Equipment-side
space-and-policy check before a host commits to S7F3.
* S7F17/F18 — Delete Process Program. Empty list = delete-all.
* S6F5/F6 — Multi-block Data Send Inquire / Grant (with MultiBlockGrant
enum: Ok/Busy/NoSpace/DuplicateMsg/BadMsg).
* S6F7/F8 — Data Transfer Request / Send. Host pulls a DATAID;
equipment replies with the nested DS/DV structure.
* S6F15/F16 — Event Report Request (host-initiated). Reply mirrors
the unsolicited S6F11.
* S6F19/F20 — Individual Report Request (RPTID -> values).
* S6F21/F22 — Annotated Individual Report Request (RPTID -> (VID, value)).
* S2F21/F22 — Legacy Remote Command (no parameter list). Delegates
to the same HostCommandRegistry as S2F41.
* S12F9/F10 — Map Data Send (array format, MAPFT=1).
* S12F11/F12 — Map Data Send (coordinate format, MAPFT=2).
* S12F13/F14, F15/F16, F17/F18 — Map Data Request variants for the
row, array, and coordinate formats.
11 new round-trip tests; suite at 289 cases / 1495 assertions.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a Docker-based interop harness that drives the C++ server with
secsgem-py 0.3.0 as the active host and probes a secsgem-py-passive
equipment from a minimal C++ active client. Surfaces and fixes four
interoperability bugs uncovered by cross-testing:
* SEMI E5 identifier formatcodes are a U1|U2|U4|U8 wildcard;
secsgem-py picks the narrowest fitting width while our parsers
only accepted U4. `as_uN_scalar` / `as_iN_scalar` now accept
any unsigned/signed width and range-check the downcast.
* PPBODY (S7F3/F6) is "ASCII | Binary | List" per the spec;
secsgem-py defaults to ASCII. Added BINARY_OR_ASCII codegen
item type with `as_text_or_binary` accessor.
* S1F23/F24 Collection Event Namelist was unimplemented; added
schema + `vids_for(ceid)` accessor on EventReportSubscriptions
plus the dispatch handler.
* S10F1 was registered as a host->equipment handler, but per
SEMI E5 §12 S10F1 is equipment->host; S10F3 is the actual
host->equipment Terminal Display Single. Added an S10F3
handler alongside (we keep S10F1 too for backward compat).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The biggest single gap I called out in the GEM300 audit — closed.
E84 is the digital handshake between AMHS (Automated Material
Handling System) and the equipment for carrier load/unload. Unlike
the rest of GEM300, this isn't SECS messaging; it's a fixed set of
ten parallel boolean wires that follow a strict sequencing protocol
(E84-0710 §6.3).
Adds:
E84Signal enum CS_0/CS_1/VALID/TR_REQ/BUSY/COMPT/L_REQ/U_REQ/
READY/ES
E84SignalSet 10-bit bitmap with bool get/set
E84State Idle / CarrierPresent / ValidAsserted /
LoadReady / UnloadReady / Transferring /
Complete / EmergencyStop
E84StateMachine re-evaluates state on every signal change,
observable via set_state_change_handler
Joins EquipmentDataModel as `e84` (top-level — there's one per tool,
not per port). ES (emergency stop) dominates regardless of other
signals; COMPT and BUSY override the VALID-handshake states. Same
FSM drives real opto-isolated I/O lines (when wired through an
asio digital input adapter) and the back-to-back test simulation.
Six test cases cover the full load handshake trace (six transitions,
including the transient LoadReady-after-BUSY-drops state), the
unload variant via U_REQ, ES dominance + recovery, reset(), and
no-op suppression for idempotent signal writes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Extends the existing Clock with the metrics a host needs to gate
time-sensitive data against the equipment's sync state (E148 §6.3):
offset_seconds() current applied offset vs system clock
last_drift_seconds() signed drift observed at the most recent sync
sync_count() how many successful syncs have happened
sync_quality() Synchronized (|drift|<=1s) /
Drifting (<=60s) / Unsynchronized (>60s or
never synced)
The thresholds are tuneable per call; the defaults match typical fab
practice but the application can pass tighter bounds for tracelog-
sensitive flows. set_time_string() now snapshots the apparent delta
between the previously-applied offset and the new one as
last_drift_seconds_ at the moment of resync; no background timer.
Three new test cases cover the initial Unsynchronized state, a large
forward drift registering as Unsynchronized, and a same-value resync
landing as Synchronized.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The catalog had S14F9/F10 / F11/F12 specialized for E94 ControlJob;
this commit adds the generic E14 attribute access pair, the most-
queried half of the E39 surface area, backed by the CemObjectStore.
S14F1 / F2 GetAttr — OBJSPEC + OBJTYPE + ATTRID list ->
(ATTRID, VALUE) pairs + OBJACK
S14F3 / F4 SetAttr — same addressing, applies ATTRID/VALUE pairs,
reply echoes stored values + OBJACK
Server dispatches both into the CemObjectStore added in tranche G.
OBJTYPE validation is case-sensitive against the CemObjectType name
(Equipment / Subsystem / IODevice / Module / MaterialLocation).
Unknown objects return Denied_UnknownObject; type mismatches return
Denied_InvalidAttribute.
The shared AttrValue struct is declared external_struct: true on
F3/F4 so both directions share the same C++ type.
Two round-trip tests cover both pairs.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes the two E40 bulk/control gaps the COMPLIANCE doc had flagged
as out-of-scope:
S16F7 / F8 PRJobMonitor — host enables/disables S16F9 alerts
per PJ. PRALERT bit 7 is the enable flag (matches the
ALED convention from S5F3). Server dispatches into the
existing set_alert() store API.
S16F15 / F16 PRJobCreateMultiple — bulk create variant. Host posts
a list of (PRJOBID, PPID, MTRLOUTSPEC) entries; the
equipment processes them in order and returns a
per-PJ HCACK list so the host can identify which
subset failed. Same validators as S16F11.
Catalog now has three new structs: PRJobMonitorEntry,
PRJobCreateEntry, PRJobCreateMultiResult. Two round-trip tests cover
the new wire shapes; server-side correctness is exercised through the
existing PJ store invariants (dedup, validator) which both new paths
delegate to.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes the slot-map verification gap I called out:
S3F19 / F20 host -> equip: verify expected slot map against what
the equipment has scanned. Equipment compares element-
wise; on match drives CSMS NotRead -> Read and replies
SVACK=Accept; on mismatch drives CSMS -> Mismatched and
replies SVACK=Mismatch.
S3F21 / F22 equip -> host: equipment-initiated slot map report
(typically pushed after CARRIERID is confirmed).
New SVACK enum: SlotMapVerifyAck { Accept, Mismatch, CarrierUnknown,
Error }. Server dispatch on S3F19 wires the actual CSMS transition
through the CarrierStore from D3.
Two round-trip tests cover both pairs; the FSM-driving behaviour is
exercised through the in-process tests because secs_server.cpp is
the dispatch entry point (no separate integration test needed beyond
the wire round-trip).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>