docs: SessionFrankfurt output semantics + declared-tap authoring (#285)

Surfaced by the measurement-milestone fieldtest: a downstream author
reaching for session anchoring or declared taps had no public statement of
either — the semantics lived only in node source / the design ledger.

SessionFrankfurt / Session output semantics:
- glossary `session node`: corrected — it claimed three streams
  (`bars_since_open`, `in_session`, `session_open_ts`), but the shipped node
  emits ONE `i64` field `bars_since_open`. Now states the real contract: the
  count of completed bar-periods since the local (tz-aware, DST-correct)
  session open, close-instant indexed (09:15->1, 09:45->3; pre-open <=0), with
  the Frankfurt 09:00 Europe/Berlin open + `period_minutes` knob + the
  value-ignored `trigger`.
- authoring-guide: a "Session anchoring: the SessionFrankfurt preset"
  subsection — schema (trigger / period_minutes / bars_since_open), the
  close-instant multiples, the EqConst gating pattern, DST handling, and the
  add-snippet. Verified against `aura graph introspect --node SessionFrankfurt`.

Declared-tap authoring (C27 / the #284 tap op):
- authoring-guide: the `tap` op-table row (seven ops -> eight) + a worked
  "declaring a measurement tap" example. The example is verified: piping it
  through `aura graph build` emits
  `taps:[{"name":"spread","from":{"node":2,"field":0}}]`.
- design ledger op-list (INDEX.md): a `tap` bullet (seven verbs -> eight);
  also corrected the `expose` "only verb that keeps `as`" claim — `tap` keeps
  it too.
- README op-kind enumeration: adds `tap`.

Docs only; no code change. Both worked examples were run through the built
`aura` binary; the SessionFrankfurt schema was checked against introspect.

closes #285
This commit is contained in:
2026-07-18 16:31:17 +02:00
parent 43a1cc44d3
commit a5e02ff541
4 changed files with 79 additions and 8 deletions
+3 -2
View File
@@ -118,8 +118,9 @@ aura graph build < crossover.ops.json > crossover.bp.json
aura sweep crossover.bp.json --list-axes
```
The op kinds are `source`, `input`, `add`, `feed`, `connect`, `expose`, and
`gang`. See
The op kinds are `source`, `input`, `add`, `feed`, `connect`, `expose`, `tap`,
and `gang` (`tap` declares a recorded measurement point on an interior wire —
see the authoring guide). See
`aura graph introspect --node <T>` for a type's exact ports and the op-script
grammar in the design ledger for the full semantics.
+65 -2
View File
@@ -217,7 +217,7 @@ $ aura graph build < smacross.json > blueprint.json
Nodes are referenced by an **identifier** (given by `add`, see below); ports
are dotted `<identifier>.<port>` on both sides of a wire.
### The seven ops
### The eight ops
| op | JSON shape | does |
|---|---|---|
@@ -226,7 +226,8 @@ are dotted `<identifier>.<port>` on both sides of a wire.
| `add` | `{"op":"add","type":<TypeId>,"name":<str>?,"bind":{<param>:<Scalar>}?}` | instantiate a node of a type in the closed vocabulary (`aura graph introspect --vocabulary`) — see §0 below for how a type gets into that vocabulary in the first place. `name` becomes the node's identifier for later ops (default: the type's own lowercase label — two unnamed nodes of the same type then collide). `bind` sets zero or more of its params. |
| `feed` | `{"op":"feed","role":<str>,"into":[<port>, …]}` | fan a previously-declared role into one or more interior input slots, all-or-nothing (a failing target leaves none of the batch wired). |
| `connect` | `{"op":"connect","from":<port>,"to":<port>}` | wire one interior output field to one interior input slot. A `connect` that would close a dataflow cycle is rejected immediately — the only legal feedback path is an explicit delay/state node (domain invariant 5). |
| `expose` | `{"op":"expose","from":<port>,"as":<str>}` | promote an interior output field to a boundary output under the alias `as`the only op whose name key is a real *alias* (contrast `add`'s `name`, which is an identifier, not a rename). |
| `expose` | `{"op":"expose","from":<port>,"as":<str>}` | promote an interior output field to a boundary output under the alias `as`a real *alias* (a terminal boundary name, not a referenceable identifier like `add`'s `name`). Together with `tap`, one of the two ops whose `as` key is a terminal name rather than an identifier. |
| `tap` | `{"op":"tap","from":<port>,"as":<str>}` | declare a **measurement tap** on an interior output field under the name `as` — the output-side twin of `expose` (a recorded observation point, not a boundary output; a `Composite.taps` entry, C27). A single `aura run` constructs a recorder at each declared tap and persists its per-cycle series as a `ColumnarTrace`; a sweep leaves it inert. Tap names are their own namespace and must be unique (a second `tap` under one name refuses: `duplicate tap name`). |
| `gang` | `{"op":"gang","as":"channel_length","into":["channel_hi.length","channel_lo.length"]}` | Fuse two or more sibling params into ONE public knob: the member addresses leave the sweepable param space and `as` replaces them; the bound or swept value fans out to every member at bootstrap. Members must share one scalar kind and stay open (un-bound). |
Value forms are the typed-tag representations used everywhere in this
@@ -273,6 +274,68 @@ fused with its siblings under ONE public knob declared by a `gang` op; the
member addresses are unbindable and only the gang's own single-segment name —
wrapped like any knob, e.g. `graph.channel_length` — appears in `--list-axes`).
### Worked example: declaring a measurement tap
A **declared tap** (C27) names an interior output wire as a recorded
observation point — the output-side twin of `expose`, but a *measurement*
rather than a boundary output. It is authored by the same `"node.field"`
addressing every other op uses; there is no raw node index. Extending the
crossover above to record the raw fastslow spread (the signal *before* the
bias scaling) under the name `spread`:
```json
[
{"op": "source", "role": "price", "kind": "F64"},
{"op": "add", "type": "SMA", "name": "fast"},
{"op": "add", "type": "SMA", "name": "slow"},
{"op": "feed", "role": "price", "into": ["fast.series", "slow.series"]},
{"op": "add", "type": "Sub", "name": "sub"},
{"op": "connect", "from": "fast.value", "to": "sub.lhs"},
{"op": "connect", "from": "slow.value", "to": "sub.rhs"},
{"op": "add", "type": "Bias", "name": "bias"},
{"op": "connect", "from": "sub.value", "to": "bias.signal"},
{"op": "tap", "from": "sub.value", "as": "spread"},
{"op": "expose", "from": "bias.bias", "as": "bias"}
]
```
The built blueprint carries a `taps` array naming the resolved wire
(`{"name":"spread","from":{"node":<sub's index>,"field":0}}` — the name is
addressed, the index is resolved *for* you). A single `aura run <blueprint>`
then constructs a recorder at that point and persists the `spread` series as a
`ColumnarTrace` in the run's trace store, chartable by its name. A tap is inert
in a sweep (no per-cell recorder) — it is a single-run observation surface.
(`sub.value` is tapped here purely to illustrate; any interior output field is
a legal tap source, and a producer needs no other consumer to be tapped.)
### Session anchoring: the `SessionFrankfurt` preset
To anchor logic to the trading session, the closed vocabulary ships a
`SessionFrankfurt` node — the Frankfurt cash open (09:00 `Europe/Berlin`)
baked in, DST-correct. Its schema:
- **input** `trigger: f64` — wired from a once-per-bar stream (e.g. a 15m
resampler's `close`); its *value is ignored*, it only clocks the node to fire
once per completed bar;
- **param** `period_minutes: i64` — the bar width to index by (conventionally
`15`, matching the resampler);
- **output** `bars_since_open: i64` — the count of completed `period_minutes`
bars since the local session open, in tz-aware local wall-clock time.
Because a resampler emits on exact `:00/:15/:30/:45` boundaries and `ctx.now()`
at emission is the *just-closed* bar's close instant, in-session bar closes
read exact positive multiples: the 09:0009:15 bar closes at **09:15 → 1**,
09:3009:45 at **09:45 → 3**, 10:0010:15 at **10:15 → 5**. Pre-open instants
give `<= 0` (non-actionable). To gate on "the third bar of the session",
compare `bars_since_open` against a constant (an `EqConst(==3)`); there is no
separate in-session bool — `bars_since_open` alone is the contract. DST is
handled by `chrono-tz`, so the same local minute reads the same index in
summer (CEST) and winter (CET).
Wire it like any node —
`{"op":"add","type":"SessionFrankfurt","bind":{"period_minutes":{"I64":15}}}`,
feed its `trigger` from a once-per-bar stream, and read `bars_since_open`.
### Commands
```
+10 -3
View File
@@ -2373,7 +2373,7 @@ fixture from the scaffolder.
**Op-script grammar (`aura graph build`, the #157 wire surface).** The construction
op-script is a JSON **array of ops**, each object internally tagged by `"op"`,
replayed in order; nodes are referenced **by identifier**, ports as dotted
`<identifier>.<port>`. The seven verbs:
`<identifier>.<port>`. The eight verbs:
- `source``{"op":"source","role":<str>,"kind":<ScalarKind>}` — declare a root
source role producing a base column of `kind`.
- `input``{"op":"input","role":<str>}` — declare a root input role (kind inferred
@@ -2388,8 +2388,15 @@ replayed in order; nodes are referenced **by identifier**, ports as dotted
field to an input slot; a `connect` closing a cycle is rejected eagerly
(invariant 5).
- `expose``{"op":"expose","from":<port>,"as":<str>}` — promote an interior output
field to a boundary output, aliased by `as` **the only verb that keeps `as`**
(a real alias, not a naming; contrast `add`'s `name`).
field to a boundary output, aliased by `as` (a real alias, not a naming; contrast
`add`'s `name`) — one of the two verbs that keep `as` (with `tap`).
- `tap``{"op":"tap","from":<port>,"as":<str>}` — declare a measurement **tap** on
an interior output field under `as` (#284) — the output-side twin of `expose`: a
recorded observation point (a `Composite.taps` entry, C27), not a boundary output.
Name-addressed like every other op (no raw index); tap names are their own
namespace (a duplicate refuses). The `finish` gate threads op-declared taps into
the built `Composite` (`.with_taps`); a single `aura run` records each, a sweep
leaves them inert.
- `gang``{"op":"gang","as":"channel_length","into":["channel_hi.length","channel_lo.length"]}`
— fuse two or more sibling params into ONE public knob: the member addresses
leave the sweepable param space and `as` replaces them; the bound or swept
+1 -1
View File
@@ -259,7 +259,7 @@ The four streamed scalar kinds — `i64`, `f64`, `bool`, `timestamp` (a newtype
### session node
**Avoid:** session window
A node that exposes session context as scalar streams (`bars_since_open`, `in_session`, `session_open_ts`) so session logic stays inside the stream model. Calendars and instrument specs remain metadata beside the hot path.
A node that exposes session context as a scalar stream so session logic stays inside the stream model. The shipped `Session` / `SessionFrankfurt` node (`aura-std`) emits ONE `i64` field, `bars_since_open` — the count of completed bar-periods since the local (tz-aware, DST-correct) session open, indexed off the just-closed bar's close instant (in-session closes read exact positive multiples: 09:15→1, 09:45→3; pre-open ≤0). `SessionFrankfurt` bakes the Frankfurt 09:00 `Europe/Berlin` open with a single `period_minutes` knob; its `trigger` input only clocks it once per completed bar (the value is ignored). Calendars and instrument specs remain metadata beside the hot path.
### sign-agreement
**Avoid:** sign consistency, market breadth