# Generic twin glue: stimulus derived from the wiring

For twins too large to hand-write behaviour (the 63-component Wildfire twin),
the glue is derived from the twin's own `.vsi.cmd`: every connected signal
gets a counter at its source and a monitor at its destination, every client
writes a CSV trace, and a checker verifies the run from those CSVs.

| file | role |
|---|---|
| `generic_stim.h` | header-only, no VSI dependency: `produce(step, k)` (an 8-bit counter, distinct per signal, advancing every `TWIN_PERIOD` steps), `Consumer` (last value, change count, silent steps) and a CSV `Trace` |
| `apply_glue_generic.py <twin.vsi.cmd> <twin dir>` | reads the command file, assigns roles, writes the glue into every component's user regions, copies the header into `src`, writes `<twin dir>/glue_wiring.json` |
| `check_traces.py <twin dir> [--traces <dir>]` | after a run: each consumed signal must follow its producer within `--lag` steps (mod 256) and change often enough. `--selftest` compiles the C++ formula and compares it with the checker's copy, fabricates a run from the wiring (must pass), then freezes one consumer column (must fail) |

## Roles, decided from the command file

- `connect signals A.x -> B.y`: `A.x` PRODUCES, `B.y` CONSUMES.
- CAN/LIN `define frame` with one id on many components: every node's generated
  stub transmits that frame each step and unpacks any received frame into the
  same fields, so the FIRST component defining the id PRODUCES its frame signals
  and every other node CONSUMES the same-position signal. Wildfire: one id,
  `0x100`, owned by `ADS_B_Transceiver`, 21 consumers. Values are kept within 8
  bits because the frame layout is 8 bits per signal.
  ⚠ **Consumers must not transmit (measured 2026-09-08).** Left as generated, the
  21 consumers each put a zero-payload frame with the SAME id on the bus every
  step and every consumer read 0 for all 7 fields for the whole run while the
  owner sent 96+step (147 of 177 links flat). The stub's `sendCanPacket()` sits
  outside every user region, beside the same component's generic-payload sends,
  so it can neither be wrapped nor skipped: an `if (false) {` across the regions
  also muted those sends (13 links flat, second run), and an early `return`
  would skip the end-of-step handshake and deadlock `mainThread`. The glue now
  puts `#define sendCanPacket() setCanId(256)` in the globals region of each
  consumer (`glue_wiring.json` lists them under `silent_can`), which rewrites the
  one call site into a harmless call and nothing else. The owner is untouched.
  `silent_can` is scoped to the FRAME BUS, not to the node: a listed component still
  produces on its other ports (Wildfire_Detection_Payload is muted on PayloadControlBus and
  transmits on PayloadPowerInhibitDiscrete; a checker in the vsi-message-flow repository
  that assumed otherwise failed three of four runs on it, 2026-09-09). Any tool reading the
  list must pair it with the frame, never treat it as "this node sends nothing".
  ⚠ **Precondition, and a retracted control (same day, 2026-09-08).** vsiBuild
  emits the send call only for a node that has OUTPUT or IN_OUT signals on the
  frame. The ICD generator declares every bus signal `int:in_out`, so in Wildfire
  all 22 CAN nodes carry `canGateway->sendCanPacket()` (counted in the generated
  sources). A 3-node twin from another session (`D:\vw4\can3.vsi.cmd`) was briefly
  cited here as a negative at small scale; it was not a valid control, because its
  two consumers were declared `input` only and contained no send call at all, so
  it never had the mechanism. That session retracted it. The finding stands as
  measured (21 transmitting consumers, 147 links flat); no valid small-twin
  control exists yet, so the threshold, if there is one, is unmeasured. A faithful
  minimal repro is three nodes on one frame where each owns one field and receives
  the others, which gives all three a send call.
- Ethernet `portSocket`: sockets carry no signals in the generated twin, so
  those signals are traced only, and the checker lists such components as
  "nothing to check" rather than as passes.

## Wildfire, 2026-09-06

