diff --git a/README.md b/README.md index c7d335d..8944670 100644 --- a/README.md +++ b/README.md @@ -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 ` for a type's exact ports and the op-script grammar in the design ledger for the full semantics. diff --git a/docs/authoring-guide.md b/docs/authoring-guide.md index 4216c9e..cf3d481 100644 --- a/docs/authoring-guide.md +++ b/docs/authoring-guide.md @@ -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 `.` on both sides of a wire. -### The seven ops +### The eight ops | op | JSON shape | does | |---|---|---| @@ -226,7 +226,8 @@ are dotted `.` on both sides of a wire. | `add` | `{"op":"add","type":,"name":?,"bind":{:}?}` | 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":,"into":[, …]}` | 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":,"to":}` | 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":,"as":}` | 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":,"as":}` | 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":,"as":}` | 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 fast−slow 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":,"field":0}}` — the name is +addressed, the index is resolved *for* you). A single `aura run ` +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:00–09:15 bar closes at **09:15 → 1**, +09:30–09:45 at **09:45 → 3**, 10:00–10: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 ``` diff --git a/docs/design/INDEX.md b/docs/design/INDEX.md index 6db0835..356bd1b 100644 --- a/docs/design/INDEX.md +++ b/docs/design/INDEX.md @@ -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 -`.`. The seven verbs: +`.`. The eight verbs: - `source` — `{"op":"source","role":,"kind":}` — declare a root source role producing a base column of `kind`. - `input` — `{"op":"input","role":}` — 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":,"as":}` — 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":,"as":}` — 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 diff --git a/docs/glossary.md b/docs/glossary.md index 8e905dc..1b347f9 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -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