← Files hgraph DevelopmentARCHIVED FILE
skills/hgraph-compute-sink-node/SKILL.md
12.8 KB · Oct 4, 2026 · 12:30 UTC
---
name: hgraph-compute-sink-node
description: Implement or review C++ hgraph compute nodes, sink nodes, algorithms, and concrete operator-node specializations. Use when adding or changing a static node `eval`, lifecycle hooks, local or recordable state, REF usage, incremental or windowed algorithms, input-type branching, operator strategy selection or overload registration, type-erased node strategy, node naming, complexity documentation, or native/Python behavioral tests for a compute or sink node.
---
# HGraph Compute and Sink Nodes
Build C++-first nodes whose per-tick path is small, whose lifecycle and state
semantics are explicit, and whose names and tests fit the operator system.
## Start from the existing contract
1. Read the current repository's `AGENTS.md` and the relevant operator
definition, implementation, registration, and tests before editing.
2. Establish whether the task targets hgraph core or a downstream extension:
- In the hgraph core checkout, read the applicable parts of
`docs/source/user_guide/cpp/authoring_nodes.rst` and use
`tests/cpp/test_static_node.cpp` as the compact executable reference for
lifecycle hooks, `State`, and `RecordableState`.
- In a downstream project, use that project's extension layout and tests,
plus the public hgraph headers and documentation for its pinned hgraph
version. Do not assume the hgraph core source tree is present.
3. Classify the work before choosing a type:
- Use a compute node for time-series input plus an output.
- Use a sink node for time-series input and a side effect with no output.
- Implement an operator specialization when the behavior belongs to an
existing abstract operator or valid implementations may vary by type or
algorithm.
- Introduce a standalone node only when the behavior is not an operator and
cannot be expressed clearly as a graph.
Keep static node implementation structs empty. Put instance data in the
appropriate selector or plan rather than in members.
Keep the node's per-tick implementation logic in `eval`. Extract a helper only
when it is a well-defined operation with its own clear contract or is genuinely
shared by multiple callers. Do not scatter one node's implementation across
single-use helper functions. Keep one-off lifecycle work in `start` and `stop`
as described below.
## Design the hot path first
Treat `eval` as the hot path. Leave only work that depends on the current tick.
- Prefer typed `In`, `Out`, `State`, and `RecordableState` access.
- Avoid schema discovery, registry lookup, overload selection, RTTI, string
construction, parsing, policy selection, and representation selection in
`eval`.
- Avoid avoidable allocation, container growth, reference counting, locking,
and Python conversion in `eval`. Reuse pre-sized storage when the algorithm
requires scratch space.
- Resolve wiring-time scalar policies to a concrete overload or plan. Do not
branch on a policy string every tick.
- Read each input view only as often as needed. Use modified/delta access when
full-value traversal is unnecessary.
- Emit no output when the contract calls for no tick; do not manufacture an
unchanged value merely to simplify control flow.
If expensive work appears unavoidable, state why, inspect neighboring hot-path
code, and add focused performance evidence for a material regression risk.
## Select input types through operators
Treat an `if`/`else`, `switch`, schema-kind test, RTTI check, or visitor branch
that chooses node behaviour from an input's type as a design signal: stop and
make the alternatives operator overloads. Wiring already knows the concrete
input schemas, so select the implementation once there instead of rediscovering
the type during `start` or on every `eval`.
- If an operator already describes the behaviour, implement and register a
more-specific node or graph overload.
- If no operator exists, introduce an implementation-free operator contract,
then register the concrete implementations under it. Follow
`../hgraph-write-operators/SKILL.md`.
- Keep each node signature as specific as its implementation. Template shared
code where useful, but register the concrete instances that wiring should
choose.
- Use a wiring-time scalar value and overload `requires_` predicate when a
fixed policy selects an implementation. Do not carry that choice into the
hot path.
- Use runtime switching only when the selecting value is itself time-series
data and the contract genuinely permits the implementation choice to change
while the graph runs.
Do not hide semantic type selection inside an erased helper or plan. An erased
ops-table may dispatch representation mechanics at a required abstraction
boundary; it must not replace the operator registry's wiring-time selection of
meaningfully different behaviours.
## Use REF deliberately
Treat `REF` as a semantic indirection with significant memory overhead, not as
the default way to connect nodes. Input bindings to non-`REF` outputs are
lightweight C++ structures.
- Use a normal binding when the consumer only needs to read an output.
- Use `REF` when indirection prevents a value from being copied from an input
to an output. Selection and routing operators such as `if_then` and `route`
are the primary pattern: capture the selected source output and direct that
source instead of copying its current value.
- Use `REF` when dynamic source identity is part of the operator contract.
- Before adding `REF`, state which copy or dynamic binding it avoids and verify
that plain input binding cannot express the behavior.
Do not reject `REF` merely because it is expensive. Use it whenever the
indirection is required, and pay its overhead intentionally.
## Place one-off work in lifecycle hooks
Use `start` for work performed once per node lifetime, including:
- initializing `State`, or seeding `RecordableState` only when it is invalid;
never overwrite recordable state restored for replay;
- initializing sequences, cursors, buffers, or cached plans associated with
that state;
- acquiring run-scoped resources or establishing subscriptions;
- scheduling the initial evaluation when declarative `schedule_on_start` is
insufficient.
Use `stop` to flush, finalize, unsubscribe, or release what `start` acquired.
Keep teardown safe for partial lifecycle progress and follow existing scope
guard patterns where rollback is required.
Request only the selectors each hook needs. When lifecycle-only arguments are
required, use the established explicit `signature_args` pattern rather than
polluting `eval`.
## Choose state by semantics
- Use no state for a pure transformation or side effect.
- Use `State<T>` for private, ephemeral implementation state that does not
participate in record/replay.
- Use `RecordableState<TSchema>` when a tick updates state that affects later
ticks: this is loopback or feedback state and should be observable and
restorable by record/replay.
- Do not combine `State` and `RecordableState` in one static node.
- Keep recordable state structured and typed. Update only the fields modified
by the current tick.
Do not replace semantic loopback state with an opaque cache merely because the
cache is easier to implement.
## Implement algorithms incrementally
Prefer an incremental algorithm that consumes the current tick or delta and
updates only the sufficient statistics needed for the result. Do not retain an
entire history or window in private state when an online formulation exists.
Minimal sufficient statistics are algorithmic state; they are preferable to
capturing the input history.
- Put incremental state that affects later ticks in `RecordableState`.
- When an exact result intrinsically requires the window contents, use `TSW`
as the semantic window rather than copying the window into private state.
Exact median is the standard example.
- Do not maintain a private duplicate of a `TSW` merely to simplify the
implementation.
- Test an incremental implementation against a simple full-recomputation
reference over multiple ticks, including removals and boundary conditions.
Document algorithmic cost beside the implementation and public operator:
- state the worst-case or amortized time cost per tick;
- state retained-memory cost;
- for `TSW`, express both costs in terms of window size `W` and document the
supported window semantics.
## Exploit concrete C++ types
Use type erasure at boundaries that genuinely need to accept independently
realized types. Once wiring or plan construction selects a concrete strategy,
make the per-tick implementation typed.
- Template an implementation when its scalar or time-series types vary, and
use those template parameters to remove runtime conversion and dispatch.
- Use the operator registry to choose a concrete typed overload instead of
inspecting `TSTypeKind`, scalar metadata, or a schema in the node.
- Select erased representation operations once from immutable wiring or plan
metadata, then dispatch through the installed ops table.
- Follow the repository passive ops-table plus explicit erased-ownership
pattern for a reusable erased contract. Do not add a facade `std::variant`
or scatter strategy branches through semantic node code.
- Preserve native C++ authoring as the primary path. Adapt Python values and
callables to the same node rather than implementing separate Python runtime
semantics.
## Name operator implementations consistently
Name a concrete operator node from the operator plus a concise specialization
suffix, using lower snake case.
- If the operator name ends in `_`, append the suffix directly:
`add_` + `float` becomes `add_float`.
- Otherwise insert `_` as the separator:
`debug_print` + `tsb` becomes `debug_print_tsb`.
- Describe the specialization by the distinguishing type, shape, policy, or
direction. Avoid generic suffixes such as `impl` when a precise name exists.
- Apply the same rule to the implementation type and any explicit diagnostic
`name` unless a nearby registration convention requires a different label.
Keep the abstract operator in its operator header, the concrete node under the
existing `impl` boundary, and register it through the neighboring operator
registration mechanism.
Use the operator as the public abstraction whenever implementation selection
may vary by type or algorithm. Keep concrete node choices behind overload
resolution or the operator's wiring contract.
When an operator offers meaningful algorithmic trade-offs, expose a strongly
typed enum as a wiring-time scalar policy:
- Give each enum value a name that describes the accepted property or risk.
- Document accuracy, overflow, numerical stability, time, and memory trade-offs
that differ between values.
- Select the concrete overload or immutable plan before execution; do not
switch on the enum in `eval`.
- Use a graph-level switch only when the policy is genuinely time-varying.
For example, average may offer sum divided by count, with overflow risk, and an
online recurrence based on the previous average, count, and next value, with
different floating-point accuracy behavior. Test every exposed strategy.
## Document standalone nodes
For a node that is not an operator, add documentation beside its public or
implementation declaration that states:
- why a primitive node is required instead of a graph or existing operator;
- its input activation and validity behavior;
- its output or side-effect contract;
- its lifecycle and state semantics;
- any intentional hot-path cost or external-resource behavior.
Add user-facing documentation when the standalone node is public. Do not add
domain-specific behavior to core merely because the algorithm is generic.
## Test through public wiring
1. Add native C++ behavior coverage using a concrete minimal graph and
`eval_node`. Cover sink side effects without driving runtime internals
directly.
2. Cover multiple ticks for stateful nodes, including initialization, update,
no-tick behavior, and teardown where relevant.
3. Prove recordable loopback state through its public recordable-state behavior.
4. Add equivalent Python authoring/bridge coverage for every Python-visible
behavior; keep the semantic implementation in C++.
5. Test invalid input, passive/active behavior, and lifecycle failure in
proportion to the risk.
6. Run focused tests while iterating, then every acceptance gate required by
the current repository `AGENTS.md`. Add an installed-SDK consumer check for
public-header changes and cross-platform validation for large runtime or
type-erasure changes.
Before handing off, review the final diff specifically for work that can move
out of `eval`, unnecessary `REF`, retained history that can become incremental,
state that should be recordable, undocumented per-tick or window cost, policy
branches that should select an overload, per-tick erased dispatch that can
become typed, and names that do not identify their operator specialization.
SHA-256: b1e7f23c9c4b21aea182c557956adbe382b0400dad4481958dacef7fb67f1470