`apply_glue_generic.py wildfire_grouped.vsi.cmd workspace/WildfireDT`: 63
components, 18 produced signals, 177 consumed, 18 trace-only. Self-test: C++
and Python formula equal over 2,400 values; fabricated run 177 pass / 0 fail;
negative control detected. `mingw32-make compile build`: exit 0 in 638 s, 63 of 63
clients plus FabricServer, 0 errors, 380 warnings (vsiBuild's own, none from the glue).

## Wildfire RUN, 2026-09-08 (EXERCISED, VeloceStratoOS on saltd)

From the twin folder, `env_vsi.ps1` sourced and the mingw64 `bin` on PATH:

    vsiSim.exe WildfireDT.dt --batch --run
    python ..\..\twin_glue\check_traces.py . --traces vsi.sim\_logs

(`mingw32-make sim` alone waits forever for a `run` command in the Simulation
Control window; `--batch --run` exits by itself after the 10 ms.) Four launches:

| run | glue | result |
|---|---|---|
| 1 | as of 2026-09-06 | 27 pass / 150 fail: all 147 CAN links flat at 0 (consumers transmitting, above) plus 3 links delivered at lag 0 that the checker excluded |
| 2 | `if (false)` wrap | 164 pass / 13 fail: the wrap also muted the generic-payload sends of `Edge_Detection_Processor` (edpGimbal, 12 links) and `Wildfire_Detection_Payload` (plInhibit) |
| 3 | macro mute | never started: all 63 clients at "Connecting ... to server@localhost", fabric "Waiting for client connections", no licence request, no trace; killed at 7 min. Root cause found the same day by another session (rm-uo6rsn4x): the fabric binds fixed ports 50101+index inside the Windows ephemeral range and one was held by an unrelated socket; fixed machine-wide with an administered exclusion 50101-50200 |
| 4 | macro mute, retry | **177 pass / 0 fail / 0 missing**, 25 nothing-to-check, 63 traces of 100 rows, 98 s wall clock |

Read the trace count and the checker, never the launcher's exit code: FabricServer
exits 0xC0000374 at teardown after "Simulation stopped by user" with every trace
complete, and `--enableStats` then reports exit 1 on a run that succeeded.

## Per-bus periods (EXERCISED 2026-09-08 20:41, for the message-flow animation rm-7h76ks76)

Every producing bus now ticks at its own rate. `apply_glue_generic.py` reads the
generator's `# bus <name> [<protocol>]` comment above each connectivity group,
gives each producing bus the next entry of `PERIODS = [1, 2, 3, 4, 5, 6, 8, 10, 12]`
in file order, and bakes it into the generated call, `twin::produce(g_step, k, period)`.
`glue_wiring.json` records the table under `buses` (protocol, period, signals) and
each produce entry carries its period as a third element. `TWIN_PERIOD` is now only
the fallback for the two-argument `produce`. Wildfire's nine producing buses:

| bus | protocol | period | producer changes in 100 steps (measured / predicted) |
|---|---|---|---|
| RangefinderSerial | Uart | 1 | 99 / 99 |
| PayloadPowerInhibitDiscrete | Gpio | 2 | 49 / 49 |
| FlightTerminationDiscrete | Gpio | 3 | 33 / 33 |
| TowerCameraControl | Uart | 4 | 24 / 24 |
| GimbalSerialControl | Uart | 5 | 19 / 19 |
| WeightOnWheelsDiscrete | Gpio | 6 | 16 / 16 |
| TimeReference | Gpio | 8 | 12 / 12 |
| LaserInhibitDiscrete | Gpio | 10 | 9 / 9 |
| PayloadControlBus | Can (0x100) | 12 | 8 / 8, all seven fields |

`check_traces.py` moved with it: the change floor per link is `steps // period - lag`
using the producer's bus period, and a new **producer-rate check** requires every
producer column to change exactly `(steps - 1) // period` times, reported as
`rate_ok` / `rate_bad` and folded into the verdict. That check is what proves the
declared period landed in the run; the link check alone would pass a producer at
the wrong rate because consumers still follow it. The self-test now has four
parts: formula equality over 12,000 values across five periods, a fabricated run at
the declared periods (177 pass, 18 rate_ok), a frozen consumer (detected), and a
producer forced to period 1 against a declared 12 (rate_bad 1, detected).

Rebuild 440 s, 63 of 63; run 110 s; **177 pass / 0 fail / 0 missing, 18 of 18
producers at their declared rate**. Traces in `runs/20260908-2042-wildfire-perbus-177pass/`.

## Python blocks (EXERCISED 2026-09-08 21:28, mixed C++/Python twin, 177 pass)

`icd_to_vsi.py --python-blocks <regex>` emits the matching blocks as vsiBuild
**Python** components (`add component -type Python`, `python2Dt*` gateways; Can,
Ethernet, GenericPayload, Gpio and Lin exist in `lib/pythonGateways`). Everything
else stays C++. The glue handles both languages from one script:

- `generic_stim.py` is the Python twin of `generic_stim.h`: same `produce()` (checked
  equal to the checker's copy over 12,000 values), same `Consumer`, same CSV `Trace`.
  A `Glue` object holds step, trace and consumers so the generated method can advance
  the step without a `global` statement, and `MutedSend` wraps the CAN gateway alias
  so a consumer's stub never transmits (the Python form of the C++ macro mute).
- `apply_glue_generic.py` recognises both region markers (`//` and `#`), patches
  `<comp>.py` or `<comp>.cxx` by the component's declared type, copies both stimulus
  files into `src`, and `py_compile`s every Python component it touched.
  `glue_wiring.json` records `lang` per component and a `python_components` list.
- The generated Makefile runs a Python client from the TWIN ROOT (`python src/<c>/<c>.py`),
  not from `vsi.sim/_logs` like a C++ client, so the Python `Trace` writes into
  `vsi.sim/_logs` when it can see it; one `--traces` directory serves the checker.

Proof: `wildfire_py.vsi.cmd`, generated to `D:/wfpy` (Python gateways nest a build
directory that breaks Windows MAX_PATH from the repo path; the peer session measured
it), with `Navigation_Unit` (CAN consumer + GenericPayload consumer),
`Landing_Gear_System` and `Flight_Termination_System` (GenericPayload producers) and
`Safety_Interlock_Unit__siuWow` (consumer of a Python producer) as Python. Build
(`mingw32-make compile build`) 59 C++ clients + 4 Python gateway extensions, run
145 s, **177 pass / 0 fail / 0 missing, 18 of 18 producers at declared rate**, so
Python to C++, C++ to Python and Python to Python all carry the counters, and the
Python CAN consumer reads the C++ producer's frame with the mute active. Traces in
`runs/20260908-2130-wildfire-python4-177pass/`.

⚠ **vsiBuild 2026.2 defect, Python + Ethernet.** A Python component whose Ethernet
socket carries no connected signals is generated with an EMPTY
`def establishTcpUdpConnection(self):` and is an IndentationError before any user code
(`Gimbal_Video_Aggregator` here; the peer session reproduced it with controls, a
socket WITH connected signals gets a real body, see `runs/evidence/`). This generator
connects no signals over Ethernet because Teamcenter records none, so `--python-blocks`
keeps any such block C++, prints a WARN and writes the demotion into the
`.assumptions.md`. The rule reads `SIGNAL_WIRED` in `icd_to_vsi.py`, the one set that
says which protocols get `connect signals`, so it stops firing the day Ethernet joins it.

## Limits

- This is connectivity stimulus, not system behaviour: it proves that what a
  producer sends arrives where the ICD says it should, with a bounded lag. The
  MSP-2 twins carry a scenario instead (`../msp2_glue`).
- The checker accepts a consumer value from the producer's steps `[s-lag, s]`
  (default `--lag 3`). Lag 0 is real: the fabric delivers within a step to any
  component scheduled after the producer (the VMS and Navigation Unit read
  navTime with no lag, the video aggregator with one), so the original
  `[s-lag, s-1]` window failed 3 correct links on the first real run.
- Reactions land one step after arrival by construction (inputs are unpacked
  by the generated callback and read in *After sending the packet*).
