From f9f1cceb82cd72f7dc52e59fe2060de29b148f31 Mon Sep 17 00:00:00 2001 From: znetsixe Date: Mon, 11 May 2026 20:22:05 +0200 Subject: [PATCH] =?UTF-8?q?docs:=20finalise=20CONTRACTS.md=20=C2=A74=20+?= =?UTF-8?q?=20WIKI=5FTEMPLATE.md=20tweaks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CONTRACTS.md §4: full payloadSchema.type table including 'none', plus the optional description field example. Matches the B3.2 implementation. WIKI_TEMPLATE.md §5: Unit column appears with explanatory paragraph. Matches the P11.4 wikiGen output. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/refactor/CONTRACTS.md | 123 +++++++++++++++++++++++++++++- .claude/refactor/WIKI_TEMPLATE.md | 11 ++- 2 files changed, 129 insertions(+), 5 deletions(-) diff --git a/.claude/refactor/CONTRACTS.md b/.claude/refactor/CONTRACTS.md index 45b7dc2..3623520 100644 --- a/.claude/refactor/CONTRACTS.md +++ b/.claude/refactor/CONTRACTS.md @@ -340,6 +340,7 @@ module.exports = [ topic: 'set.mode', aliases: ['setMode', 'changemode'], // legacy names payloadSchema: { type: 'string' }, + description: 'Switch the node between auto and manual control modes.', handler: handlers.setMode, }, { @@ -348,10 +349,79 @@ module.exports = [ payloadSchema: { type: 'object', properties: { source: { type: 'string' } } }, handler: handlers.startup, }, + { + topic: 'cmd.calibrate', + payloadSchema: { type: 'none' }, + description: 'Trigger a one-shot calibration. Payload is ignored.', + handler: handlers.calibrate, + }, ... ]; ``` +### `payloadSchema.type` values + +| Type | Meaning | +|---|---| +| `'string'` | `typeof payload === 'string'`. | +| `'number'` | `typeof payload === 'number'`. | +| `'boolean'` | `typeof payload === 'boolean'`. | +| `'object'` | Non-null object. Optional `properties: { key: 'typeName' }` enforces per-key `typeof` (missing keys allowed). | +| `'any'` | Anything passes. Use when the handler accepts heterogeneous payloads. | +| `'none'` | **Trigger-only.** Handler is invoked regardless of payload. If `msg.payload` is anything other than `undefined`/`null`, the registry logs a `warn` (`": payload ignored — this is a trigger-only topic"`) and still invokes the handler. Use for pure triggers (`cmd.calibrate`, `cmd.estop`, `set.simulator`, ...) — strict alternative to `'any'`. | + +### Optional `description` field + +A descriptor may include a free-text 1-line `description` string. It is surfaced by `.list()` (the docs surface) and consumed by `wikiGen`'s topic-contract auto-gen. Example: + +```js +{ topic: 'cmd.calibrate', payloadSchema: { type: 'none' }, description: 'Trigger a one-shot calibration.', handler: handlers.calibrate } +``` + +### Optional `units` field — pre-dispatch unit normalisation + +A descriptor for a numeric setter / data topic may declare: + +```js +units: { measure: '', default: '' } +``` + +- `measure`: a `convert`-recognised measure name (`volumeFlowRate`, `pressure`, `power`, `temperature`, `volume`, `length`, …). +- `default`: the unit the handler always receives. Operator-friendly (e.g. `m3/h`, `mbar`, `kW`, `C`). + +Validation: if `units` is present, both fields must be non-empty strings. The registry throws at construction otherwise. + +At dispatch time, **before** the handler runs and **before** payload-schema validation, the registry normalises the incoming msg: + +1. Extract value + unit. Three accepted shapes: + - `msg.payload` is a number → `value = msg.payload`, `unit = msg.unit`. + - `msg.payload = { value: , unit?: }` → use those (falls back to `msg.unit` if `payload.unit` is absent). + - Anything else (string, object without `value`, missing payload, …) → normalisation is skipped; the handler receives the raw msg unchanged. No crash. +2. Determine the unit-of-record: + - **No unit supplied** → silently assume `units.default`. + - **Unit recognised + correct measure** → `convert(value).from(unit).to(default)`. + - **Unit recognised but wrong measure** → log `warn` with the topic, the actual measure, the expected measure, and the accepted-unit list. Fall through with the supplied value assumed to already be in `default`. + - **Unit unrecognised** → log `warn` with the topic, the unknown unit, and the accepted-unit list. Fall through with the supplied value assumed to already be in `default`. +3. Rewrite the msg so the handler sees uniform inputs: + - `msg.payload` becomes the normalised number in `units.default` (the object form `{value, unit}` is flattened to a number). + - `msg.unit` is set to `units.default`. + +Accepted-unit lists come from `convert.possibilities(measure)`. If that helper is unavailable, the warn falls back to `(see convert docs)`. + +The `units` field is surfaced by `.list()` (so wikiGen + `query.units` can render the contract) and is `null` for descriptors that don't declare it. + +Example: + +```js +{ + topic: 'set.demand', + units: { measure: 'volumeFlowRate', default: 'm3/h' }, + payloadSchema: { type: 'number' }, + description: 'Operator demand setpoint.', + handler: handlers.setDemand, +} +``` + A handler is a pure function: ```js @@ -429,10 +499,36 @@ Methods on the resulting policy: |---|---| | `policy.canonical(type)` | Canonical unit for a measurement type. | | `policy.output(type)` | Display / IO unit for a measurement type. | +| `policy.curve(type)` | Curve-input unit for a measurement type (returns `null` if no `curve` was declared). | | `policy.resolve(candidate, expectedMeasure, fallback, label)` | Validate a user-supplied unit, fall back if invalid (logs `warn`). | | `policy.convert(value, fromUnit, toUnit, contextLabel)` | Strict conversion. | | `policy.containerOptions()` | Returns the option bag for a `MeasurementContainer`. | +### Dual access shape (method OR frozen property bag) + +`canonical`, `output`, and `curve` each work both as a method call AND as a +frozen own-property map. They are functions with `Object.defineProperty`-installed +non-writable, non-configurable own properties, frozen via `Object.freeze`: + +```js +policy.canonical('flow') // 'm3/s' (method) +policy.canonical.flow // 'm3/s' (property) +policy.output.pressure // 'mbar' (property) +policy.curve.control // '%' (property) + +policy.canonical.flow = 'tampered'; // TypeError in strict mode +delete policy.canonical.pressure; // TypeError +Object.isFrozen(policy.canonical); // true +``` + +The property-bag form is preferred in hot paths and tight inner loops (one +lookup vs one function call). The method form is preferred when the type is +itself dynamic (`policy.canonical(typeName)`). Both forms are first-class +parts of the contract — call sites may use whichever reads best. + +This replaces the per-node `_unitView` / `unitPolicyView` mirror that +pre-dated the dual-shape accessor — domains read `this.unitPolicy` directly. + `BaseDomain` reads `static unitPolicy` and passes `policy.containerOptions()` straight into `new MeasurementContainer(...)`. @@ -473,8 +569,14 @@ this.demandGate = new LatestWinsGate(async (demand) => { await this._dispatchDemandToChildren(demand); }); -// Caller side — never blocks. The latest demand always wins. +// Fire-and-forget — never blocks. The latest demand always wins. this.demandGate.fire(demand); + +// Await the per-fire settlement. +const result = await this.demandGate.fireAndWait(demand); +if (result && result.superseded === true) { + // A later fire/fireAndWait overwrote this one in the pending slot. +} ``` Guarantees: @@ -483,6 +585,25 @@ Guarantees: enqueued; intermediate ones are dropped. - After the in-flight call settles, the latest pending value fires. +### `fire(value)` vs `fireAndWait(value)` + +| Method | Returns | Settles when | +|---|---|---| +| `fire(value)` | `void` | n/a — caller never awaits. | +| `fireAndWait(value)` | `Promise` | THIS specific fire's dispatch settles. If a later fire (plain or awaited) overwrites this one in the pending slot, the returned promise **resolves** with the frozen sentinel `LatestWinsGate.SUPERSEDED = { superseded: true }`. If the dispatch itself throws, the promise still resolves (with `undefined`) and the error is recorded on `gate.lastError` — callers don't need try/catch. | + +The supersede-resolves-with-sentinel choice (rather than rejecting with +`'superseded'`) means consumers branch on a value: + +```js +const r = await gate.fireAndWait(v); +if (r && r.superseded) return; // dropped by a later fire +// ... otherwise r is the dispatch's return value +``` + +`drain()` remains the right tool for "wait until idle" (returns one +promise regardless of how many fires landed); `fireAndWait` is per-fire. + ## 9. `HealthStatus` A standardised shape for nodes that compute prediction quality / drift diff --git a/.claude/refactor/WIKI_TEMPLATE.md b/.claude/refactor/WIKI_TEMPLATE.md index eee65de..93919e7 100644 --- a/.claude/refactor/WIKI_TEMPLATE.md +++ b/.claude/refactor/WIKI_TEMPLATE.md @@ -126,12 +126,15 @@ Update this section when you rename or split a directory. > **Auto-generated** from `src/commands/index.js`. Do NOT hand-edit between the markers. Re-run `npm run wiki:contract`. +The **Unit** column reflects the descriptor's `units: { measure, default }` declaration, rendered as ` (default )`. Topics without a `units` field (non-quantity payloads — mode strings, child ids, sequence triggers) show `—`. The default unit is what the commandRegistry coerces incoming `msg.unit` values to before the handler runs. The **Effect** column is sourced from the descriptor's `description` field; topics without one fall back to a generic per-prefix sentence. + -| Canonical topic | Aliases | Payload | Effect | -|---|---|---|---| -| `set.mode` | `setMode` | `string` (`auto`\|`manual`\|`maintenance`) | Switches operating mode. | -| `cmd.startup` | `execSequence` (with `payload.action='startup'`) | `{source: string}` | Triggers startup sequence. | +| Canonical topic | Aliases | Payload | Unit | Effect | +|---|---|---|---|---| +| `set.mode` | `setMode` | `string` (`auto`\|`manual`\|`maintenance`) | — | Switches operating mode. | +| `set.demand` | `Qd` | `number` | `volumeFlowRate` (default `m3/h`) | Sets the manual demand setpoint. | +| `cmd.startup` | `execSequence` (with `payload.action='startup'`) | `{source: string}` | — | Triggers startup sequence. |